@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/dist/server.js ADDED
@@ -0,0 +1,1656 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server.ts
4
+ import express4 from "express";
5
+ import bodyParser from "body-parser";
6
+
7
+ // src/handlers/shared.ts
8
+ import "express";
9
+
10
+ // src/cache.ts
11
+ import {
12
+ S3Client,
13
+ PutObjectCommand,
14
+ GetObjectCommand,
15
+ HeadObjectCommand,
16
+ DeleteObjectCommand
17
+ } from "@aws-sdk/client-s3";
18
+ import { Upload } from "@aws-sdk/lib-storage";
19
+ import fs from "fs/promises";
20
+ import { createReadStream, createWriteStream, existsSync } from "fs";
21
+ import { pipeline } from "stream/promises";
22
+ import path from "path";
23
+ import os from "os";
24
+ import crypto from "crypto";
25
+ var S3CacheStorage = class {
26
+ client;
27
+ bucket;
28
+ prefix;
29
+ ttlMs;
30
+ constructor(options) {
31
+ this.client = new S3Client({
32
+ endpoint: options.endpoint,
33
+ region: options.region ?? "auto",
34
+ credentials: options.accessKeyId ? {
35
+ accessKeyId: options.accessKeyId,
36
+ secretAccessKey: options.secretAccessKey
37
+ } : void 0,
38
+ forcePathStyle: !!options.endpoint
39
+ });
40
+ this.bucket = options.bucket;
41
+ this.prefix = options.prefix ?? "";
42
+ this.ttlMs = options.ttlMs ?? 60 * 60 * 1e3;
43
+ }
44
+ getExpires() {
45
+ return new Date(Date.now() + this.ttlMs);
46
+ }
47
+ getFullKey(key) {
48
+ return `${this.prefix}${key}`;
49
+ }
50
+ async put(key, stream) {
51
+ const upload = new Upload({
52
+ client: this.client,
53
+ params: {
54
+ Bucket: this.bucket,
55
+ Key: this.getFullKey(key),
56
+ Body: stream,
57
+ Expires: this.getExpires()
58
+ }
59
+ });
60
+ await upload.done();
61
+ }
62
+ async getStream(key) {
63
+ try {
64
+ const response = await this.client.send(
65
+ new GetObjectCommand({
66
+ Bucket: this.bucket,
67
+ Key: this.getFullKey(key)
68
+ })
69
+ );
70
+ return response.Body;
71
+ } catch (err) {
72
+ const error = err;
73
+ if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
74
+ return null;
75
+ }
76
+ throw err;
77
+ }
78
+ }
79
+ async exists(key) {
80
+ try {
81
+ await this.client.send(
82
+ new HeadObjectCommand({
83
+ Bucket: this.bucket,
84
+ Key: this.getFullKey(key)
85
+ })
86
+ );
87
+ return true;
88
+ } catch (err) {
89
+ const error = err;
90
+ if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) {
91
+ return false;
92
+ }
93
+ throw err;
94
+ }
95
+ }
96
+ async existsMany(keys) {
97
+ const results = await Promise.all(
98
+ keys.map(async (key) => [key, await this.exists(key)])
99
+ );
100
+ return new Map(results);
101
+ }
102
+ async delete(key) {
103
+ try {
104
+ await this.client.send(
105
+ new DeleteObjectCommand({
106
+ Bucket: this.bucket,
107
+ Key: this.getFullKey(key)
108
+ })
109
+ );
110
+ } catch (err) {
111
+ const error = err;
112
+ if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
113
+ return;
114
+ }
115
+ throw err;
116
+ }
117
+ }
118
+ async putJson(key, data) {
119
+ await this.client.send(
120
+ new PutObjectCommand({
121
+ Bucket: this.bucket,
122
+ Key: this.getFullKey(key),
123
+ Body: JSON.stringify(data),
124
+ ContentType: "application/json",
125
+ Expires: this.getExpires()
126
+ })
127
+ );
128
+ }
129
+ async getJson(key) {
130
+ try {
131
+ const response = await this.client.send(
132
+ new GetObjectCommand({
133
+ Bucket: this.bucket,
134
+ Key: this.getFullKey(key)
135
+ })
136
+ );
137
+ const body = await response.Body?.transformToString();
138
+ if (!body) return null;
139
+ return JSON.parse(body);
140
+ } catch (err) {
141
+ const error = err;
142
+ if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
143
+ return null;
144
+ }
145
+ throw err;
146
+ }
147
+ }
148
+ close() {
149
+ }
150
+ };
151
+ var LocalCacheStorage = class {
152
+ baseDir;
153
+ initialized = false;
154
+ cleanupInterval;
155
+ ttlMs;
156
+ constructor(baseDir, ttlMs = 60 * 60 * 1e3) {
157
+ this.baseDir = baseDir ?? path.join(os.tmpdir(), "ffs-cache");
158
+ this.ttlMs = ttlMs;
159
+ this.cleanupInterval = setInterval(() => {
160
+ this.cleanupExpired().catch(console.error);
161
+ }, 3e5);
162
+ }
163
+ /**
164
+ * Remove files older than TTL
165
+ */
166
+ async cleanupExpired() {
167
+ if (!this.initialized) return;
168
+ const now = Date.now();
169
+ await this.cleanupDir(this.baseDir, now);
170
+ }
171
+ async cleanupDir(dir, now) {
172
+ let entries;
173
+ try {
174
+ entries = await fs.readdir(dir, { withFileTypes: true });
175
+ } catch {
176
+ return;
177
+ }
178
+ for (const entry of entries) {
179
+ const fullPath = path.join(dir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ await this.cleanupDir(fullPath, now);
182
+ try {
183
+ await fs.rmdir(fullPath);
184
+ } catch {
185
+ }
186
+ } else if (entry.isFile()) {
187
+ try {
188
+ const stat = await fs.stat(fullPath);
189
+ if (now - stat.mtimeMs > this.ttlMs) {
190
+ await fs.rm(fullPath, { force: true });
191
+ }
192
+ } catch {
193
+ }
194
+ }
195
+ }
196
+ }
197
+ async ensureDir(filePath) {
198
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
199
+ this.initialized = true;
200
+ }
201
+ filePath(key) {
202
+ return path.join(this.baseDir, key);
203
+ }
204
+ tmpPathFor(finalPath) {
205
+ const rand = crypto.randomBytes(8).toString("hex");
206
+ return `${finalPath}.tmp-${process.pid}-${rand}`;
207
+ }
208
+ async put(key, stream) {
209
+ const fp = this.filePath(key);
210
+ await this.ensureDir(fp);
211
+ const tmpPath = this.tmpPathFor(fp);
212
+ try {
213
+ const writeStream = createWriteStream(tmpPath);
214
+ await pipeline(stream, writeStream);
215
+ await fs.rename(tmpPath, fp);
216
+ } catch (err) {
217
+ await fs.rm(tmpPath, { force: true }).catch(() => {
218
+ });
219
+ throw err;
220
+ }
221
+ }
222
+ async getStream(key) {
223
+ const fp = this.filePath(key);
224
+ if (!existsSync(fp)) return null;
225
+ return createReadStream(fp);
226
+ }
227
+ async exists(key) {
228
+ try {
229
+ await fs.access(this.filePath(key));
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+ async existsMany(keys) {
236
+ const results = await Promise.all(
237
+ keys.map(async (key) => [key, await this.exists(key)])
238
+ );
239
+ return new Map(results);
240
+ }
241
+ async delete(key) {
242
+ await fs.rm(this.filePath(key), { force: true });
243
+ }
244
+ async putJson(key, data) {
245
+ const fp = this.filePath(key);
246
+ await this.ensureDir(fp);
247
+ const tmpPath = this.tmpPathFor(fp);
248
+ try {
249
+ await fs.writeFile(tmpPath, JSON.stringify(data));
250
+ await fs.rename(tmpPath, fp);
251
+ } catch (err) {
252
+ await fs.rm(tmpPath, { force: true }).catch(() => {
253
+ });
254
+ throw err;
255
+ }
256
+ }
257
+ async getJson(key) {
258
+ try {
259
+ const content = await fs.readFile(this.filePath(key), "utf-8");
260
+ return JSON.parse(content);
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+ close() {
266
+ if (this.cleanupInterval) {
267
+ clearInterval(this.cleanupInterval);
268
+ this.cleanupInterval = void 0;
269
+ }
270
+ }
271
+ };
272
+ function createCacheStorage() {
273
+ const ttlMs = process.env.FFS_CACHE_TTL_MS ? parseInt(process.env.FFS_CACHE_TTL_MS, 10) : 60 * 60 * 1e3;
274
+ if (process.env.FFS_CACHE_BUCKET) {
275
+ return new S3CacheStorage({
276
+ endpoint: process.env.FFS_CACHE_ENDPOINT,
277
+ region: process.env.FFS_CACHE_REGION ?? "auto",
278
+ bucket: process.env.FFS_CACHE_BUCKET,
279
+ prefix: process.env.FFS_CACHE_PREFIX,
280
+ accessKeyId: process.env.FFS_CACHE_ACCESS_KEY,
281
+ secretAccessKey: process.env.FFS_CACHE_SECRET_KEY,
282
+ ttlMs
283
+ });
284
+ }
285
+ return new LocalCacheStorage(process.env.FFS_CACHE_LOCAL_DIR, ttlMs);
286
+ }
287
+ function hashUrl(url) {
288
+ return crypto.createHash("sha256").update(url).digest("hex").slice(0, 16);
289
+ }
290
+ function sourceCacheKey(url) {
291
+ return `sources/${hashUrl(url)}`;
292
+ }
293
+ function warmupJobCacheKey(jobId) {
294
+ return `jobs/warmup/${jobId}.json`;
295
+ }
296
+ function renderJobCacheKey(jobId) {
297
+ return `jobs/render/${jobId}.json`;
298
+ }
299
+ var cacheKeys = {
300
+ source: sourceCacheKey,
301
+ warmupJob: warmupJobCacheKey,
302
+ renderJob: renderJobCacheKey
303
+ };
304
+
305
+ // src/handlers/shared.ts
306
+ import { effieDataSchema } from "@effing/effie";
307
+ function createServerContext() {
308
+ const port2 = process.env.FFS_PORT || 2e3;
309
+ return {
310
+ cacheStorage: createCacheStorage(),
311
+ baseUrl: process.env.FFS_BASE_URL || `http://localhost:${port2}`,
312
+ skipValidation: !!process.env.FFS_SKIP_VALIDATION && process.env.FFS_SKIP_VALIDATION !== "false",
313
+ cacheConcurrency: parseInt(process.env.FFS_CACHE_CONCURRENCY || "4", 10)
314
+ };
315
+ }
316
+ function parseEffieData(body, skipValidation) {
317
+ const isWrapped = typeof body === "object" && body !== null && "effie" in body;
318
+ const rawEffieData = isWrapped ? body.effie : body;
319
+ if (!skipValidation) {
320
+ const result = effieDataSchema.safeParse(rawEffieData);
321
+ if (!result.success) {
322
+ return {
323
+ error: "Invalid effie data",
324
+ issues: result.error.issues.map((issue) => ({
325
+ path: issue.path.join("."),
326
+ message: issue.message
327
+ }))
328
+ };
329
+ }
330
+ return { effie: result.data };
331
+ } else {
332
+ const effie = rawEffieData;
333
+ if (!effie?.segments) {
334
+ return { error: "Invalid effie data: missing segments" };
335
+ }
336
+ return { effie };
337
+ }
338
+ }
339
+ function setupCORSHeaders(res) {
340
+ res.setHeader("Access-Control-Allow-Origin", "*");
341
+ res.setHeader("Access-Control-Allow-Methods", "GET");
342
+ }
343
+ function setupSSEResponse(res) {
344
+ res.setHeader("Content-Type", "text/event-stream");
345
+ res.setHeader("Cache-Control", "no-cache");
346
+ res.setHeader("Connection", "keep-alive");
347
+ res.flushHeaders();
348
+ }
349
+ function createSSEEventSender(res) {
350
+ return (event, data) => {
351
+ res.write(`event: ${event}
352
+ data: ${JSON.stringify(data)}
353
+
354
+ `);
355
+ };
356
+ }
357
+
358
+ // src/handlers/caching.ts
359
+ import "express";
360
+ import { Readable, Transform } from "stream";
361
+ import { randomUUID } from "crypto";
362
+
363
+ // src/fetch.ts
364
+ import { fetch, Agent } from "undici";
365
+ async function ffsFetch(url, options) {
366
+ const {
367
+ method,
368
+ body,
369
+ headers,
370
+ headersTimeout = 3e5,
371
+ // 5 minutes
372
+ bodyTimeout = 3e5
373
+ // 5 minutes
374
+ } = options ?? {};
375
+ const agent = new Agent({ headersTimeout, bodyTimeout });
376
+ return fetch(url, {
377
+ method,
378
+ body,
379
+ headers: { "User-Agent": "FFS (+https://effing.dev/ffs)", ...headers },
380
+ dispatcher: agent
381
+ });
382
+ }
383
+
384
+ // src/handlers/caching.ts
385
+ import { extractEffieSources } from "@effing/effie";
386
+ var inFlightFetches = /* @__PURE__ */ new Map();
387
+ async function createWarmupJob(req, res, ctx2) {
388
+ try {
389
+ const parseResult = parseEffieData(req.body, ctx2.skipValidation);
390
+ if ("error" in parseResult) {
391
+ res.status(400).json(parseResult);
392
+ return;
393
+ }
394
+ const sources = extractEffieSources(parseResult.effie);
395
+ const jobId = randomUUID();
396
+ await ctx2.cacheStorage.putJson(cacheKeys.warmupJob(jobId), { sources });
397
+ res.json({
398
+ id: jobId,
399
+ url: `${ctx2.baseUrl}/warmup/${jobId}`
400
+ });
401
+ } catch (error) {
402
+ console.error("Error creating warmup job:", error);
403
+ res.status(500).json({ error: "Failed to create warmup job" });
404
+ }
405
+ }
406
+ async function streamWarmupJob(req, res, ctx2) {
407
+ try {
408
+ setupCORSHeaders(res);
409
+ const jobId = req.params.id;
410
+ const jobCacheKey = cacheKeys.warmupJob(jobId);
411
+ const job = await ctx2.cacheStorage.getJson(jobCacheKey);
412
+ ctx2.cacheStorage.delete(jobCacheKey);
413
+ if (!job) {
414
+ res.status(404).json({ error: "Job not found" });
415
+ return;
416
+ }
417
+ setupSSEResponse(res);
418
+ const sendEvent = createSSEEventSender(res);
419
+ try {
420
+ await warmupSources(job.sources, sendEvent, ctx2);
421
+ sendEvent("complete", { status: "ready" });
422
+ } catch (error) {
423
+ sendEvent("error", { message: String(error) });
424
+ } finally {
425
+ res.end();
426
+ }
427
+ } catch (error) {
428
+ console.error("Error in warmup streaming:", error);
429
+ if (!res.headersSent) {
430
+ res.status(500).json({ error: "Warmup streaming failed" });
431
+ } else {
432
+ res.end();
433
+ }
434
+ }
435
+ }
436
+ async function purgeCache(req, res, ctx2) {
437
+ try {
438
+ const parseResult = parseEffieData(req.body, ctx2.skipValidation);
439
+ if ("error" in parseResult) {
440
+ res.status(400).json(parseResult);
441
+ return;
442
+ }
443
+ const sources = extractEffieSources(parseResult.effie);
444
+ let purged = 0;
445
+ for (const url of sources) {
446
+ const ck = cacheKeys.source(url);
447
+ if (await ctx2.cacheStorage.exists(ck)) {
448
+ await ctx2.cacheStorage.delete(ck);
449
+ purged++;
450
+ }
451
+ }
452
+ res.json({ purged, total: sources.length });
453
+ } catch (error) {
454
+ console.error("Error purging cache:", error);
455
+ res.status(500).json({ error: "Failed to purge cache" });
456
+ }
457
+ }
458
+ async function warmupSources(sources, sendEvent, ctx2) {
459
+ const total = sources.length;
460
+ const sourceCacheKeys = sources.map(cacheKeys.source);
461
+ sendEvent("start", { total });
462
+ const existsMap = await ctx2.cacheStorage.existsMany(sourceCacheKeys);
463
+ let cached = 0;
464
+ let failed = 0;
465
+ for (let i = 0; i < sources.length; i++) {
466
+ if (existsMap.get(sourceCacheKeys[i])) {
467
+ cached++;
468
+ sendEvent("progress", {
469
+ url: sources[i],
470
+ status: "hit",
471
+ cached,
472
+ failed,
473
+ total
474
+ });
475
+ }
476
+ }
477
+ const uncached = sources.filter((_, i) => !existsMap.get(sourceCacheKeys[i]));
478
+ if (uncached.length === 0) {
479
+ sendEvent("summary", { cached, failed, total });
480
+ return;
481
+ }
482
+ const keepalive = setInterval(() => {
483
+ sendEvent("keepalive", { cached, failed, total });
484
+ }, 25e3);
485
+ const queue = [...uncached];
486
+ const workers = Array.from(
487
+ { length: Math.min(ctx2.cacheConcurrency, queue.length) },
488
+ async () => {
489
+ while (queue.length > 0) {
490
+ const url = queue.shift();
491
+ const cacheKey = cacheKeys.source(url);
492
+ const startTime = Date.now();
493
+ try {
494
+ let fetchPromise = inFlightFetches.get(cacheKey);
495
+ if (!fetchPromise) {
496
+ fetchPromise = fetchAndCache(url, cacheKey, sendEvent, ctx2);
497
+ inFlightFetches.set(cacheKey, fetchPromise);
498
+ }
499
+ await fetchPromise;
500
+ inFlightFetches.delete(cacheKey);
501
+ cached++;
502
+ sendEvent("progress", {
503
+ url,
504
+ status: "cached",
505
+ cached,
506
+ failed,
507
+ total,
508
+ ms: Date.now() - startTime
509
+ });
510
+ } catch (error) {
511
+ inFlightFetches.delete(cacheKey);
512
+ failed++;
513
+ sendEvent("progress", {
514
+ url,
515
+ status: "error",
516
+ error: String(error),
517
+ cached,
518
+ failed,
519
+ total,
520
+ ms: Date.now() - startTime
521
+ });
522
+ }
523
+ }
524
+ }
525
+ );
526
+ await Promise.all(workers);
527
+ clearInterval(keepalive);
528
+ sendEvent("summary", { cached, failed, total });
529
+ }
530
+ async function fetchAndCache(url, cacheKey, sendEvent, ctx2) {
531
+ const response = await ffsFetch(url, {
532
+ headersTimeout: 10 * 60 * 1e3,
533
+ // 10 minutes
534
+ bodyTimeout: 20 * 60 * 1e3
535
+ // 20 minutes
536
+ });
537
+ if (!response.ok) {
538
+ throw new Error(`${response.status} ${response.statusText}`);
539
+ }
540
+ sendEvent("downloading", { url, status: "started", bytesReceived: 0 });
541
+ const sourceStream = Readable.fromWeb(
542
+ response.body
543
+ );
544
+ let totalBytes = 0;
545
+ let lastEventTime = Date.now();
546
+ const PROGRESS_INTERVAL = 1e4;
547
+ const progressStream = new Transform({
548
+ transform(chunk, _encoding, callback) {
549
+ totalBytes += chunk.length;
550
+ const now = Date.now();
551
+ if (now - lastEventTime >= PROGRESS_INTERVAL) {
552
+ sendEvent("downloading", {
553
+ url,
554
+ status: "downloading",
555
+ bytesReceived: totalBytes
556
+ });
557
+ lastEventTime = now;
558
+ }
559
+ callback(null, chunk);
560
+ }
561
+ });
562
+ const trackedStream = sourceStream.pipe(progressStream);
563
+ await ctx2.cacheStorage.put(cacheKey, trackedStream);
564
+ }
565
+
566
+ // src/handlers/rendering.ts
567
+ import "express";
568
+ import { randomUUID as randomUUID2 } from "crypto";
569
+
570
+ // src/render.ts
571
+ import { Readable as Readable2 } from "stream";
572
+ import { createReadStream as createReadStream2 } from "fs";
573
+
574
+ // src/motion.ts
575
+ function getEasingExpression(tNormExpr, easingType) {
576
+ switch (easingType) {
577
+ case "ease-in":
578
+ return `pow(${tNormExpr},2)`;
579
+ case "ease-out":
580
+ return `(1-pow(1-(${tNormExpr}),2))`;
581
+ case "ease-in-out":
582
+ return `if(lt(${tNormExpr},0.5),2*pow(${tNormExpr},2),1-pow(-2*(${tNormExpr})+2,2)/2)`;
583
+ case "linear":
584
+ default:
585
+ return `(${tNormExpr})`;
586
+ }
587
+ }
588
+ function processSlideMotion(motion, relativeTimeExpr) {
589
+ const duration = motion.duration ?? 1;
590
+ const distance = motion.distance ?? 1;
591
+ const reverse = motion.reverse ?? false;
592
+ const easing = motion.easing ?? "linear";
593
+ const tNormExpr = `(${relativeTimeExpr})/${duration}`;
594
+ const easedProgressExpr = getEasingExpression(tNormExpr, easing);
595
+ const finalTimeFactorExpr = reverse ? easedProgressExpr : `(1-(${easedProgressExpr}))`;
596
+ let activeX;
597
+ let activeY;
598
+ let initialX;
599
+ let initialY;
600
+ let finalX;
601
+ let finalY;
602
+ switch (motion.direction) {
603
+ case "left": {
604
+ const offsetXLeft = `${distance}*W`;
605
+ activeX = `(${offsetXLeft})*${finalTimeFactorExpr}`;
606
+ activeY = "0";
607
+ initialX = reverse ? "0" : offsetXLeft;
608
+ initialY = "0";
609
+ finalX = reverse ? offsetXLeft : "0";
610
+ finalY = "0";
611
+ break;
612
+ }
613
+ case "right": {
614
+ const offsetXRight = `-${distance}*W`;
615
+ activeX = `(${offsetXRight})*${finalTimeFactorExpr}`;
616
+ activeY = "0";
617
+ initialX = reverse ? "0" : offsetXRight;
618
+ initialY = "0";
619
+ finalX = reverse ? offsetXRight : "0";
620
+ finalY = "0";
621
+ break;
622
+ }
623
+ case "up": {
624
+ const offsetYUp = `${distance}*H`;
625
+ activeX = "0";
626
+ activeY = `(${offsetYUp})*${finalTimeFactorExpr}`;
627
+ initialX = "0";
628
+ initialY = reverse ? "0" : offsetYUp;
629
+ finalX = "0";
630
+ finalY = reverse ? offsetYUp : "0";
631
+ break;
632
+ }
633
+ case "down": {
634
+ const offsetYDown = `-${distance}*H`;
635
+ activeX = "0";
636
+ activeY = `(${offsetYDown})*${finalTimeFactorExpr}`;
637
+ initialX = "0";
638
+ initialY = reverse ? "0" : offsetYDown;
639
+ finalX = "0";
640
+ finalY = reverse ? offsetYDown : "0";
641
+ break;
642
+ }
643
+ }
644
+ return { initialX, initialY, activeX, activeY, finalX, finalY, duration };
645
+ }
646
+ function processBounceMotion(motion, relativeTimeExpr) {
647
+ const amplitude = motion.amplitude ?? 0.5;
648
+ const duration = motion.duration ?? 1;
649
+ const initialY = `-overlay_h*${amplitude}`;
650
+ const finalY = "0";
651
+ const tNormExpr = `(${relativeTimeExpr})/${duration}`;
652
+ const activeBounceExpression = `if(lt(${tNormExpr},0.363636),${initialY}+overlay_h*${amplitude}*(7.5625*${tNormExpr}*${tNormExpr}),if(lt(${tNormExpr},0.727273),${initialY}+overlay_h*${amplitude}*(7.5625*(${tNormExpr}-0.545455)*(${tNormExpr}-0.545455)+0.75),if(lt(${tNormExpr},0.909091),${initialY}+overlay_h*${amplitude}*(7.5625*(${tNormExpr}-0.818182)*(${tNormExpr}-0.818182)+0.9375),if(lt(${tNormExpr},0.954545),${initialY}+overlay_h*${amplitude}*(7.5625*(${tNormExpr}-0.954545)*(${tNormExpr}-0.954545)+0.984375),${finalY}))))`;
653
+ return {
654
+ initialX: "0",
655
+ initialY,
656
+ activeX: "0",
657
+ activeY: activeBounceExpression,
658
+ // This expression now scales with duration
659
+ finalX: "0",
660
+ finalY,
661
+ duration
662
+ // Return the actual duration used
663
+ };
664
+ }
665
+ function processShakeMotion(motion, relativeTimeExpr) {
666
+ const intensity = motion.intensity ?? 10;
667
+ const frequency = motion.frequency ?? 4;
668
+ const duration = motion.duration ?? 1;
669
+ const activeX = `${intensity}*sin(${relativeTimeExpr}*PI*${frequency})`;
670
+ const activeY = `${intensity}*cos(${relativeTimeExpr}*PI*${frequency})`;
671
+ return {
672
+ initialX: "0",
673
+ initialY: "0",
674
+ activeX,
675
+ activeY,
676
+ finalX: "0",
677
+ finalY: "0",
678
+ duration
679
+ };
680
+ }
681
+ function processMotion(delay, motion) {
682
+ if (!motion) return "x=0:y=0";
683
+ const start = delay + (motion.start ?? 0);
684
+ const relativeTimeExpr = `(t-${start})`;
685
+ let components;
686
+ switch (motion.type) {
687
+ case "bounce":
688
+ components = processBounceMotion(motion, relativeTimeExpr);
689
+ break;
690
+ case "shake":
691
+ components = processShakeMotion(motion, relativeTimeExpr);
692
+ break;
693
+ case "slide":
694
+ components = processSlideMotion(motion, relativeTimeExpr);
695
+ break;
696
+ default:
697
+ motion;
698
+ throw new Error(
699
+ `Unsupported motion type: ${motion.type}`
700
+ );
701
+ }
702
+ const motionEndTime = start + components.duration;
703
+ const xArg = `if(lt(t,${start}),${components.initialX},if(lt(t,${motionEndTime}),${components.activeX},${components.finalX}))`;
704
+ const yArg = `if(lt(t,${start}),${components.initialY},if(lt(t,${motionEndTime}),${components.activeY},${components.finalY}))`;
705
+ return `x='${xArg}':y='${yArg}'`;
706
+ }
707
+
708
+ // src/effect.ts
709
+ function processFadeIn(effect, _frameRate, _frameWidth, _frameHeight) {
710
+ return `fade=t=in:st=${effect.start}:d=${effect.duration}:alpha=1`;
711
+ }
712
+ function processFadeOut(effect, _frameRate, _frameWidth, _frameHeight) {
713
+ return `fade=t=out:st=${effect.start}:d=${effect.duration}:alpha=1`;
714
+ }
715
+ function processSaturateIn(effect, _frameRate, _frameWidth, _frameHeight) {
716
+ return `hue='s=max(0,min(1,(t-${effect.start})/${effect.duration}))'`;
717
+ }
718
+ function processSaturateOut(effect, _frameRate, _frameWidth, _frameHeight) {
719
+ return `hue='s=max(0,min(1,(${effect.start + effect.duration}-t)/${effect.duration}))'`;
720
+ }
721
+ function processScroll(effect, frameRate, _frameWidth, _frameHeight) {
722
+ const distance = effect.distance ?? 1;
723
+ const scroll = distance / (1 + distance);
724
+ const speed = scroll / (effect.duration * frameRate);
725
+ switch (effect.direction) {
726
+ case "left":
727
+ return `scroll=h=${speed}`;
728
+ case "right":
729
+ return `scroll=hpos=${1 - scroll}:h=-${speed}`;
730
+ case "up":
731
+ return `scroll=v=${speed}`;
732
+ case "down":
733
+ return `scroll=vpos=${1 - scroll}:v=-${speed}`;
734
+ }
735
+ }
736
+ function processEffect(effect, frameRate, frameWidth, frameHeight) {
737
+ switch (effect.type) {
738
+ case "fade-in":
739
+ return processFadeIn(effect, frameRate, frameWidth, frameHeight);
740
+ case "fade-out":
741
+ return processFadeOut(effect, frameRate, frameWidth, frameHeight);
742
+ case "saturate-in":
743
+ return processSaturateIn(effect, frameRate, frameWidth, frameHeight);
744
+ case "saturate-out":
745
+ return processSaturateOut(effect, frameRate, frameWidth, frameHeight);
746
+ case "scroll":
747
+ return processScroll(effect, frameRate, frameWidth, frameHeight);
748
+ default:
749
+ effect;
750
+ throw new Error(
751
+ `Unsupported effect type: ${effect.type}`
752
+ );
753
+ }
754
+ }
755
+ function processEffects(effects, frameRate, frameWidth, frameHeight) {
756
+ if (!effects || effects.length === 0) return "";
757
+ const filters = [];
758
+ for (const effect of effects) {
759
+ const filter = processEffect(effect, frameRate, frameWidth, frameHeight);
760
+ filters.push(filter);
761
+ }
762
+ return filters.join(",");
763
+ }
764
+
765
+ // src/ffmpeg.ts
766
+ import { spawn } from "child_process";
767
+ import { pipeline as pipeline2 } from "stream";
768
+ import fs2 from "fs/promises";
769
+ import os2 from "os";
770
+ import path2 from "path";
771
+ import pathToFFmpeg from "ffmpeg-static";
772
+ import tar from "tar-stream";
773
+ import { createWriteStream as createWriteStream2 } from "fs";
774
+ import { promisify } from "util";
775
+ var pump = promisify(pipeline2);
776
+ var FFmpegCommand = class {
777
+ globalArgs;
778
+ inputs;
779
+ filterComplex;
780
+ outputArgs;
781
+ constructor(globalArgs, inputs, filterComplex, outputArgs) {
782
+ this.globalArgs = globalArgs;
783
+ this.inputs = inputs;
784
+ this.filterComplex = filterComplex;
785
+ this.outputArgs = outputArgs;
786
+ }
787
+ buildArgs(inputResolver) {
788
+ const inputArgs = [];
789
+ for (const input of this.inputs) {
790
+ if (input.type === "color") {
791
+ inputArgs.push(...input.preArgs);
792
+ } else if (input.type === "animation") {
793
+ inputArgs.push(
794
+ ...input.preArgs,
795
+ "-i",
796
+ path2.join(inputResolver(input), "frame_%05d")
797
+ );
798
+ } else {
799
+ inputArgs.push(...input.preArgs, "-i", inputResolver(input));
800
+ }
801
+ }
802
+ const args = [
803
+ ...this.globalArgs,
804
+ ...inputArgs,
805
+ "-filter_complex",
806
+ this.filterComplex,
807
+ ...this.outputArgs
808
+ ];
809
+ return args;
810
+ }
811
+ };
812
+ var FFmpegRunner = class {
813
+ command;
814
+ ffmpegProc;
815
+ constructor(command) {
816
+ this.command = command;
817
+ }
818
+ async run(sourceResolver, imageTransformer) {
819
+ const tempDir = await fs2.mkdtemp(path2.join(os2.tmpdir(), "ffs-"));
820
+ const fileMapping = /* @__PURE__ */ new Map();
821
+ const sourceCache = /* @__PURE__ */ new Map();
822
+ const fetchAndSaveSource = async (input, inputName) => {
823
+ const stream = await sourceResolver({
824
+ type: input.type,
825
+ src: input.source
826
+ });
827
+ if (input.type === "animation") {
828
+ const extractionDir = path2.join(tempDir, inputName);
829
+ await fs2.mkdir(extractionDir, { recursive: true });
830
+ const extract = tar.extract();
831
+ const extractPromise = new Promise((resolve, reject) => {
832
+ extract.on("entry", async (header, stream2, next) => {
833
+ if (header.name.startsWith("frame_")) {
834
+ const transformedStream = imageTransformer ? await imageTransformer(stream2) : stream2;
835
+ const outputPath = path2.join(extractionDir, header.name);
836
+ const writeStream = createWriteStream2(outputPath);
837
+ transformedStream.pipe(writeStream);
838
+ writeStream.on("finish", next);
839
+ writeStream.on("error", reject);
840
+ }
841
+ });
842
+ extract.on("finish", resolve);
843
+ extract.on("error", reject);
844
+ });
845
+ stream.pipe(extract);
846
+ await extractPromise;
847
+ return extractionDir;
848
+ } else if (input.type === "image" && imageTransformer) {
849
+ const tempFile = path2.join(tempDir, inputName);
850
+ const transformedStream = await imageTransformer(stream);
851
+ const writeStream = createWriteStream2(tempFile);
852
+ transformedStream.on("error", (e) => writeStream.destroy(e));
853
+ await pump(transformedStream, writeStream);
854
+ return tempFile;
855
+ } else {
856
+ const tempFile = path2.join(tempDir, inputName);
857
+ const writeStream = createWriteStream2(tempFile);
858
+ stream.on("error", (e) => writeStream.destroy(e));
859
+ await pump(stream, writeStream);
860
+ return tempFile;
861
+ }
862
+ };
863
+ await Promise.all(
864
+ this.command.inputs.map(async (input) => {
865
+ if (input.type === "color") return;
866
+ const inputName = `ffmpeg_input_${input.index.toString().padStart(3, "0")}`;
867
+ const shouldCache = input.source.startsWith("#");
868
+ if (shouldCache) {
869
+ let fetchPromise = sourceCache.get(input.source);
870
+ if (!fetchPromise) {
871
+ fetchPromise = fetchAndSaveSource(input, inputName);
872
+ sourceCache.set(input.source, fetchPromise);
873
+ }
874
+ const filePath = await fetchPromise;
875
+ fileMapping.set(input.index, filePath);
876
+ } else {
877
+ const filePath = await fetchAndSaveSource(input, inputName);
878
+ fileMapping.set(input.index, filePath);
879
+ }
880
+ })
881
+ );
882
+ const finalArgs = this.command.buildArgs((input) => {
883
+ const filePath = fileMapping.get(input.index);
884
+ if (!filePath)
885
+ throw new Error(`File for input index ${input.index} not found`);
886
+ return filePath;
887
+ });
888
+ const ffmpegProc = spawn(process.env.FFMPEG ?? pathToFFmpeg, finalArgs);
889
+ ffmpegProc.stderr.on("data", (data) => {
890
+ console.error(data.toString());
891
+ });
892
+ ffmpegProc.on("close", async () => {
893
+ try {
894
+ await fs2.rm(tempDir, { recursive: true, force: true });
895
+ } catch (err) {
896
+ console.error("Error removing temp directory:", err);
897
+ }
898
+ });
899
+ this.ffmpegProc = ffmpegProc;
900
+ return ffmpegProc.stdout;
901
+ }
902
+ close() {
903
+ if (this.ffmpegProc) {
904
+ this.ffmpegProc.kill("SIGTERM");
905
+ this.ffmpegProc = void 0;
906
+ }
907
+ }
908
+ };
909
+
910
+ // src/transition.ts
911
+ function processTransition(transition) {
912
+ switch (transition.type) {
913
+ case "fade": {
914
+ if ("through" in transition) {
915
+ return `fade${transition.through}`;
916
+ }
917
+ const easing = transition.easing ?? "linear";
918
+ return {
919
+ linear: "fade",
920
+ "ease-in": "fadeslow",
921
+ "ease-out": "fadefast"
922
+ }[easing];
923
+ }
924
+ case "barn": {
925
+ const orientation = transition.orientation ?? "horizontal";
926
+ const mode = transition.mode ?? "open";
927
+ const prefix = orientation === "vertical" ? "vert" : "horz";
928
+ return `${prefix}${mode}`;
929
+ }
930
+ case "circle": {
931
+ const mode = transition.mode ?? "open";
932
+ return `circle${mode}`;
933
+ }
934
+ case "wipe":
935
+ case "slide":
936
+ case "smooth": {
937
+ const direction = transition.direction ?? "left";
938
+ return `${transition.type}${direction}`;
939
+ }
940
+ case "slice": {
941
+ const direction = transition.direction ?? "left";
942
+ const prefix = {
943
+ left: "hl",
944
+ right: "hr",
945
+ up: "vu",
946
+ down: "vd"
947
+ }[direction];
948
+ return `${prefix}${transition.type}`;
949
+ }
950
+ case "zoom": {
951
+ return "zoomin";
952
+ }
953
+ case "dissolve":
954
+ case "pixelize":
955
+ case "radial":
956
+ return transition.type;
957
+ default:
958
+ transition;
959
+ throw new Error(
960
+ `Unsupported transition type: ${transition.type}`
961
+ );
962
+ }
963
+ }
964
+
965
+ // src/render.ts
966
+ import sharp from "sharp";
967
+ import { fileURLToPath } from "url";
968
+ var EffieRenderer = class {
969
+ effieData;
970
+ ffmpegRunner;
971
+ allowLocalFiles;
972
+ cacheStorage;
973
+ constructor(effieData, options) {
974
+ this.effieData = effieData;
975
+ this.allowLocalFiles = options?.allowLocalFiles ?? false;
976
+ this.cacheStorage = options?.cacheStorage;
977
+ }
978
+ async fetchSource(src) {
979
+ if (src.startsWith("#")) {
980
+ const sourceName = src.slice(1);
981
+ if (!(sourceName in this.effieData.sources)) {
982
+ throw new Error(`Named source "${sourceName}" not found`);
983
+ }
984
+ src = this.effieData.sources[sourceName];
985
+ }
986
+ if (src.startsWith("data:")) {
987
+ const commaIndex = src.indexOf(",");
988
+ if (commaIndex === -1) {
989
+ throw new Error("Invalid data URL");
990
+ }
991
+ const meta = src.slice(5, commaIndex);
992
+ const isBase64 = meta.endsWith(";base64");
993
+ const data = src.slice(commaIndex + 1);
994
+ const buffer = isBase64 ? Buffer.from(data, "base64") : Buffer.from(decodeURIComponent(data));
995
+ return Readable2.from(buffer);
996
+ }
997
+ if (src.startsWith("file:")) {
998
+ if (!this.allowLocalFiles) {
999
+ throw new Error(
1000
+ "Local file paths are not allowed. Use allowLocalFiles option for trusted operations."
1001
+ );
1002
+ }
1003
+ return createReadStream2(fileURLToPath(src));
1004
+ }
1005
+ if (this.cacheStorage) {
1006
+ const cachedStream = await this.cacheStorage.getStream(
1007
+ cacheKeys.source(src)
1008
+ );
1009
+ if (cachedStream) {
1010
+ return cachedStream;
1011
+ }
1012
+ }
1013
+ const response = await ffsFetch(src, {
1014
+ headersTimeout: 10 * 60 * 1e3,
1015
+ // 10 minutes
1016
+ bodyTimeout: 20 * 60 * 1e3
1017
+ // 20 minutes
1018
+ });
1019
+ if (!response.ok) {
1020
+ throw new Error(
1021
+ `Failed to fetch ${src}: ${response.status} ${response.statusText}`
1022
+ );
1023
+ }
1024
+ if (!response.body) {
1025
+ throw new Error(`No body for ${src}`);
1026
+ }
1027
+ return Readable2.fromWeb(response.body);
1028
+ }
1029
+ buildAudioFilter({
1030
+ duration,
1031
+ volume,
1032
+ fadeIn,
1033
+ fadeOut
1034
+ }) {
1035
+ const filters = [];
1036
+ if (volume !== void 0) {
1037
+ filters.push(`volume=${volume}`);
1038
+ }
1039
+ if (fadeIn !== void 0) {
1040
+ filters.push(`afade=type=in:start_time=0:duration=${fadeIn}`);
1041
+ }
1042
+ if (fadeOut !== void 0) {
1043
+ filters.push(
1044
+ `afade=type=out:start_time=${duration - fadeOut}:duration=${fadeOut}`
1045
+ );
1046
+ }
1047
+ return filters.length ? filters.join(",") : "anull";
1048
+ }
1049
+ getFrameDimensions(scaleFactor) {
1050
+ return {
1051
+ frameWidth: Math.floor(this.effieData.width * scaleFactor / 2) * 2,
1052
+ frameHeight: Math.floor(this.effieData.height * scaleFactor / 2) * 2
1053
+ };
1054
+ }
1055
+ /**
1056
+ * Builds an FFmpeg input for a background (global or segment).
1057
+ */
1058
+ buildBackgroundInput(background, inputIndex, frameWidth, frameHeight) {
1059
+ if (background.type === "image") {
1060
+ return {
1061
+ index: inputIndex,
1062
+ source: background.source,
1063
+ preArgs: ["-loop", "1", "-framerate", this.effieData.fps.toString()],
1064
+ type: "image"
1065
+ };
1066
+ } else if (background.type === "video") {
1067
+ return {
1068
+ index: inputIndex,
1069
+ source: background.source,
1070
+ preArgs: ["-stream_loop", "-1"],
1071
+ type: "video"
1072
+ };
1073
+ }
1074
+ return {
1075
+ index: inputIndex,
1076
+ source: "",
1077
+ preArgs: [
1078
+ "-f",
1079
+ "lavfi",
1080
+ "-i",
1081
+ `color=${background.color}:size=${frameWidth}x${frameHeight}:rate=${this.effieData.fps}`
1082
+ ],
1083
+ type: "color"
1084
+ };
1085
+ }
1086
+ buildOutputArgs(outputFilename) {
1087
+ return [
1088
+ "-map",
1089
+ "[outv]",
1090
+ "-map",
1091
+ "[outa]",
1092
+ "-c:v",
1093
+ "libx264",
1094
+ "-r",
1095
+ this.effieData.fps.toString(),
1096
+ "-pix_fmt",
1097
+ "yuv420p",
1098
+ "-preset",
1099
+ "fast",
1100
+ "-crf",
1101
+ "28",
1102
+ "-c:a",
1103
+ "aac",
1104
+ "-movflags",
1105
+ "frag_keyframe+empty_moov",
1106
+ "-f",
1107
+ "mp4",
1108
+ outputFilename
1109
+ ];
1110
+ }
1111
+ buildLayerInput(layer, duration, inputIndex) {
1112
+ let preArgs = [];
1113
+ if (layer.type === "image") {
1114
+ preArgs = [
1115
+ "-loop",
1116
+ "1",
1117
+ "-t",
1118
+ duration.toString(),
1119
+ "-framerate",
1120
+ this.effieData.fps.toString()
1121
+ ];
1122
+ } else if (layer.type === "animation") {
1123
+ preArgs = ["-f", "image2", "-framerate", this.effieData.fps.toString()];
1124
+ }
1125
+ return {
1126
+ index: inputIndex,
1127
+ source: layer.source,
1128
+ preArgs,
1129
+ type: layer.type
1130
+ };
1131
+ }
1132
+ /**
1133
+ * Builds filter chain for all layers in a segment.
1134
+ * @param segment - The segment containing layers
1135
+ * @param bgLabel - Label for the background input (e.g., "bg_seg0" or "bg_seg")
1136
+ * @param labelPrefix - Prefix for generated labels (e.g., "seg0_" or "")
1137
+ * @param layerInputOffset - Starting input index for layers
1138
+ * @param frameWidth - Frame width for nullsrc
1139
+ * @param frameHeight - Frame height for nullsrc
1140
+ * @param outputLabel - Label for the final video output
1141
+ * @returns Array of filter parts to add to the filter chain
1142
+ */
1143
+ buildLayerFilters(segment, bgLabel, labelPrefix, layerInputOffset, frameWidth, frameHeight, outputLabel) {
1144
+ const filterParts = [];
1145
+ let currentVidLabel = bgLabel;
1146
+ for (let l = 0; l < segment.layers.length; l++) {
1147
+ const inputIdx = layerInputOffset + l;
1148
+ const layerLabel = `${labelPrefix}layer${l}`;
1149
+ const layer = segment.layers[l];
1150
+ const effectChain = layer.effects ? processEffects(
1151
+ layer.effects,
1152
+ this.effieData.fps,
1153
+ frameWidth,
1154
+ frameHeight
1155
+ ) : "";
1156
+ filterParts.push(
1157
+ `[${inputIdx}:v]trim=start=0:duration=${segment.duration},${effectChain ? effectChain + "," : ""}setsar=1,setpts=PTS-STARTPTS[${layerLabel}]`
1158
+ );
1159
+ let overlayInputLabel = layerLabel;
1160
+ const delay = layer.delay ?? 0;
1161
+ if (delay > 0) {
1162
+ filterParts.push(
1163
+ `nullsrc=size=${frameWidth}x${frameHeight}:duration=${delay},setpts=PTS-STARTPTS[null_${layerLabel}]`
1164
+ );
1165
+ filterParts.push(
1166
+ `[null_${layerLabel}][${layerLabel}]concat=n=2:v=1:a=0[delayed_${layerLabel}]`
1167
+ );
1168
+ overlayInputLabel = `delayed_${layerLabel}`;
1169
+ }
1170
+ const overlayOutputLabel = `${labelPrefix}tmp${l}`;
1171
+ const offset = layer.motion ? processMotion(delay, layer.motion) : "0:0";
1172
+ const fromTime = layer.from ?? 0;
1173
+ const untilTime = layer.until ?? segment.duration;
1174
+ filterParts.push(
1175
+ `[${currentVidLabel}][${overlayInputLabel}]overlay=${offset}:enable='between(t,${fromTime},${untilTime})',fps=${this.effieData.fps}[${overlayOutputLabel}]`
1176
+ );
1177
+ currentVidLabel = overlayOutputLabel;
1178
+ }
1179
+ filterParts.push(`[${currentVidLabel}]null[${outputLabel}]`);
1180
+ return filterParts;
1181
+ }
1182
+ /**
1183
+ * Applies xfade/concat transitions between video segments.
1184
+ * Modifies videoSegmentLabels in place to update labels after transitions.
1185
+ * @param filterParts - Array to append filter parts to
1186
+ * @param videoSegmentLabels - Array of video segment labels (modified in place)
1187
+ */
1188
+ applyTransitions(filterParts, videoSegmentLabels) {
1189
+ let transitionOffset = 0;
1190
+ this.effieData.segments.forEach((segment, i) => {
1191
+ if (i === 0) {
1192
+ transitionOffset = segment.duration;
1193
+ return;
1194
+ }
1195
+ const combineLabel = `[vid_com${i}]`;
1196
+ if (!segment.transition) {
1197
+ transitionOffset += segment.duration;
1198
+ filterParts.push(
1199
+ `${videoSegmentLabels[i - 1]}${videoSegmentLabels[i]}concat=n=2:v=1:a=0,fps=${this.effieData.fps}${combineLabel}`
1200
+ );
1201
+ videoSegmentLabels[i] = combineLabel;
1202
+ return;
1203
+ }
1204
+ const transitionName = processTransition(segment.transition);
1205
+ const transitionDuration = segment.transition.duration;
1206
+ transitionOffset -= transitionDuration;
1207
+ filterParts.push(
1208
+ `${videoSegmentLabels[i - 1]}${videoSegmentLabels[i]}xfade=transition=${transitionName}:duration=${transitionDuration}:offset=${transitionOffset}${combineLabel}`
1209
+ );
1210
+ videoSegmentLabels[i] = combineLabel;
1211
+ transitionOffset += segment.duration;
1212
+ });
1213
+ filterParts.push(`${videoSegmentLabels.at(-1)}null[outv]`);
1214
+ }
1215
+ /**
1216
+ * Applies general audio mixing: concats segment audio and mixes with global audio if present.
1217
+ * @param filterParts - Array to append filter parts to
1218
+ * @param audioSegmentLabels - Array of audio segment labels to concat
1219
+ * @param totalDuration - Total duration for audio trimming
1220
+ * @param generalAudioInputIndex - Input index for general audio (if present)
1221
+ */
1222
+ applyGeneralAudio(filterParts, audioSegmentLabels, totalDuration, generalAudioInputIndex) {
1223
+ if (this.effieData.audio) {
1224
+ const audioSeek = this.effieData.audio.seek ?? 0;
1225
+ const generalAudioFilter = this.buildAudioFilter({
1226
+ duration: totalDuration,
1227
+ volume: this.effieData.audio.volume,
1228
+ fadeIn: this.effieData.audio.fadeIn,
1229
+ fadeOut: this.effieData.audio.fadeOut
1230
+ });
1231
+ filterParts.push(
1232
+ `[${generalAudioInputIndex}:a]atrim=start=${audioSeek}:duration=${totalDuration},${generalAudioFilter},asetpts=PTS-STARTPTS[general_audio]`
1233
+ );
1234
+ filterParts.push(
1235
+ `${audioSegmentLabels.join("")}concat=n=${this.effieData.segments.length}:v=0:a=1,atrim=start=0:duration=${totalDuration}[segments_audio]`
1236
+ );
1237
+ filterParts.push(
1238
+ `[general_audio][segments_audio]amix=inputs=2:duration=longest[outa]`
1239
+ );
1240
+ } else {
1241
+ filterParts.push(
1242
+ `${audioSegmentLabels.join("")}concat=n=${this.effieData.segments.length}:v=0:a=1[outa]`
1243
+ );
1244
+ }
1245
+ }
1246
+ buildFFmpegCommand(outputFilename, scaleFactor = 1) {
1247
+ const globalArgs = ["-y", "-loglevel", "error"];
1248
+ const inputs = [];
1249
+ let inputIndex = 0;
1250
+ const { frameWidth, frameHeight } = this.getFrameDimensions(scaleFactor);
1251
+ const backgroundSeek = this.effieData.background.type === "video" ? this.effieData.background.seek ?? 0 : 0;
1252
+ inputs.push(
1253
+ this.buildBackgroundInput(
1254
+ this.effieData.background,
1255
+ inputIndex,
1256
+ frameWidth,
1257
+ frameHeight
1258
+ )
1259
+ );
1260
+ const globalBgInputIdx = inputIndex;
1261
+ inputIndex++;
1262
+ const segmentBgInputIndices = [];
1263
+ for (const segment of this.effieData.segments) {
1264
+ if (segment.background) {
1265
+ inputs.push(
1266
+ this.buildBackgroundInput(
1267
+ segment.background,
1268
+ inputIndex,
1269
+ frameWidth,
1270
+ frameHeight
1271
+ )
1272
+ );
1273
+ segmentBgInputIndices.push(inputIndex);
1274
+ inputIndex++;
1275
+ } else {
1276
+ segmentBgInputIndices.push(null);
1277
+ }
1278
+ }
1279
+ for (const segment of this.effieData.segments) {
1280
+ for (const layer of segment.layers) {
1281
+ inputs.push(this.buildLayerInput(layer, segment.duration, inputIndex));
1282
+ inputIndex++;
1283
+ }
1284
+ }
1285
+ for (const segment of this.effieData.segments) {
1286
+ if (segment.audio) {
1287
+ inputs.push({
1288
+ index: inputIndex,
1289
+ source: segment.audio.source,
1290
+ preArgs: [],
1291
+ type: "audio"
1292
+ });
1293
+ inputIndex++;
1294
+ }
1295
+ }
1296
+ if (this.effieData.audio) {
1297
+ inputs.push({
1298
+ index: inputIndex,
1299
+ source: this.effieData.audio.source,
1300
+ preArgs: [],
1301
+ type: "audio"
1302
+ });
1303
+ inputIndex++;
1304
+ }
1305
+ const numSegmentBgInputs = segmentBgInputIndices.filter(
1306
+ (i) => i !== null
1307
+ ).length;
1308
+ const numVideoInputs = 1 + numSegmentBgInputs + this.effieData.segments.reduce((sum, seg) => sum + seg.layers.length, 0);
1309
+ let audioCounter = 0;
1310
+ let currentTime = 0;
1311
+ let layerInputOffset = 1 + numSegmentBgInputs;
1312
+ const filterParts = [];
1313
+ const videoSegmentLabels = [];
1314
+ const audioSegmentLabels = [];
1315
+ for (let segIdx = 0; segIdx < this.effieData.segments.length; segIdx++) {
1316
+ const segment = this.effieData.segments[segIdx];
1317
+ const bgLabel = `bg_seg${segIdx}`;
1318
+ if (segment.background) {
1319
+ const segBgInputIdx = segmentBgInputIndices[segIdx];
1320
+ const segBgSeek = segment.background.type === "video" ? segment.background.seek ?? 0 : 0;
1321
+ filterParts.push(
1322
+ `[${segBgInputIdx}:v]fps=${this.effieData.fps},scale=${frameWidth}x${frameHeight},trim=start=${segBgSeek}:duration=${segment.duration},setpts=PTS-STARTPTS[${bgLabel}]`
1323
+ );
1324
+ } else {
1325
+ filterParts.push(
1326
+ `[${globalBgInputIdx}:v]fps=${this.effieData.fps},scale=${frameWidth}x${frameHeight},trim=start=${backgroundSeek + currentTime}:duration=${segment.duration},setpts=PTS-STARTPTS[${bgLabel}]`
1327
+ );
1328
+ }
1329
+ const vidLabel = `vid_seg${segIdx}`;
1330
+ filterParts.push(
1331
+ ...this.buildLayerFilters(
1332
+ segment,
1333
+ bgLabel,
1334
+ `seg${segIdx}_`,
1335
+ layerInputOffset,
1336
+ frameWidth,
1337
+ frameHeight,
1338
+ vidLabel
1339
+ )
1340
+ );
1341
+ layerInputOffset += segment.layers.length;
1342
+ videoSegmentLabels.push(`[${vidLabel}]`);
1343
+ const nextSegment = this.effieData.segments[segIdx + 1];
1344
+ const transitionDuration = nextSegment?.transition?.duration ?? 0;
1345
+ const realDuration = Math.max(
1346
+ 1e-3,
1347
+ segment.duration - transitionDuration
1348
+ );
1349
+ if (segment.audio) {
1350
+ const audioInputIndex = numVideoInputs + audioCounter;
1351
+ const audioFilter = this.buildAudioFilter({
1352
+ duration: realDuration,
1353
+ volume: segment.audio.volume,
1354
+ fadeIn: segment.audio.fadeIn,
1355
+ fadeOut: segment.audio.fadeOut
1356
+ });
1357
+ filterParts.push(
1358
+ `[${audioInputIndex}:a]atrim=start=0:duration=${realDuration},${audioFilter},asetpts=PTS-STARTPTS[aud_seg${segIdx}]`
1359
+ );
1360
+ audioCounter++;
1361
+ } else {
1362
+ filterParts.push(
1363
+ `anullsrc=r=44100:cl=stereo,atrim=start=0:duration=${realDuration},asetpts=PTS-STARTPTS[aud_seg${segIdx}]`
1364
+ );
1365
+ }
1366
+ audioSegmentLabels.push(`[aud_seg${segIdx}]`);
1367
+ currentTime += realDuration;
1368
+ }
1369
+ this.applyGeneralAudio(
1370
+ filterParts,
1371
+ audioSegmentLabels,
1372
+ currentTime,
1373
+ numVideoInputs + audioCounter
1374
+ );
1375
+ this.applyTransitions(filterParts, videoSegmentLabels);
1376
+ const filterComplex = filterParts.join(";");
1377
+ const outputArgs = this.buildOutputArgs(outputFilename);
1378
+ return new FFmpegCommand(globalArgs, inputs, filterComplex, outputArgs);
1379
+ }
1380
+ createImageTransformer(scaleFactor) {
1381
+ return async (imageStream) => {
1382
+ if (scaleFactor === 1) return imageStream;
1383
+ const sharpTransformer = sharp();
1384
+ imageStream.on("error", (err) => {
1385
+ if (!sharpTransformer.destroyed) {
1386
+ sharpTransformer.destroy(err);
1387
+ }
1388
+ });
1389
+ sharpTransformer.on("error", (err) => {
1390
+ if (!imageStream.destroyed) {
1391
+ imageStream.destroy(err);
1392
+ }
1393
+ });
1394
+ imageStream.pipe(sharpTransformer);
1395
+ try {
1396
+ const metadata = await sharpTransformer.metadata();
1397
+ const imageWidth = metadata.width ?? this.effieData.width;
1398
+ const imageHeight = metadata.height ?? this.effieData.height;
1399
+ return sharpTransformer.resize({
1400
+ width: Math.floor(imageWidth * scaleFactor),
1401
+ height: Math.floor(imageHeight * scaleFactor)
1402
+ });
1403
+ } catch (error) {
1404
+ if (!sharpTransformer.destroyed) {
1405
+ sharpTransformer.destroy(error);
1406
+ }
1407
+ throw error;
1408
+ }
1409
+ };
1410
+ }
1411
+ /**
1412
+ * Renders the effie data to a video stream.
1413
+ * @param scaleFactor - Scale factor for output dimensions
1414
+ */
1415
+ async render(scaleFactor = 1) {
1416
+ const ffmpegCommand = this.buildFFmpegCommand("-", scaleFactor);
1417
+ this.ffmpegRunner = new FFmpegRunner(ffmpegCommand);
1418
+ return this.ffmpegRunner.run(
1419
+ async ({ src }) => this.fetchSource(src),
1420
+ this.createImageTransformer(scaleFactor)
1421
+ );
1422
+ }
1423
+ close() {
1424
+ if (this.ffmpegRunner) {
1425
+ this.ffmpegRunner.close();
1426
+ }
1427
+ }
1428
+ };
1429
+
1430
+ // src/handlers/rendering.ts
1431
+ import { effieDataSchema as effieDataSchema2 } from "@effing/effie";
1432
+ async function createRenderJob(req, res, ctx2) {
1433
+ try {
1434
+ const isWrapped = "effie" in req.body;
1435
+ let rawEffieData;
1436
+ let scale;
1437
+ let upload;
1438
+ if (isWrapped) {
1439
+ const options = req.body;
1440
+ if (typeof options.effie === "string") {
1441
+ const response = await ffsFetch(options.effie);
1442
+ if (!response.ok) {
1443
+ throw new Error(
1444
+ `Failed to fetch Effie data: ${response.status} ${response.statusText}`
1445
+ );
1446
+ }
1447
+ rawEffieData = await response.json();
1448
+ } else {
1449
+ rawEffieData = options.effie;
1450
+ }
1451
+ scale = options.scale ?? 1;
1452
+ upload = options.upload;
1453
+ } else {
1454
+ rawEffieData = req.body;
1455
+ scale = parseFloat(req.query.scale?.toString() || "1");
1456
+ }
1457
+ let effie;
1458
+ if (!ctx2.skipValidation) {
1459
+ const result = effieDataSchema2.safeParse(rawEffieData);
1460
+ if (!result.success) {
1461
+ res.status(400).json({
1462
+ error: "Invalid effie data",
1463
+ issues: result.error.issues.map((issue) => ({
1464
+ path: issue.path.join("."),
1465
+ message: issue.message
1466
+ }))
1467
+ });
1468
+ return;
1469
+ }
1470
+ effie = result.data;
1471
+ } else {
1472
+ const data = rawEffieData;
1473
+ if (!data?.segments) {
1474
+ res.status(400).json({ error: "Invalid effie data: missing segments" });
1475
+ return;
1476
+ }
1477
+ effie = data;
1478
+ }
1479
+ const jobId = randomUUID2();
1480
+ const job = {
1481
+ effie,
1482
+ scale,
1483
+ upload,
1484
+ createdAt: Date.now()
1485
+ };
1486
+ await ctx2.cacheStorage.putJson(cacheKeys.renderJob(jobId), job);
1487
+ res.json({
1488
+ id: jobId,
1489
+ url: `${ctx2.baseUrl}/render/${jobId}`
1490
+ });
1491
+ } catch (error) {
1492
+ console.error("Error creating render job:", error);
1493
+ res.status(500).json({ error: "Failed to create render job" });
1494
+ }
1495
+ }
1496
+ async function streamRenderJob(req, res, ctx2) {
1497
+ try {
1498
+ setupCORSHeaders(res);
1499
+ const jobId = req.params.id;
1500
+ const jobCacheKey = cacheKeys.renderJob(jobId);
1501
+ const job = await ctx2.cacheStorage.getJson(jobCacheKey);
1502
+ ctx2.cacheStorage.delete(jobCacheKey);
1503
+ if (!job) {
1504
+ res.status(404).json({ error: "Job not found or expired" });
1505
+ return;
1506
+ }
1507
+ if (job.upload) {
1508
+ await streamRenderWithUpload(res, job, ctx2);
1509
+ } else {
1510
+ await streamRenderDirect(res, job, ctx2);
1511
+ }
1512
+ } catch (error) {
1513
+ console.error("Error in render:", error);
1514
+ if (!res.headersSent) {
1515
+ res.status(500).json({ error: "Rendering failed" });
1516
+ } else {
1517
+ res.end();
1518
+ }
1519
+ }
1520
+ }
1521
+ async function streamRenderDirect(res, job, ctx2) {
1522
+ const renderer = new EffieRenderer(job.effie, {
1523
+ cacheStorage: ctx2.cacheStorage
1524
+ });
1525
+ const videoStream = await renderer.render(job.scale);
1526
+ res.on("close", () => {
1527
+ videoStream.destroy();
1528
+ renderer.close();
1529
+ });
1530
+ res.set("Content-Type", "video/mp4");
1531
+ videoStream.pipe(res);
1532
+ }
1533
+ async function streamRenderWithUpload(res, job, ctx2) {
1534
+ setupSSEResponse(res);
1535
+ const sendEvent = createSSEEventSender(res);
1536
+ const keepalive = setInterval(() => {
1537
+ sendEvent("keepalive", { status: "rendering" });
1538
+ }, 25e3);
1539
+ try {
1540
+ sendEvent("started", { status: "rendering" });
1541
+ const timings = await renderAndUploadInternal(
1542
+ job.effie,
1543
+ job.scale,
1544
+ job.upload,
1545
+ sendEvent,
1546
+ ctx2
1547
+ );
1548
+ sendEvent("complete", { status: "uploaded", timings });
1549
+ } catch (error) {
1550
+ sendEvent("error", { message: String(error) });
1551
+ } finally {
1552
+ clearInterval(keepalive);
1553
+ res.end();
1554
+ }
1555
+ }
1556
+ async function renderAndUploadInternal(effie, scale, upload, sendEvent, ctx2) {
1557
+ const timings = {};
1558
+ if (upload.coverUrl) {
1559
+ const fetchCoverStartTime = Date.now();
1560
+ const coverFetchResponse = await ffsFetch(effie.cover);
1561
+ if (!coverFetchResponse.ok) {
1562
+ throw new Error(
1563
+ `Failed to fetch cover image: ${coverFetchResponse.status} ${coverFetchResponse.statusText}`
1564
+ );
1565
+ }
1566
+ const coverBuffer = Buffer.from(await coverFetchResponse.arrayBuffer());
1567
+ timings.fetchCoverTime = Date.now() - fetchCoverStartTime;
1568
+ const uploadCoverStartTime = Date.now();
1569
+ const uploadCoverResponse = await ffsFetch(upload.coverUrl, {
1570
+ method: "PUT",
1571
+ body: coverBuffer,
1572
+ headers: {
1573
+ "Content-Type": "image/png",
1574
+ "Content-Length": coverBuffer.length.toString()
1575
+ }
1576
+ });
1577
+ if (!uploadCoverResponse.ok) {
1578
+ throw new Error(
1579
+ `Failed to upload cover: ${uploadCoverResponse.status} ${uploadCoverResponse.statusText}`
1580
+ );
1581
+ }
1582
+ timings.uploadCoverTime = Date.now() - uploadCoverStartTime;
1583
+ }
1584
+ const renderStartTime = Date.now();
1585
+ const renderer = new EffieRenderer(effie, { cacheStorage: ctx2.cacheStorage });
1586
+ const videoStream = await renderer.render(scale);
1587
+ const chunks = [];
1588
+ for await (const chunk of videoStream) {
1589
+ chunks.push(Buffer.from(chunk));
1590
+ }
1591
+ const videoBuffer = Buffer.concat(chunks);
1592
+ timings.renderTime = Date.now() - renderStartTime;
1593
+ sendEvent("keepalive", { status: "uploading" });
1594
+ const uploadStartTime = Date.now();
1595
+ const uploadResponse = await ffsFetch(upload.videoUrl, {
1596
+ method: "PUT",
1597
+ body: videoBuffer,
1598
+ headers: {
1599
+ "Content-Type": "video/mp4",
1600
+ "Content-Length": videoBuffer.length.toString()
1601
+ }
1602
+ });
1603
+ if (!uploadResponse.ok) {
1604
+ throw new Error(
1605
+ `Failed to upload rendered video: ${uploadResponse.status} ${uploadResponse.statusText}`
1606
+ );
1607
+ }
1608
+ timings.uploadTime = Date.now() - uploadStartTime;
1609
+ return timings;
1610
+ }
1611
+
1612
+ // src/server.ts
1613
+ var app = express4();
1614
+ app.use(bodyParser.json({ limit: "50mb" }));
1615
+ var ctx = createServerContext();
1616
+ function validateAuth(req, res) {
1617
+ const apiKey = process.env.FFS_API_KEY;
1618
+ if (!apiKey) return true;
1619
+ const authHeader = req.headers.authorization;
1620
+ if (!authHeader || authHeader !== `Bearer ${apiKey}`) {
1621
+ res.status(401).json({ error: "Unauthorized" });
1622
+ return false;
1623
+ }
1624
+ return true;
1625
+ }
1626
+ app.post("/warmup", (req, res) => {
1627
+ if (!validateAuth(req, res)) return;
1628
+ createWarmupJob(req, res, ctx);
1629
+ });
1630
+ app.post("/purge", (req, res) => {
1631
+ if (!validateAuth(req, res)) return;
1632
+ purgeCache(req, res, ctx);
1633
+ });
1634
+ app.post("/render", (req, res) => {
1635
+ if (!validateAuth(req, res)) return;
1636
+ createRenderJob(req, res, ctx);
1637
+ });
1638
+ app.get("/warmup/:id", (req, res) => streamWarmupJob(req, res, ctx));
1639
+ app.get("/render/:id", (req, res) => streamRenderJob(req, res, ctx));
1640
+ var port = process.env.FFS_PORT || 2e3;
1641
+ var server = app.listen(port, () => {
1642
+ console.log(`FFS server listening on port ${port}`);
1643
+ });
1644
+ function shutdown() {
1645
+ console.log("Shutting down FFS server...");
1646
+ ctx.cacheStorage.close();
1647
+ server.close(() => {
1648
+ console.log("FFS server stopped");
1649
+ process.exit(0);
1650
+ });
1651
+ }
1652
+ process.on("SIGTERM", shutdown);
1653
+ process.on("SIGINT", shutdown);
1654
+ export {
1655
+ app
1656
+ };