@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 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 };