@effing/ffs 0.1.2 → 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/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
- // 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
- });
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 = extractEffieSources(parseResult.effie);
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
- for (let i = 0; i < sources.length; i++) {
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: sources[i],
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 = sources.filter((_, i) => !existsMap.get(sourceCacheKeys[i]));
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 url = queue.shift();
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 = Readable.fromWeb(
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 Readable2 } from "stream";
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(sourceResolver, imageTransformer) {
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 sourceCache = /* @__PURE__ */ new Map();
822
- const fetchAndSaveSource = async (input, inputName) => {
823
- const stream = await sourceResolver({
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: input.source
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 = sourceCache.get(input.source);
1029
+ let fetchPromise = fetchCache.get(input.source);
870
1030
  if (!fetchPromise) {
871
- fetchPromise = fetchAndSaveSource(input, inputName);
872
- sourceCache.set(input.source, fetchPromise);
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(input, inputName);
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 Readable2.from(buffer);
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 Readable2.fromWeb(response.body);
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
- 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
- );
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, { cacheStorage: ctx2.cacheStorage });
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");
@@ -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;AAEhC,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,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":[]}
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.1.2",
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.1.2"
36
+ "@effing/effie": "0.2.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/body-parser": "^1.19.5",
@@ -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 };