@effing/ffs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -0
- package/README.md +368 -0
- package/dist/cache-BUVFfGZF.d.ts +25 -0
- package/dist/chunk-LK5K4SQV.js +439 -0
- package/dist/chunk-LK5K4SQV.js.map +1 -0
- package/dist/chunk-RNE6TKMF.js +1190 -0
- package/dist/chunk-RNE6TKMF.js.map +1 -0
- package/dist/handlers/index.d.ts +57 -0
- package/dist/handlers/index.js +18 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +1656 -0
- package/dist/server.js.map +1 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
O'Saasy License
|
|
2
|
+
|
|
3
|
+
Copyright © 2026, Trackuity BV.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# @effing/ffs
|
|
2
|
+
|
|
3
|
+
**FFmpeg-based video renderer for Effie compositions.**
|
|
4
|
+
|
|
5
|
+
> Part of the [**Effing**](../../README.md) family — programmatic video creation with TypeScript.
|
|
6
|
+
|
|
7
|
+
Takes an `EffieData` composition and renders it to an MP4 video using FFmpeg. Use as a library or run as a standalone HTTP server.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @effing/ffs
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
FFmpeg is bundled via `ffmpeg-static` — no system installation required.
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
### As a Library
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { EffieRenderer } from "@effing/ffs";
|
|
23
|
+
|
|
24
|
+
const renderer = new EffieRenderer(effieData);
|
|
25
|
+
const videoStream = await renderer.render();
|
|
26
|
+
|
|
27
|
+
// Pipe to file
|
|
28
|
+
videoStream.pipe(fs.createWriteStream("output.mp4"));
|
|
29
|
+
|
|
30
|
+
// Or pipe to HTTP response
|
|
31
|
+
videoStream.pipe(res);
|
|
32
|
+
|
|
33
|
+
// Clean up when done
|
|
34
|
+
renderer.close();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### As an HTTP Server
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Run the server
|
|
41
|
+
npx @effing/ffs
|
|
42
|
+
|
|
43
|
+
# Or with custom port
|
|
44
|
+
FFS_PORT=8080 npx @effing/ffs
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Rendering is a two-step process with the HTTP server: first obtain a stream URL, then stream that URL to get the video.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 1. Obtain a stream URL
|
|
51
|
+
curl -X POST http://localhost:2000/render \
|
|
52
|
+
-H "Content-Type: application/json" \
|
|
53
|
+
-d @composition.json
|
|
54
|
+
# Returns: { "id": "...", "url": "http://localhost:2000/render/123e4567-e89b-12d3-a456-426614174000" }
|
|
55
|
+
|
|
56
|
+
# 2. Stream the URL to get the video
|
|
57
|
+
curl http://localhost:2000/render/123e4567-e89b-12d3-a456-426614174000 -o output.mp4
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
#### Environment Variables
|
|
61
|
+
|
|
62
|
+
| Variable | Description |
|
|
63
|
+
| ----------------------- | --------------------------------------------- |
|
|
64
|
+
| `FFS_PORT` | Server port (default: 2000) |
|
|
65
|
+
| `FFS_BASE_URL` | Base URL for returned URLs |
|
|
66
|
+
| `FFS_API_KEY` | API key for authentication (optional) |
|
|
67
|
+
| `FFS_CACHE_BUCKET` | S3 bucket for cache (enables S3 mode) |
|
|
68
|
+
| `FFS_CACHE_ENDPOINT` | S3-compatible endpoint (for e.g. R2 or MinIO) |
|
|
69
|
+
| `FFS_CACHE_REGION` | AWS region (default: "auto") |
|
|
70
|
+
| `FFS_CACHE_PREFIX` | Key prefix for cached objects |
|
|
71
|
+
| `FFS_CACHE_ACCESS_KEY` | S3 access key ID |
|
|
72
|
+
| `FFS_CACHE_SECRET_KEY` | S3 secret access key |
|
|
73
|
+
| `FFS_CACHE_LOCAL_DIR` | Local cache directory (when not using S3) |
|
|
74
|
+
| `FFS_CACHE_TTL_MS` | Cache TTL in milliseconds (default: 60 min) |
|
|
75
|
+
| `FFS_CACHE_CONCURRENCY` | Concurrent fetches during warmup (default: 4) |
|
|
76
|
+
|
|
77
|
+
When `FFS_CACHE_BUCKET` is not set, FFS uses the local filesystem for caching (default: system temp directory). Local cache files are automatically cleaned up after the TTL expires.
|
|
78
|
+
|
|
79
|
+
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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html) on your bucket to delete expired objects.
|
|
80
|
+
|
|
81
|
+
## Concepts
|
|
82
|
+
|
|
83
|
+
### EffieRenderer
|
|
84
|
+
|
|
85
|
+
The main class that orchestrates video rendering:
|
|
86
|
+
|
|
87
|
+
1. **Builds FFmpeg command** — Constructs complex filter graphs for overlays, transitions, effects
|
|
88
|
+
2. **Fetches sources** — Downloads images, animations, and audio from URLs
|
|
89
|
+
3. **Processes layers** — Applies motion, effects, and timing to each layer
|
|
90
|
+
4. **Outputs video** — Streams H.264/AAC MP4 to stdout or file
|
|
91
|
+
|
|
92
|
+
## API Overview
|
|
93
|
+
|
|
94
|
+
### EffieRenderer
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
class EffieRenderer {
|
|
98
|
+
constructor(effieData: EffieData<EffieSources>);
|
|
99
|
+
|
|
100
|
+
// Render composition
|
|
101
|
+
render(scaleFactor?: number): Promise<Readable>;
|
|
102
|
+
|
|
103
|
+
// Clean up FFmpeg process
|
|
104
|
+
close(): void;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### FFmpegCommand & FFmpegRunner
|
|
109
|
+
|
|
110
|
+
Lower-level classes for building and executing FFmpeg commands:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import { FFmpegCommand, FFmpegRunner } from "@effing/ffs";
|
|
114
|
+
|
|
115
|
+
const cmd = new FFmpegCommand(globalArgs, inputs, filterComplex, outputArgs);
|
|
116
|
+
const runner = new FFmpegRunner(cmd);
|
|
117
|
+
const output = await runner.run(fetchSource, transformImage);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Processing Functions
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { processMotion, processEffects, processTransition } from "@effing/ffs";
|
|
124
|
+
|
|
125
|
+
// Convert motion config to FFmpeg overlay expression
|
|
126
|
+
const overlayExpr = processMotion(delay, motionConfig);
|
|
127
|
+
|
|
128
|
+
// Build effect filter chain
|
|
129
|
+
const filters = processEffects(effects, fps, width, height);
|
|
130
|
+
|
|
131
|
+
// Get FFmpeg transition name
|
|
132
|
+
const xfadeName = processTransition(transition);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Server Endpoints
|
|
136
|
+
|
|
137
|
+
When running as an HTTP server, FFS provides endpoints for rendering, cache warmup, and cache purging.
|
|
138
|
+
|
|
139
|
+
### `POST /render`
|
|
140
|
+
|
|
141
|
+
Creates a render job and returns a stream URL to execute it. Supports two request formats:
|
|
142
|
+
|
|
143
|
+
**Raw EffieData**: Body is raw EffieData, options in query params.
|
|
144
|
+
|
|
145
|
+
| Query Param | Effect |
|
|
146
|
+
| ----------- | ------------------------- |
|
|
147
|
+
| `scale` | Scale factor (default: 1) |
|
|
148
|
+
|
|
149
|
+
**Wrapped format**: Body contains `effie` plus options.
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
type RenderOptions = {
|
|
153
|
+
effie: EffieData | string; // EffieData object or URL to fetch from
|
|
154
|
+
scale?: number; // Scale factor (default: 1)
|
|
155
|
+
upload?: {
|
|
156
|
+
videoUrl: string; // Pre-signed URL to upload rendered video
|
|
157
|
+
coverUrl?: string; // Pre-signed URL to upload cover image
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| Option | Effect |
|
|
163
|
+
| -------------- | ---------------------------------------------- |
|
|
164
|
+
| `effie` as URL | Fetches EffieData from the URL before storing |
|
|
165
|
+
| `upload` | GET will upload and stream SSE progress events |
|
|
166
|
+
|
|
167
|
+
**Response:**
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
172
|
+
"url": "http://localhost:2000/render/550e8400-e29b-41d4-a716-446655440000"
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### `GET /render/:id`
|
|
177
|
+
|
|
178
|
+
Executes the render job. Behavior depends on whether `upload` was specified:
|
|
179
|
+
|
|
180
|
+
**Without upload** — Streams the MP4 video directly:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
curl http://localhost:2000/render/550e8400-... -o output.mp4
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**With upload** — Streams progress via Server-Sent Events (SSE) while uploading:
|
|
187
|
+
|
|
188
|
+
| Event | Data |
|
|
189
|
+
| ----------- | ---------------------------------------------------------- |
|
|
190
|
+
| `started` | `{ "status": "rendering" }` |
|
|
191
|
+
| `keepalive` | `{ "status": "rendering" }` or `{ "status": "uploading" }` |
|
|
192
|
+
| `complete` | `{ "status": "uploaded", "timings": {...} }` |
|
|
193
|
+
| `error` | `{ "message": "..." }` |
|
|
194
|
+
|
|
195
|
+
**Example:**
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// Create render job
|
|
199
|
+
const { url } = await fetch("/render", {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { "Content-Type": "application/json" },
|
|
202
|
+
body: JSON.stringify({ effie: effieData, scale: 0.5 }),
|
|
203
|
+
}).then((r) => r.json());
|
|
204
|
+
|
|
205
|
+
// Stream video directly
|
|
206
|
+
const videoResponse = await fetch(url);
|
|
207
|
+
const videoBlob = await videoResponse.blob();
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### `POST /warmup`
|
|
211
|
+
|
|
212
|
+
Creates a warmup job for pre-fetching and caching the sources from an Effie composition. Use this to avoid render timeouts when sources (especially annies) take a long time to generate.
|
|
213
|
+
|
|
214
|
+
**Request:** Same format as `/render` (raw or wrapped EffieData with `effie` field).
|
|
215
|
+
|
|
216
|
+
**Response:**
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{
|
|
220
|
+
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
221
|
+
"url": "http://localhost:2000/warmup/550e8400-e29b-41d4-a716-446655440000"
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### `GET /warmup/:id`
|
|
226
|
+
|
|
227
|
+
Runs the cache warmup job and streams the progress via Server-Sent Events (SSE). Connect with `EventSource` for real-time updates.
|
|
228
|
+
|
|
229
|
+
**Events:**
|
|
230
|
+
|
|
231
|
+
| Event | Data |
|
|
232
|
+
| ------------- | -------------------------------------------------------------------------------------------- |
|
|
233
|
+
| `start` | `{ "total": 5 }` |
|
|
234
|
+
| `progress` | `{ "url": "...", "status": "hit"\|"cached"\|"error", "cached": 2, "failed": 0, "total": 5 }` |
|
|
235
|
+
| `downloading` | `{ "url": "...", "status": "downloading", "bytesReceived": 1048576 }` |
|
|
236
|
+
| `keepalive` | `{ "cached": 2, "failed": 0, "total": 5 }` |
|
|
237
|
+
| `summary` | `{ "cached": 5, "failed": 0, "total": 5 }` |
|
|
238
|
+
| `complete` | `{ "status": "ready" }` |
|
|
239
|
+
|
|
240
|
+
**Example:**
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
// Create warmup job
|
|
244
|
+
const { url } = await fetch("/warmup", {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "Content-Type": "application/json" },
|
|
247
|
+
body: JSON.stringify({ effie: effieData }),
|
|
248
|
+
}).then((r) => r.json());
|
|
249
|
+
|
|
250
|
+
// Stream progress (url is a full URL)
|
|
251
|
+
const events = new EventSource(url);
|
|
252
|
+
events.addEventListener("complete", () => {
|
|
253
|
+
events.close();
|
|
254
|
+
// Now safe to call /render
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### `POST /purge`
|
|
259
|
+
|
|
260
|
+
Purges cached sources for a given Effie composition.
|
|
261
|
+
|
|
262
|
+
**Request:** Same format as `/render` (raw or wrapped EffieData with `effie` field).
|
|
263
|
+
|
|
264
|
+
**Response:**
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
{ "purged": 3, "total": 5 }
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Examples
|
|
271
|
+
|
|
272
|
+
### Scale Factor for Previews
|
|
273
|
+
|
|
274
|
+
Render at reduced resolution for faster previews:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
const renderer = new EffieRenderer(video);
|
|
278
|
+
|
|
279
|
+
// Render at 50% resolution
|
|
280
|
+
const previewStream = await renderer.render(0.5);
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Distributed Rendering
|
|
284
|
+
|
|
285
|
+
For videos with many segments, you can render in parallel using the partitioning helpers from `@effing/effie`:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
import { EffieRenderer } from "@effing/ffs";
|
|
289
|
+
import { effieDataForSegment, effieDataForJoin } from "@effing/effie";
|
|
290
|
+
|
|
291
|
+
const effieData = /* ... */;
|
|
292
|
+
|
|
293
|
+
// 1. Render each segment (can be parallelized across workers/servers)
|
|
294
|
+
const segmentUrls = await Promise.all(
|
|
295
|
+
effieData.segments.map(async (_, i) => {
|
|
296
|
+
const segEffie = effieDataForSegment(effieData, i);
|
|
297
|
+
const renderer = new EffieRenderer(segEffie);
|
|
298
|
+
const stream = await renderer.render();
|
|
299
|
+
// Upload to storage and get URL
|
|
300
|
+
const url = await uploadToStorage(stream, `segment_${i}.mp4`);
|
|
301
|
+
renderer.close();
|
|
302
|
+
return url;
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// 2. Join segments with transitions and global audio
|
|
307
|
+
const joinEffie = effieDataForJoin(effieData, segmentUrls);
|
|
308
|
+
const joinRenderer = new EffieRenderer(joinEffie);
|
|
309
|
+
const finalStream = await joinRenderer.render();
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Server API Examples
|
|
313
|
+
|
|
314
|
+
**Create render job and stream video:**
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
// Create render job
|
|
318
|
+
const { url } = await fetch("http://localhost:2000/render", {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: { "Content-Type": "application/json" },
|
|
321
|
+
body: JSON.stringify({ effie: effieData, scale: 0.5 }),
|
|
322
|
+
}).then((r) => r.json());
|
|
323
|
+
|
|
324
|
+
// Stream the video
|
|
325
|
+
const video = await fetch(url).then((r) => r.blob());
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**Fetch from URL and render:**
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
const { url } = await fetch("http://localhost:2000/render", {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: { "Content-Type": "application/json" },
|
|
334
|
+
body: JSON.stringify({
|
|
335
|
+
effie: "https://example.com/composition.json",
|
|
336
|
+
scale: 0.5,
|
|
337
|
+
}),
|
|
338
|
+
}).then((r) => r.json());
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Render and upload to S3 (SSE progress):**
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
const { url } = await fetch("http://localhost:2000/render", {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { "Content-Type": "application/json" },
|
|
347
|
+
body: JSON.stringify({
|
|
348
|
+
effie: effieData,
|
|
349
|
+
upload: {
|
|
350
|
+
videoUrl: "https://s3.../presigned-video-url",
|
|
351
|
+
coverUrl: "https://s3.../presigned-cover-url",
|
|
352
|
+
},
|
|
353
|
+
}),
|
|
354
|
+
}).then((r) => r.json());
|
|
355
|
+
|
|
356
|
+
// Connect to SSE for progress
|
|
357
|
+
const events = new EventSource(url);
|
|
358
|
+
events.addEventListener("complete", (e) => {
|
|
359
|
+
const { timings } = JSON.parse(e.data);
|
|
360
|
+
console.log("Uploaded!", timings);
|
|
361
|
+
events.close();
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Related Packages
|
|
366
|
+
|
|
367
|
+
- [`@effing/effie`](../effie) — Define video compositions
|
|
368
|
+
- [`@effing/annie`](../annie) — Generate animations for layers
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Readable } from 'stream';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cache storage interface
|
|
5
|
+
*/
|
|
6
|
+
interface CacheStorage {
|
|
7
|
+
/** Store a stream with the given key */
|
|
8
|
+
put(key: string, stream: Readable): Promise<void>;
|
|
9
|
+
/** Get a stream for the given key, or null if not found */
|
|
10
|
+
getStream(key: string): Promise<Readable | null>;
|
|
11
|
+
/** Check if a key exists */
|
|
12
|
+
exists(key: string): Promise<boolean>;
|
|
13
|
+
/** Check if multiple keys exist (batch operation) */
|
|
14
|
+
existsMany(keys: string[]): Promise<Map<string, boolean>>;
|
|
15
|
+
/** Delete a key */
|
|
16
|
+
delete(key: string): Promise<void>;
|
|
17
|
+
/** Store JSON data */
|
|
18
|
+
putJson(key: string, data: object): Promise<void>;
|
|
19
|
+
/** Get JSON data, or null if not found */
|
|
20
|
+
getJson<T>(key: string): Promise<T | null>;
|
|
21
|
+
/** Close and cleanup resources */
|
|
22
|
+
close(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type { CacheStorage as C };
|