@effing/ffs 0.1.1 → 0.2.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/README.md +2 -0
- package/dist/{chunk-LK5K4SQV.js → chunk-6YHSYHDY.js} +182 -22
- package/dist/chunk-6YHSYHDY.js.map +1 -0
- package/dist/{chunk-RNE6TKMF.js → chunk-A7BAW24L.js} +212 -162
- package/dist/chunk-A7BAW24L.js.map +1 -0
- package/dist/handlers/index.d.ts +5 -4
- package/dist/handlers/index.js +2 -2
- package/dist/index.d.ts +19 -11
- package/dist/index.js +3 -9
- package/dist/proxy-BI8OMQl0.d.ts +68 -0
- package/dist/server.js +277 -66
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/dist/cache-BUVFfGZF.d.ts +0 -25
- package/dist/chunk-LK5K4SQV.js.map +0 -1
- package/dist/chunk-RNE6TKMF.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -302,12 +302,160 @@ var cacheKeys = {
|
|
|
302
302
|
renderJob: renderJobCacheKey
|
|
303
303
|
};
|
|
304
304
|
|
|
305
|
+
// src/proxy.ts
|
|
306
|
+
import http from "http";
|
|
307
|
+
import { Readable } from "stream";
|
|
308
|
+
|
|
309
|
+
// src/fetch.ts
|
|
310
|
+
import { fetch, Agent } from "undici";
|
|
311
|
+
async function ffsFetch(url, options) {
|
|
312
|
+
const {
|
|
313
|
+
method,
|
|
314
|
+
body,
|
|
315
|
+
headers,
|
|
316
|
+
headersTimeout = 3e5,
|
|
317
|
+
// 5 minutes
|
|
318
|
+
bodyTimeout = 3e5
|
|
319
|
+
// 5 minutes
|
|
320
|
+
} = options ?? {};
|
|
321
|
+
const agent = new Agent({ headersTimeout, bodyTimeout });
|
|
322
|
+
return fetch(url, {
|
|
323
|
+
method,
|
|
324
|
+
body,
|
|
325
|
+
headers: { "User-Agent": "FFS (+https://effing.dev/ffs)", ...headers },
|
|
326
|
+
dispatcher: agent
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/proxy.ts
|
|
331
|
+
var HttpProxy = class {
|
|
332
|
+
server = null;
|
|
333
|
+
_port = null;
|
|
334
|
+
startPromise = null;
|
|
335
|
+
get port() {
|
|
336
|
+
return this._port;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Transform a URL to go through the proxy.
|
|
340
|
+
* @throws Error if proxy not started
|
|
341
|
+
*/
|
|
342
|
+
transformUrl(url) {
|
|
343
|
+
if (this._port === null) throw new Error("Proxy not started");
|
|
344
|
+
return `http://127.0.0.1:${this._port}/${url}`;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Start the proxy server. Safe to call multiple times.
|
|
348
|
+
*/
|
|
349
|
+
async start() {
|
|
350
|
+
if (this._port !== null) return;
|
|
351
|
+
if (this.startPromise) {
|
|
352
|
+
await this.startPromise;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.startPromise = this.doStart();
|
|
356
|
+
await this.startPromise;
|
|
357
|
+
}
|
|
358
|
+
async doStart() {
|
|
359
|
+
this.server = http.createServer(async (req, res) => {
|
|
360
|
+
try {
|
|
361
|
+
const originalUrl = this.parseProxyPath(req.url || "");
|
|
362
|
+
if (!originalUrl) {
|
|
363
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
364
|
+
res.end("Bad Request: invalid proxy path");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const response = await ffsFetch(originalUrl, {
|
|
368
|
+
method: req.method,
|
|
369
|
+
headers: this.filterHeaders(req.headers),
|
|
370
|
+
bodyTimeout: 0
|
|
371
|
+
// No timeout for streaming
|
|
372
|
+
});
|
|
373
|
+
const headers = {};
|
|
374
|
+
response.headers.forEach((value, key) => {
|
|
375
|
+
headers[key] = value;
|
|
376
|
+
});
|
|
377
|
+
res.writeHead(response.status, headers);
|
|
378
|
+
if (response.body) {
|
|
379
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
380
|
+
nodeStream.pipe(res);
|
|
381
|
+
nodeStream.on("error", (err) => {
|
|
382
|
+
console.error("Proxy stream error:", err);
|
|
383
|
+
res.destroy();
|
|
384
|
+
});
|
|
385
|
+
} else {
|
|
386
|
+
res.end();
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.error("Proxy request error:", err);
|
|
390
|
+
if (!res.headersSent) {
|
|
391
|
+
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
392
|
+
res.end("Bad Gateway");
|
|
393
|
+
} else {
|
|
394
|
+
res.destroy();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
await new Promise((resolve) => {
|
|
399
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
400
|
+
this._port = this.server.address().port;
|
|
401
|
+
resolve();
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Parse the proxy path to extract the original URL.
|
|
407
|
+
* Path format: /{originalUrl}
|
|
408
|
+
*/
|
|
409
|
+
parseProxyPath(path3) {
|
|
410
|
+
if (!path3.startsWith("/http://") && !path3.startsWith("/https://")) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
return path3.slice(1);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Filter headers to forward to the upstream server.
|
|
417
|
+
* Removes hop-by-hop headers that shouldn't be forwarded.
|
|
418
|
+
*/
|
|
419
|
+
filterHeaders(headers) {
|
|
420
|
+
const skip = /* @__PURE__ */ new Set([
|
|
421
|
+
"host",
|
|
422
|
+
"connection",
|
|
423
|
+
"keep-alive",
|
|
424
|
+
"transfer-encoding",
|
|
425
|
+
"te",
|
|
426
|
+
"trailer",
|
|
427
|
+
"upgrade",
|
|
428
|
+
"proxy-authorization",
|
|
429
|
+
"proxy-authenticate"
|
|
430
|
+
]);
|
|
431
|
+
const result = {};
|
|
432
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
433
|
+
if (!skip.has(key.toLowerCase()) && typeof value === "string") {
|
|
434
|
+
result[key] = value;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Close the proxy server and reset state.
|
|
441
|
+
*/
|
|
442
|
+
close() {
|
|
443
|
+
this.server?.close();
|
|
444
|
+
this.server = null;
|
|
445
|
+
this._port = null;
|
|
446
|
+
this.startPromise = null;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
305
450
|
// src/handlers/shared.ts
|
|
306
451
|
import { effieDataSchema } from "@effing/effie";
|
|
307
|
-
function createServerContext() {
|
|
452
|
+
async function createServerContext() {
|
|
308
453
|
const port2 = process.env.FFS_PORT || 2e3;
|
|
454
|
+
const httpProxy = new HttpProxy();
|
|
455
|
+
await httpProxy.start();
|
|
309
456
|
return {
|
|
310
457
|
cacheStorage: createCacheStorage(),
|
|
458
|
+
httpProxy,
|
|
311
459
|
baseUrl: process.env.FFS_BASE_URL || `http://localhost:${port2}`,
|
|
312
460
|
skipValidation: !!process.env.FFS_SKIP_VALIDATION && process.env.FFS_SKIP_VALIDATION !== "false",
|
|
313
461
|
cacheConcurrency: parseInt(process.env.FFS_CACHE_CONCURRENCY || "4", 10)
|
|
@@ -357,32 +505,15 @@ data: ${JSON.stringify(data)}
|
|
|
357
505
|
|
|
358
506
|
// src/handlers/caching.ts
|
|
359
507
|
import "express";
|
|
360
|
-
import { Readable, Transform } from "stream";
|
|
508
|
+
import { Readable as Readable2, Transform } from "stream";
|
|
361
509
|
import { randomUUID } from "crypto";
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
});
|
|
510
|
+
import {
|
|
511
|
+
extractEffieSources,
|
|
512
|
+
extractEffieSourcesWithTypes
|
|
513
|
+
} from "@effing/effie";
|
|
514
|
+
function shouldSkipWarmup(source) {
|
|
515
|
+
return source.type === "video" || source.type === "audio";
|
|
382
516
|
}
|
|
383
|
-
|
|
384
|
-
// src/handlers/caching.ts
|
|
385
|
-
import { extractEffieSources } from "@effing/effie";
|
|
386
517
|
var inFlightFetches = /* @__PURE__ */ new Map();
|
|
387
518
|
async function createWarmupJob(req, res, ctx2) {
|
|
388
519
|
try {
|
|
@@ -391,7 +522,7 @@ async function createWarmupJob(req, res, ctx2) {
|
|
|
391
522
|
res.status(400).json(parseResult);
|
|
392
523
|
return;
|
|
393
524
|
}
|
|
394
|
-
const sources =
|
|
525
|
+
const sources = extractEffieSourcesWithTypes(parseResult.effie);
|
|
395
526
|
const jobId = randomUUID();
|
|
396
527
|
await ctx2.cacheStorage.putJson(cacheKeys.warmupJob(jobId), { sources });
|
|
397
528
|
res.json({
|
|
@@ -457,53 +588,75 @@ async function purgeCache(req, res, ctx2) {
|
|
|
457
588
|
}
|
|
458
589
|
async function warmupSources(sources, sendEvent, ctx2) {
|
|
459
590
|
const total = sources.length;
|
|
460
|
-
const sourceCacheKeys = sources.map(cacheKeys.source);
|
|
461
591
|
sendEvent("start", { total });
|
|
462
|
-
const existsMap = await ctx2.cacheStorage.existsMany(sourceCacheKeys);
|
|
463
592
|
let cached = 0;
|
|
464
593
|
let failed = 0;
|
|
465
|
-
|
|
594
|
+
let skipped = 0;
|
|
595
|
+
const sourcesToCache = [];
|
|
596
|
+
for (const source of sources) {
|
|
597
|
+
if (shouldSkipWarmup(source)) {
|
|
598
|
+
skipped++;
|
|
599
|
+
sendEvent("progress", {
|
|
600
|
+
url: source.url,
|
|
601
|
+
status: "skipped",
|
|
602
|
+
reason: "http-video-audio-passthrough",
|
|
603
|
+
cached,
|
|
604
|
+
failed,
|
|
605
|
+
skipped,
|
|
606
|
+
total
|
|
607
|
+
});
|
|
608
|
+
} else {
|
|
609
|
+
sourcesToCache.push(source);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const sourceCacheKeys = sourcesToCache.map((s) => cacheKeys.source(s.url));
|
|
613
|
+
const existsMap = await ctx2.cacheStorage.existsMany(sourceCacheKeys);
|
|
614
|
+
for (let i = 0; i < sourcesToCache.length; i++) {
|
|
466
615
|
if (existsMap.get(sourceCacheKeys[i])) {
|
|
467
616
|
cached++;
|
|
468
617
|
sendEvent("progress", {
|
|
469
|
-
url:
|
|
618
|
+
url: sourcesToCache[i].url,
|
|
470
619
|
status: "hit",
|
|
471
620
|
cached,
|
|
472
621
|
failed,
|
|
622
|
+
skipped,
|
|
473
623
|
total
|
|
474
624
|
});
|
|
475
625
|
}
|
|
476
626
|
}
|
|
477
|
-
const uncached =
|
|
627
|
+
const uncached = sourcesToCache.filter(
|
|
628
|
+
(_, i) => !existsMap.get(sourceCacheKeys[i])
|
|
629
|
+
);
|
|
478
630
|
if (uncached.length === 0) {
|
|
479
|
-
sendEvent("summary", { cached, failed, total });
|
|
631
|
+
sendEvent("summary", { cached, failed, skipped, total });
|
|
480
632
|
return;
|
|
481
633
|
}
|
|
482
634
|
const keepalive = setInterval(() => {
|
|
483
|
-
sendEvent("keepalive", { cached, failed, total });
|
|
635
|
+
sendEvent("keepalive", { cached, failed, skipped, total });
|
|
484
636
|
}, 25e3);
|
|
485
637
|
const queue = [...uncached];
|
|
486
638
|
const workers = Array.from(
|
|
487
639
|
{ length: Math.min(ctx2.cacheConcurrency, queue.length) },
|
|
488
640
|
async () => {
|
|
489
641
|
while (queue.length > 0) {
|
|
490
|
-
const
|
|
491
|
-
const cacheKey = cacheKeys.source(url);
|
|
642
|
+
const source = queue.shift();
|
|
643
|
+
const cacheKey = cacheKeys.source(source.url);
|
|
492
644
|
const startTime = Date.now();
|
|
493
645
|
try {
|
|
494
646
|
let fetchPromise = inFlightFetches.get(cacheKey);
|
|
495
647
|
if (!fetchPromise) {
|
|
496
|
-
fetchPromise = fetchAndCache(url, cacheKey, sendEvent, ctx2);
|
|
648
|
+
fetchPromise = fetchAndCache(source.url, cacheKey, sendEvent, ctx2);
|
|
497
649
|
inFlightFetches.set(cacheKey, fetchPromise);
|
|
498
650
|
}
|
|
499
651
|
await fetchPromise;
|
|
500
652
|
inFlightFetches.delete(cacheKey);
|
|
501
653
|
cached++;
|
|
502
654
|
sendEvent("progress", {
|
|
503
|
-
url,
|
|
655
|
+
url: source.url,
|
|
504
656
|
status: "cached",
|
|
505
657
|
cached,
|
|
506
658
|
failed,
|
|
659
|
+
skipped,
|
|
507
660
|
total,
|
|
508
661
|
ms: Date.now() - startTime
|
|
509
662
|
});
|
|
@@ -511,11 +664,12 @@ async function warmupSources(sources, sendEvent, ctx2) {
|
|
|
511
664
|
inFlightFetches.delete(cacheKey);
|
|
512
665
|
failed++;
|
|
513
666
|
sendEvent("progress", {
|
|
514
|
-
url,
|
|
667
|
+
url: source.url,
|
|
515
668
|
status: "error",
|
|
516
669
|
error: String(error),
|
|
517
670
|
cached,
|
|
518
671
|
failed,
|
|
672
|
+
skipped,
|
|
519
673
|
total,
|
|
520
674
|
ms: Date.now() - startTime
|
|
521
675
|
});
|
|
@@ -525,7 +679,7 @@ async function warmupSources(sources, sendEvent, ctx2) {
|
|
|
525
679
|
);
|
|
526
680
|
await Promise.all(workers);
|
|
527
681
|
clearInterval(keepalive);
|
|
528
|
-
sendEvent("summary", { cached, failed, total });
|
|
682
|
+
sendEvent("summary", { cached, failed, skipped, total });
|
|
529
683
|
}
|
|
530
684
|
async function fetchAndCache(url, cacheKey, sendEvent, ctx2) {
|
|
531
685
|
const response = await ffsFetch(url, {
|
|
@@ -538,7 +692,7 @@ async function fetchAndCache(url, cacheKey, sendEvent, ctx2) {
|
|
|
538
692
|
throw new Error(`${response.status} ${response.statusText}`);
|
|
539
693
|
}
|
|
540
694
|
sendEvent("downloading", { url, status: "started", bytesReceived: 0 });
|
|
541
|
-
const sourceStream =
|
|
695
|
+
const sourceStream = Readable2.fromWeb(
|
|
542
696
|
response.body
|
|
543
697
|
);
|
|
544
698
|
let totalBytes = 0;
|
|
@@ -568,7 +722,7 @@ import "express";
|
|
|
568
722
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
569
723
|
|
|
570
724
|
// src/render.ts
|
|
571
|
-
import { Readable as
|
|
725
|
+
import { Readable as Readable3 } from "stream";
|
|
572
726
|
import { createReadStream as createReadStream2 } from "fs";
|
|
573
727
|
|
|
574
728
|
// src/motion.ts
|
|
@@ -815,14 +969,14 @@ var FFmpegRunner = class {
|
|
|
815
969
|
constructor(command) {
|
|
816
970
|
this.command = command;
|
|
817
971
|
}
|
|
818
|
-
async run(
|
|
972
|
+
async run(sourceFetcher, imageTransformer, referenceResolver, urlTransformer) {
|
|
819
973
|
const tempDir = await fs2.mkdtemp(path2.join(os2.tmpdir(), "ffs-"));
|
|
820
974
|
const fileMapping = /* @__PURE__ */ new Map();
|
|
821
|
-
const
|
|
822
|
-
const fetchAndSaveSource = async (input, inputName) => {
|
|
823
|
-
const stream = await
|
|
975
|
+
const fetchCache = /* @__PURE__ */ new Map();
|
|
976
|
+
const fetchAndSaveSource = async (input, sourceUrl, inputName) => {
|
|
977
|
+
const stream = await sourceFetcher({
|
|
824
978
|
type: input.type,
|
|
825
|
-
src:
|
|
979
|
+
src: sourceUrl
|
|
826
980
|
});
|
|
827
981
|
if (input.type === "animation") {
|
|
828
982
|
const extractionDir = path2.join(tempDir, inputName);
|
|
@@ -864,17 +1018,27 @@ var FFmpegRunner = class {
|
|
|
864
1018
|
this.command.inputs.map(async (input) => {
|
|
865
1019
|
if (input.type === "color") return;
|
|
866
1020
|
const inputName = `ffmpeg_input_${input.index.toString().padStart(3, "0")}`;
|
|
1021
|
+
const sourceUrl = referenceResolver ? referenceResolver(input.source) : input.source;
|
|
1022
|
+
if ((input.type === "video" || input.type === "audio") && (sourceUrl.startsWith("http://") || sourceUrl.startsWith("https://"))) {
|
|
1023
|
+
const finalUrl = urlTransformer ? urlTransformer(sourceUrl) : sourceUrl;
|
|
1024
|
+
fileMapping.set(input.index, finalUrl);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
867
1027
|
const shouldCache = input.source.startsWith("#");
|
|
868
1028
|
if (shouldCache) {
|
|
869
|
-
let fetchPromise =
|
|
1029
|
+
let fetchPromise = fetchCache.get(input.source);
|
|
870
1030
|
if (!fetchPromise) {
|
|
871
|
-
fetchPromise = fetchAndSaveSource(input, inputName);
|
|
872
|
-
|
|
1031
|
+
fetchPromise = fetchAndSaveSource(input, sourceUrl, inputName);
|
|
1032
|
+
fetchCache.set(input.source, fetchPromise);
|
|
873
1033
|
}
|
|
874
1034
|
const filePath = await fetchPromise;
|
|
875
1035
|
fileMapping.set(input.index, filePath);
|
|
876
1036
|
} else {
|
|
877
|
-
const filePath = await fetchAndSaveSource(
|
|
1037
|
+
const filePath = await fetchAndSaveSource(
|
|
1038
|
+
input,
|
|
1039
|
+
sourceUrl,
|
|
1040
|
+
inputName
|
|
1041
|
+
);
|
|
878
1042
|
fileMapping.set(input.index, filePath);
|
|
879
1043
|
}
|
|
880
1044
|
})
|
|
@@ -970,19 +1134,14 @@ var EffieRenderer = class {
|
|
|
970
1134
|
ffmpegRunner;
|
|
971
1135
|
allowLocalFiles;
|
|
972
1136
|
cacheStorage;
|
|
1137
|
+
httpProxy;
|
|
973
1138
|
constructor(effieData, options) {
|
|
974
1139
|
this.effieData = effieData;
|
|
975
1140
|
this.allowLocalFiles = options?.allowLocalFiles ?? false;
|
|
976
1141
|
this.cacheStorage = options?.cacheStorage;
|
|
1142
|
+
this.httpProxy = options?.httpProxy;
|
|
977
1143
|
}
|
|
978
1144
|
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
1145
|
if (src.startsWith("data:")) {
|
|
987
1146
|
const commaIndex = src.indexOf(",");
|
|
988
1147
|
if (commaIndex === -1) {
|
|
@@ -992,7 +1151,7 @@ var EffieRenderer = class {
|
|
|
992
1151
|
const isBase64 = meta.endsWith(";base64");
|
|
993
1152
|
const data = src.slice(commaIndex + 1);
|
|
994
1153
|
const buffer = isBase64 ? Buffer.from(data, "base64") : Buffer.from(decodeURIComponent(data));
|
|
995
|
-
return
|
|
1154
|
+
return Readable3.from(buffer);
|
|
996
1155
|
}
|
|
997
1156
|
if (src.startsWith("file:")) {
|
|
998
1157
|
if (!this.allowLocalFiles) {
|
|
@@ -1024,7 +1183,7 @@ var EffieRenderer = class {
|
|
|
1024
1183
|
if (!response.body) {
|
|
1025
1184
|
throw new Error(`No body for ${src}`);
|
|
1026
1185
|
}
|
|
1027
|
-
return
|
|
1186
|
+
return Readable3.fromWeb(response.body);
|
|
1028
1187
|
}
|
|
1029
1188
|
buildAudioFilter({
|
|
1030
1189
|
duration,
|
|
@@ -1276,6 +1435,12 @@ var EffieRenderer = class {
|
|
|
1276
1435
|
segmentBgInputIndices.push(null);
|
|
1277
1436
|
}
|
|
1278
1437
|
}
|
|
1438
|
+
const globalBgSegmentIndices = [];
|
|
1439
|
+
for (let i = 0; i < this.effieData.segments.length; i++) {
|
|
1440
|
+
if (segmentBgInputIndices[i] === null) {
|
|
1441
|
+
globalBgSegmentIndices.push(i);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1279
1444
|
for (const segment of this.effieData.segments) {
|
|
1280
1445
|
for (const layer of segment.layers) {
|
|
1281
1446
|
inputs.push(this.buildLayerInput(layer, segment.duration, inputIndex));
|
|
@@ -1312,6 +1477,26 @@ var EffieRenderer = class {
|
|
|
1312
1477
|
const filterParts = [];
|
|
1313
1478
|
const videoSegmentLabels = [];
|
|
1314
1479
|
const audioSegmentLabels = [];
|
|
1480
|
+
const globalBgFifoLabels = /* @__PURE__ */ new Map();
|
|
1481
|
+
const bgFilter = `fps=${this.effieData.fps},scale=${frameWidth}x${frameHeight}:force_original_aspect_ratio=increase,crop=${frameWidth}:${frameHeight}`;
|
|
1482
|
+
if (globalBgSegmentIndices.length === 1) {
|
|
1483
|
+
const fifoLabel = `bg_fifo_0`;
|
|
1484
|
+
filterParts.push(`[${globalBgInputIdx}:v]${bgFilter},fifo[${fifoLabel}]`);
|
|
1485
|
+
globalBgFifoLabels.set(globalBgSegmentIndices[0], fifoLabel);
|
|
1486
|
+
} else if (globalBgSegmentIndices.length > 1) {
|
|
1487
|
+
const splitCount = globalBgSegmentIndices.length;
|
|
1488
|
+
const splitOutputLabels = globalBgSegmentIndices.map(
|
|
1489
|
+
(_, i) => `bg_split_${i}`
|
|
1490
|
+
);
|
|
1491
|
+
filterParts.push(
|
|
1492
|
+
`[${globalBgInputIdx}:v]${bgFilter},split=${splitCount}${splitOutputLabels.map((l) => `[${l}]`).join("")}`
|
|
1493
|
+
);
|
|
1494
|
+
for (let i = 0; i < splitCount; i++) {
|
|
1495
|
+
const fifoLabel = `bg_fifo_${i}`;
|
|
1496
|
+
filterParts.push(`[${splitOutputLabels[i]}]fifo[${fifoLabel}]`);
|
|
1497
|
+
globalBgFifoLabels.set(globalBgSegmentIndices[i], fifoLabel);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1315
1500
|
for (let segIdx = 0; segIdx < this.effieData.segments.length; segIdx++) {
|
|
1316
1501
|
const segment = this.effieData.segments[segIdx];
|
|
1317
1502
|
const bgLabel = `bg_seg${segIdx}`;
|
|
@@ -1322,9 +1507,12 @@ var EffieRenderer = class {
|
|
|
1322
1507
|
`[${segBgInputIdx}:v]fps=${this.effieData.fps},scale=${frameWidth}x${frameHeight},trim=start=${segBgSeek}:duration=${segment.duration},setpts=PTS-STARTPTS[${bgLabel}]`
|
|
1323
1508
|
);
|
|
1324
1509
|
} else {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1510
|
+
const fifoLabel = globalBgFifoLabels.get(segIdx);
|
|
1511
|
+
if (fifoLabel) {
|
|
1512
|
+
filterParts.push(
|
|
1513
|
+
`[${fifoLabel}]trim=start=${backgroundSeek + currentTime}:duration=${segment.duration},setpts=PTS-STARTPTS[${bgLabel}]`
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1328
1516
|
}
|
|
1329
1517
|
const vidLabel = `vid_seg${segIdx}`;
|
|
1330
1518
|
filterParts.push(
|
|
@@ -1408,6 +1596,20 @@ var EffieRenderer = class {
|
|
|
1408
1596
|
}
|
|
1409
1597
|
};
|
|
1410
1598
|
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Resolves a source reference to its actual URL.
|
|
1601
|
+
* If the source is a #reference, returns the resolved URL.
|
|
1602
|
+
* Otherwise, returns the source as-is.
|
|
1603
|
+
*/
|
|
1604
|
+
resolveReference(src) {
|
|
1605
|
+
if (src.startsWith("#")) {
|
|
1606
|
+
const sourceName = src.slice(1);
|
|
1607
|
+
if (sourceName in this.effieData.sources) {
|
|
1608
|
+
return this.effieData.sources[sourceName];
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return src;
|
|
1612
|
+
}
|
|
1411
1613
|
/**
|
|
1412
1614
|
* Renders the effie data to a video stream.
|
|
1413
1615
|
* @param scaleFactor - Scale factor for output dimensions
|
|
@@ -1415,9 +1617,12 @@ var EffieRenderer = class {
|
|
|
1415
1617
|
async render(scaleFactor = 1) {
|
|
1416
1618
|
const ffmpegCommand = this.buildFFmpegCommand("-", scaleFactor);
|
|
1417
1619
|
this.ffmpegRunner = new FFmpegRunner(ffmpegCommand);
|
|
1620
|
+
const urlTransformer = this.httpProxy ? (url) => this.httpProxy.transformUrl(url) : void 0;
|
|
1418
1621
|
return this.ffmpegRunner.run(
|
|
1419
1622
|
async ({ src }) => this.fetchSource(src),
|
|
1420
|
-
this.createImageTransformer(scaleFactor)
|
|
1623
|
+
this.createImageTransformer(scaleFactor),
|
|
1624
|
+
(src) => this.resolveReference(src),
|
|
1625
|
+
urlTransformer
|
|
1421
1626
|
);
|
|
1422
1627
|
}
|
|
1423
1628
|
close() {
|
|
@@ -1520,7 +1725,8 @@ async function streamRenderJob(req, res, ctx2) {
|
|
|
1520
1725
|
}
|
|
1521
1726
|
async function streamRenderDirect(res, job, ctx2) {
|
|
1522
1727
|
const renderer = new EffieRenderer(job.effie, {
|
|
1523
|
-
cacheStorage: ctx2.cacheStorage
|
|
1728
|
+
cacheStorage: ctx2.cacheStorage,
|
|
1729
|
+
httpProxy: ctx2.httpProxy
|
|
1524
1730
|
});
|
|
1525
1731
|
const videoStream = await renderer.render(job.scale);
|
|
1526
1732
|
res.on("close", () => {
|
|
@@ -1582,7 +1788,10 @@ async function renderAndUploadInternal(effie, scale, upload, sendEvent, ctx2) {
|
|
|
1582
1788
|
timings.uploadCoverTime = Date.now() - uploadCoverStartTime;
|
|
1583
1789
|
}
|
|
1584
1790
|
const renderStartTime = Date.now();
|
|
1585
|
-
const renderer = new EffieRenderer(effie, {
|
|
1791
|
+
const renderer = new EffieRenderer(effie, {
|
|
1792
|
+
cacheStorage: ctx2.cacheStorage,
|
|
1793
|
+
httpProxy: ctx2.httpProxy
|
|
1794
|
+
});
|
|
1586
1795
|
const videoStream = await renderer.render(scale);
|
|
1587
1796
|
const chunks = [];
|
|
1588
1797
|
for await (const chunk of videoStream) {
|
|
@@ -1612,7 +1821,8 @@ async function renderAndUploadInternal(effie, scale, upload, sendEvent, ctx2) {
|
|
|
1612
1821
|
// src/server.ts
|
|
1613
1822
|
var app = express4();
|
|
1614
1823
|
app.use(bodyParser.json({ limit: "50mb" }));
|
|
1615
|
-
var ctx = createServerContext();
|
|
1824
|
+
var ctx = await createServerContext();
|
|
1825
|
+
console.log(`FFS HTTP proxy listening on port ${ctx.httpProxy.port}`);
|
|
1616
1826
|
function validateAuth(req, res) {
|
|
1617
1827
|
const apiKey = process.env.FFS_API_KEY;
|
|
1618
1828
|
if (!apiKey) return true;
|
|
@@ -1643,6 +1853,7 @@ var server = app.listen(port, () => {
|
|
|
1643
1853
|
});
|
|
1644
1854
|
function shutdown() {
|
|
1645
1855
|
console.log("Shutting down FFS server...");
|
|
1856
|
+
ctx.httpProxy.close();
|
|
1646
1857
|
ctx.cacheStorage.close();
|
|
1647
1858
|
server.close(() => {
|
|
1648
1859
|
console.log("FFS server stopped");
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["import express from \"express\";\nimport bodyParser from \"body-parser\";\nimport {\n createServerContext,\n createWarmupJob,\n streamWarmupJob,\n purgeCache,\n createRenderJob,\n streamRenderJob,\n} from \"./handlers\";\n\nconst app: express.Express = express();\napp.use(bodyParser.json({ limit: \"50mb\" })); // Support large JSON requests\n\nconst ctx = createServerContext();\n\nfunction validateAuth(req: express.Request, res: express.Response): boolean {\n const apiKey = process.env.FFS_API_KEY;\n if (!apiKey) return true; // No auth required if api key not set\n\n const authHeader = req.headers.authorization;\n if (!authHeader || authHeader !== `Bearer ${apiKey}`) {\n res.status(401).json({ error: \"Unauthorized\" });\n return false;\n }\n return true;\n}\n\n// Routes with auth (POST endpoints)\napp.post(\"/warmup\", (req, res) => {\n if (!validateAuth(req, res)) return;\n createWarmupJob(req, res, ctx);\n});\napp.post(\"/purge\", (req, res) => {\n if (!validateAuth(req, res)) return;\n purgeCache(req, res, ctx);\n});\napp.post(\"/render\", (req, res) => {\n if (!validateAuth(req, res)) return;\n createRenderJob(req, res, ctx);\n});\n\n// Routes without auth (GET endpoints use job ID as capability token)\napp.get(\"/warmup/:id\", (req, res) => streamWarmupJob(req, res, ctx));\napp.get(\"/render/:id\", (req, res) => streamRenderJob(req, res, ctx));\n\n// Server lifecycle\nconst port = process.env.FFS_PORT || 2000; // ffmpeg was conceived in the year 2000\nconst server = app.listen(port, () => {\n console.log(`FFS server listening on port ${port}`);\n});\n\nfunction shutdown() {\n console.log(\"Shutting down FFS server...\");\n ctx.cacheStorage.close();\n server.close(() => {\n console.log(\"FFS server stopped\");\n process.exit(0);\n });\n}\n\nprocess.on(\"SIGTERM\", shutdown);\nprocess.on(\"SIGINT\", shutdown);\n\nexport { app };\n"],"mappings":";;;;;;;;;;;AAAA,OAAO,aAAa;AACpB,OAAO,gBAAgB;AAUvB,IAAM,MAAuB,QAAQ;AACrC,IAAI,IAAI,WAAW,KAAK,EAAE,OAAO,OAAO,CAAC,CAAC;AAE1C,IAAM,MAAM,oBAAoB;
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["import express from \"express\";\nimport bodyParser from \"body-parser\";\nimport {\n createServerContext,\n createWarmupJob,\n streamWarmupJob,\n purgeCache,\n createRenderJob,\n streamRenderJob,\n} from \"./handlers\";\n\nconst app: express.Express = express();\napp.use(bodyParser.json({ limit: \"50mb\" })); // Support large JSON requests\n\nconst ctx = await createServerContext();\nconsole.log(`FFS HTTP proxy listening on port ${ctx.httpProxy.port}`);\n\nfunction validateAuth(req: express.Request, res: express.Response): boolean {\n const apiKey = process.env.FFS_API_KEY;\n if (!apiKey) return true; // No auth required if api key not set\n\n const authHeader = req.headers.authorization;\n if (!authHeader || authHeader !== `Bearer ${apiKey}`) {\n res.status(401).json({ error: \"Unauthorized\" });\n return false;\n }\n return true;\n}\n\n// Routes with auth (POST endpoints)\napp.post(\"/warmup\", (req, res) => {\n if (!validateAuth(req, res)) return;\n createWarmupJob(req, res, ctx);\n});\napp.post(\"/purge\", (req, res) => {\n if (!validateAuth(req, res)) return;\n purgeCache(req, res, ctx);\n});\napp.post(\"/render\", (req, res) => {\n if (!validateAuth(req, res)) return;\n createRenderJob(req, res, ctx);\n});\n\n// Routes without auth (GET endpoints use job ID as capability token)\napp.get(\"/warmup/:id\", (req, res) => streamWarmupJob(req, res, ctx));\napp.get(\"/render/:id\", (req, res) => streamRenderJob(req, res, ctx));\n\n// Server lifecycle\nconst port = process.env.FFS_PORT || 2000; // ffmpeg was conceived in the year 2000\nconst server = app.listen(port, () => {\n console.log(`FFS server listening on port ${port}`);\n});\n\nfunction shutdown() {\n console.log(\"Shutting down FFS server...\");\n ctx.httpProxy.close();\n ctx.cacheStorage.close();\n server.close(() => {\n console.log(\"FFS server stopped\");\n process.exit(0);\n });\n}\n\nprocess.on(\"SIGTERM\", shutdown);\nprocess.on(\"SIGINT\", shutdown);\n\nexport { app };\n"],"mappings":";;;;;;;;;;;AAAA,OAAO,aAAa;AACpB,OAAO,gBAAgB;AAUvB,IAAM,MAAuB,QAAQ;AACrC,IAAI,IAAI,WAAW,KAAK,EAAE,OAAO,OAAO,CAAC,CAAC;AAE1C,IAAM,MAAM,MAAM,oBAAoB;AACtC,QAAQ,IAAI,oCAAoC,IAAI,UAAU,IAAI,EAAE;AAEpE,SAAS,aAAa,KAAsB,KAAgC;AAC1E,QAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,CAAC,OAAQ,QAAO;AAEpB,QAAM,aAAa,IAAI,QAAQ;AAC/B,MAAI,CAAC,cAAc,eAAe,UAAU,MAAM,IAAI;AACpD,QAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,eAAe,CAAC;AAC9C,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAGA,IAAI,KAAK,WAAW,CAAC,KAAK,QAAQ;AAChC,MAAI,CAAC,aAAa,KAAK,GAAG,EAAG;AAC7B,kBAAgB,KAAK,KAAK,GAAG;AAC/B,CAAC;AACD,IAAI,KAAK,UAAU,CAAC,KAAK,QAAQ;AAC/B,MAAI,CAAC,aAAa,KAAK,GAAG,EAAG;AAC7B,aAAW,KAAK,KAAK,GAAG;AAC1B,CAAC;AACD,IAAI,KAAK,WAAW,CAAC,KAAK,QAAQ;AAChC,MAAI,CAAC,aAAa,KAAK,GAAG,EAAG;AAC7B,kBAAgB,KAAK,KAAK,GAAG;AAC/B,CAAC;AAGD,IAAI,IAAI,eAAe,CAAC,KAAK,QAAQ,gBAAgB,KAAK,KAAK,GAAG,CAAC;AACnE,IAAI,IAAI,eAAe,CAAC,KAAK,QAAQ,gBAAgB,KAAK,KAAK,GAAG,CAAC;AAGnE,IAAM,OAAO,QAAQ,IAAI,YAAY;AACrC,IAAM,SAAS,IAAI,OAAO,MAAM,MAAM;AACpC,UAAQ,IAAI,gCAAgC,IAAI,EAAE;AACpD,CAAC;AAED,SAAS,WAAW;AAClB,UAAQ,IAAI,6BAA6B;AACzC,MAAI,UAAU,MAAM;AACpB,MAAI,aAAa,MAAM;AACvB,SAAO,MAAM,MAAM;AACjB,YAAQ,IAAI,oBAAoB;AAChC,YAAQ,KAAK,CAAC;AAAA,EAChB,CAAC;AACH;AAEA,QAAQ,GAAG,WAAW,QAAQ;AAC9B,QAAQ,GAAG,UAAU,QAAQ;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effing/ffs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "FFmpeg-based effie rendering service",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"tar-stream": "^3.1.7",
|
|
34
34
|
"undici": "^7.3.0",
|
|
35
35
|
"zod": "^3.25.76",
|
|
36
|
-
"@effing/effie": "0.
|
|
36
|
+
"@effing/effie": "0.2.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/body-parser": "^1.19.5",
|
package/dist/cache-BUVFfGZF.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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 };
|