@effing/ffs 0.1.2 → 0.3.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 +138 -16
- package/dist/{chunk-RNE6TKMF.js → chunk-J64HSZNQ.js} +276 -207
- package/dist/chunk-J64HSZNQ.js.map +1 -0
- package/dist/chunk-XSCNUWZJ.js +935 -0
- package/dist/chunk-XSCNUWZJ.js.map +1 -0
- package/dist/handlers/index.d.ts +40 -7
- package/dist/handlers/index.js +10 -2
- package/dist/index.d.ts +23 -15
- package/dist/index.js +3 -9
- package/dist/proxy-qTA69nOV.d.ts +72 -0
- package/dist/server.js +853 -283
- 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 +0 -439
- package/dist/chunk-LK5K4SQV.js.map +0 -1
- package/dist/chunk-RNE6TKMF.js.map +0 -1
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EffieRenderer,
|
|
3
|
+
createTransientStore,
|
|
4
|
+
ffsFetch,
|
|
5
|
+
storeKeys
|
|
6
|
+
} from "./chunk-J64HSZNQ.js";
|
|
7
|
+
|
|
8
|
+
// src/handlers/shared.ts
|
|
9
|
+
import "express";
|
|
10
|
+
|
|
11
|
+
// src/proxy.ts
|
|
12
|
+
import http from "http";
|
|
13
|
+
import { Readable } from "stream";
|
|
14
|
+
var HttpProxy = class {
|
|
15
|
+
server = null;
|
|
16
|
+
_port = null;
|
|
17
|
+
startPromise = null;
|
|
18
|
+
get port() {
|
|
19
|
+
return this._port;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Transform a URL to go through the proxy.
|
|
23
|
+
* @throws Error if proxy not started
|
|
24
|
+
*/
|
|
25
|
+
transformUrl(url) {
|
|
26
|
+
if (this._port === null) throw new Error("Proxy not started");
|
|
27
|
+
return `http://127.0.0.1:${this._port}/${url}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start the proxy server. Safe to call multiple times.
|
|
31
|
+
*/
|
|
32
|
+
async start() {
|
|
33
|
+
if (this._port !== null) return;
|
|
34
|
+
if (this.startPromise) {
|
|
35
|
+
await this.startPromise;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.startPromise = this.doStart();
|
|
39
|
+
await this.startPromise;
|
|
40
|
+
}
|
|
41
|
+
async doStart() {
|
|
42
|
+
this.server = http.createServer(async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const originalUrl = this.parseProxyPath(req.url || "");
|
|
45
|
+
if (!originalUrl) {
|
|
46
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
47
|
+
res.end("Bad Request: invalid proxy path");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const response = await ffsFetch(originalUrl, {
|
|
51
|
+
method: req.method,
|
|
52
|
+
headers: this.filterHeaders(req.headers),
|
|
53
|
+
bodyTimeout: 0
|
|
54
|
+
// No timeout for streaming
|
|
55
|
+
});
|
|
56
|
+
const headers = {};
|
|
57
|
+
response.headers.forEach((value, key) => {
|
|
58
|
+
headers[key] = value;
|
|
59
|
+
});
|
|
60
|
+
res.writeHead(response.status, headers);
|
|
61
|
+
if (response.body) {
|
|
62
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
63
|
+
nodeStream.pipe(res);
|
|
64
|
+
nodeStream.on("error", (err) => {
|
|
65
|
+
console.error("Proxy stream error:", err);
|
|
66
|
+
res.destroy();
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
res.end();
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error("Proxy request error:", err);
|
|
73
|
+
if (!res.headersSent) {
|
|
74
|
+
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
75
|
+
res.end("Bad Gateway");
|
|
76
|
+
} else {
|
|
77
|
+
res.destroy();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
await new Promise((resolve) => {
|
|
82
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
83
|
+
this._port = this.server.address().port;
|
|
84
|
+
resolve();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse the proxy path to extract the original URL.
|
|
90
|
+
* Path format: /{originalUrl}
|
|
91
|
+
*/
|
|
92
|
+
parseProxyPath(path) {
|
|
93
|
+
if (!path.startsWith("/http://") && !path.startsWith("/https://")) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return path.slice(1);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Filter headers to forward to the upstream server.
|
|
100
|
+
* Removes hop-by-hop headers that shouldn't be forwarded.
|
|
101
|
+
*/
|
|
102
|
+
filterHeaders(headers) {
|
|
103
|
+
const skip = /* @__PURE__ */ new Set([
|
|
104
|
+
"host",
|
|
105
|
+
"connection",
|
|
106
|
+
"keep-alive",
|
|
107
|
+
"transfer-encoding",
|
|
108
|
+
"te",
|
|
109
|
+
"trailer",
|
|
110
|
+
"upgrade",
|
|
111
|
+
"proxy-authorization",
|
|
112
|
+
"proxy-authenticate"
|
|
113
|
+
]);
|
|
114
|
+
const result = {};
|
|
115
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
116
|
+
if (!skip.has(key.toLowerCase()) && typeof value === "string") {
|
|
117
|
+
result[key] = value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Close the proxy server and reset state.
|
|
124
|
+
*/
|
|
125
|
+
close() {
|
|
126
|
+
this.server?.close();
|
|
127
|
+
this.server = null;
|
|
128
|
+
this._port = null;
|
|
129
|
+
this.startPromise = null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// src/handlers/shared.ts
|
|
134
|
+
import { effieDataSchema } from "@effing/effie";
|
|
135
|
+
async function createServerContext() {
|
|
136
|
+
const port = process.env.FFS_PORT || 2e3;
|
|
137
|
+
const httpProxy = new HttpProxy();
|
|
138
|
+
await httpProxy.start();
|
|
139
|
+
return {
|
|
140
|
+
transientStore: createTransientStore(),
|
|
141
|
+
httpProxy,
|
|
142
|
+
baseUrl: process.env.FFS_BASE_URL || `http://localhost:${port}`,
|
|
143
|
+
skipValidation: !!process.env.FFS_SKIP_VALIDATION && process.env.FFS_SKIP_VALIDATION !== "false",
|
|
144
|
+
warmupConcurrency: parseInt(process.env.FFS_WARMUP_CONCURRENCY || "4", 10),
|
|
145
|
+
warmupBackendBaseUrl: process.env.FFS_WARMUP_BACKEND_BASE_URL,
|
|
146
|
+
renderBackendBaseUrl: process.env.FFS_RENDER_BACKEND_BASE_URL
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function parseEffieData(body, skipValidation) {
|
|
150
|
+
const isWrapped = typeof body === "object" && body !== null && "effie" in body;
|
|
151
|
+
const rawEffieData = isWrapped ? body.effie : body;
|
|
152
|
+
if (!skipValidation) {
|
|
153
|
+
const result = effieDataSchema.safeParse(rawEffieData);
|
|
154
|
+
if (!result.success) {
|
|
155
|
+
return {
|
|
156
|
+
error: "Invalid effie data",
|
|
157
|
+
issues: result.error.issues.map((issue) => ({
|
|
158
|
+
path: issue.path.join("."),
|
|
159
|
+
message: issue.message
|
|
160
|
+
}))
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { effie: result.data };
|
|
164
|
+
} else {
|
|
165
|
+
const effie = rawEffieData;
|
|
166
|
+
if (!effie?.segments) {
|
|
167
|
+
return { error: "Invalid effie data: missing segments" };
|
|
168
|
+
}
|
|
169
|
+
return { effie };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function setupCORSHeaders(res) {
|
|
173
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
174
|
+
res.setHeader("Access-Control-Allow-Methods", "GET");
|
|
175
|
+
}
|
|
176
|
+
function setupSSEResponse(res) {
|
|
177
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
178
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
179
|
+
res.setHeader("Connection", "keep-alive");
|
|
180
|
+
res.flushHeaders();
|
|
181
|
+
}
|
|
182
|
+
function createSSEEventSender(res) {
|
|
183
|
+
return (event, data) => {
|
|
184
|
+
res.write(`event: ${event}
|
|
185
|
+
data: ${JSON.stringify(data)}
|
|
186
|
+
|
|
187
|
+
`);
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/handlers/caching.ts
|
|
192
|
+
import "express";
|
|
193
|
+
import { Readable as Readable2, Transform } from "stream";
|
|
194
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
195
|
+
import {
|
|
196
|
+
extractEffieSources,
|
|
197
|
+
extractEffieSourcesWithTypes as extractEffieSourcesWithTypes2
|
|
198
|
+
} from "@effing/effie";
|
|
199
|
+
|
|
200
|
+
// src/handlers/orchestrating.ts
|
|
201
|
+
import "express";
|
|
202
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
203
|
+
import { extractEffieSourcesWithTypes, effieDataSchema as effieDataSchema3 } from "@effing/effie";
|
|
204
|
+
|
|
205
|
+
// src/handlers/rendering.ts
|
|
206
|
+
import "express";
|
|
207
|
+
import { randomUUID } from "crypto";
|
|
208
|
+
import { effieDataSchema as effieDataSchema2 } from "@effing/effie";
|
|
209
|
+
async function createRenderJob(req, res, ctx) {
|
|
210
|
+
try {
|
|
211
|
+
const isWrapped = "effie" in req.body;
|
|
212
|
+
let rawEffieData;
|
|
213
|
+
let scale;
|
|
214
|
+
let upload;
|
|
215
|
+
if (isWrapped) {
|
|
216
|
+
const options = req.body;
|
|
217
|
+
if (typeof options.effie === "string") {
|
|
218
|
+
const response = await ffsFetch(options.effie);
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Failed to fetch Effie data: ${response.status} ${response.statusText}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
rawEffieData = await response.json();
|
|
225
|
+
} else {
|
|
226
|
+
rawEffieData = options.effie;
|
|
227
|
+
}
|
|
228
|
+
scale = options.scale ?? 1;
|
|
229
|
+
upload = options.upload;
|
|
230
|
+
} else {
|
|
231
|
+
rawEffieData = req.body;
|
|
232
|
+
scale = parseFloat(req.query.scale?.toString() || "1");
|
|
233
|
+
}
|
|
234
|
+
let effie;
|
|
235
|
+
if (!ctx.skipValidation) {
|
|
236
|
+
const result = effieDataSchema2.safeParse(rawEffieData);
|
|
237
|
+
if (!result.success) {
|
|
238
|
+
res.status(400).json({
|
|
239
|
+
error: "Invalid effie data",
|
|
240
|
+
issues: result.error.issues.map((issue) => ({
|
|
241
|
+
path: issue.path.join("."),
|
|
242
|
+
message: issue.message
|
|
243
|
+
}))
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
effie = result.data;
|
|
248
|
+
} else {
|
|
249
|
+
const data = rawEffieData;
|
|
250
|
+
if (!data?.segments) {
|
|
251
|
+
res.status(400).json({ error: "Invalid effie data: missing segments" });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
effie = data;
|
|
255
|
+
}
|
|
256
|
+
const jobId = randomUUID();
|
|
257
|
+
const job = {
|
|
258
|
+
effie,
|
|
259
|
+
scale,
|
|
260
|
+
upload,
|
|
261
|
+
createdAt: Date.now()
|
|
262
|
+
};
|
|
263
|
+
await ctx.transientStore.putJson(
|
|
264
|
+
storeKeys.renderJob(jobId),
|
|
265
|
+
job,
|
|
266
|
+
ctx.transientStore.jobMetadataTtlMs
|
|
267
|
+
);
|
|
268
|
+
res.json({
|
|
269
|
+
id: jobId,
|
|
270
|
+
url: `${ctx.baseUrl}/render/${jobId}`
|
|
271
|
+
});
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error("Error creating render job:", error);
|
|
274
|
+
res.status(500).json({ error: "Failed to create render job" });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function streamRenderJob(req, res, ctx) {
|
|
278
|
+
try {
|
|
279
|
+
setupCORSHeaders(res);
|
|
280
|
+
const jobId = req.params.id;
|
|
281
|
+
if (ctx.renderBackendBaseUrl) {
|
|
282
|
+
await proxyRenderFromBackend(res, jobId, ctx);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const jobCacheKey = storeKeys.renderJob(jobId);
|
|
286
|
+
const job = await ctx.transientStore.getJson(jobCacheKey);
|
|
287
|
+
ctx.transientStore.delete(jobCacheKey);
|
|
288
|
+
if (!job) {
|
|
289
|
+
res.status(404).json({ error: "Job not found or expired" });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (job.upload) {
|
|
293
|
+
await streamRenderWithUpload(res, job, ctx);
|
|
294
|
+
} else {
|
|
295
|
+
await streamRenderDirect(res, job, ctx);
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error("Error in render:", error);
|
|
299
|
+
if (!res.headersSent) {
|
|
300
|
+
res.status(500).json({ error: "Rendering failed" });
|
|
301
|
+
} else {
|
|
302
|
+
res.end();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function streamRenderDirect(res, job, ctx) {
|
|
307
|
+
const renderer = new EffieRenderer(job.effie, {
|
|
308
|
+
transientStore: ctx.transientStore,
|
|
309
|
+
httpProxy: ctx.httpProxy
|
|
310
|
+
});
|
|
311
|
+
const videoStream = await renderer.render(job.scale);
|
|
312
|
+
res.on("close", () => {
|
|
313
|
+
videoStream.destroy();
|
|
314
|
+
renderer.close();
|
|
315
|
+
});
|
|
316
|
+
res.set("Content-Type", "video/mp4");
|
|
317
|
+
videoStream.pipe(res);
|
|
318
|
+
}
|
|
319
|
+
async function streamRenderWithUpload(res, job, ctx) {
|
|
320
|
+
setupSSEResponse(res);
|
|
321
|
+
const sendEvent = createSSEEventSender(res);
|
|
322
|
+
const keepalive = setInterval(() => {
|
|
323
|
+
sendEvent("keepalive", { status: "rendering" });
|
|
324
|
+
}, 25e3);
|
|
325
|
+
try {
|
|
326
|
+
sendEvent("started", { status: "rendering" });
|
|
327
|
+
const timings = await renderAndUploadInternal(
|
|
328
|
+
job.effie,
|
|
329
|
+
job.scale,
|
|
330
|
+
job.upload,
|
|
331
|
+
sendEvent,
|
|
332
|
+
ctx
|
|
333
|
+
);
|
|
334
|
+
sendEvent("complete", { status: "uploaded", timings });
|
|
335
|
+
} catch (error) {
|
|
336
|
+
sendEvent("error", { message: String(error) });
|
|
337
|
+
} finally {
|
|
338
|
+
clearInterval(keepalive);
|
|
339
|
+
res.end();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function renderAndUploadInternal(effie, scale, upload, sendEvent, ctx) {
|
|
343
|
+
const timings = {};
|
|
344
|
+
if (upload.coverUrl) {
|
|
345
|
+
const fetchCoverStartTime = Date.now();
|
|
346
|
+
const coverFetchResponse = await ffsFetch(effie.cover);
|
|
347
|
+
if (!coverFetchResponse.ok) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Failed to fetch cover image: ${coverFetchResponse.status} ${coverFetchResponse.statusText}`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const coverBuffer = Buffer.from(await coverFetchResponse.arrayBuffer());
|
|
353
|
+
timings.fetchCoverTime = Date.now() - fetchCoverStartTime;
|
|
354
|
+
const uploadCoverStartTime = Date.now();
|
|
355
|
+
const uploadCoverResponse = await ffsFetch(upload.coverUrl, {
|
|
356
|
+
method: "PUT",
|
|
357
|
+
body: coverBuffer,
|
|
358
|
+
headers: {
|
|
359
|
+
"Content-Type": "image/png",
|
|
360
|
+
"Content-Length": coverBuffer.length.toString()
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
if (!uploadCoverResponse.ok) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Failed to upload cover: ${uploadCoverResponse.status} ${uploadCoverResponse.statusText}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
timings.uploadCoverTime = Date.now() - uploadCoverStartTime;
|
|
369
|
+
}
|
|
370
|
+
const renderStartTime = Date.now();
|
|
371
|
+
const renderer = new EffieRenderer(effie, {
|
|
372
|
+
transientStore: ctx.transientStore,
|
|
373
|
+
httpProxy: ctx.httpProxy
|
|
374
|
+
});
|
|
375
|
+
const videoStream = await renderer.render(scale);
|
|
376
|
+
const chunks = [];
|
|
377
|
+
for await (const chunk of videoStream) {
|
|
378
|
+
chunks.push(Buffer.from(chunk));
|
|
379
|
+
}
|
|
380
|
+
const videoBuffer = Buffer.concat(chunks);
|
|
381
|
+
timings.renderTime = Date.now() - renderStartTime;
|
|
382
|
+
sendEvent("keepalive", { status: "uploading" });
|
|
383
|
+
const uploadStartTime = Date.now();
|
|
384
|
+
const uploadResponse = await ffsFetch(upload.videoUrl, {
|
|
385
|
+
method: "PUT",
|
|
386
|
+
body: videoBuffer,
|
|
387
|
+
headers: {
|
|
388
|
+
"Content-Type": "video/mp4",
|
|
389
|
+
"Content-Length": videoBuffer.length.toString()
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
if (!uploadResponse.ok) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`Failed to upload rendered video: ${uploadResponse.status} ${uploadResponse.statusText}`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
timings.uploadTime = Date.now() - uploadStartTime;
|
|
398
|
+
return timings;
|
|
399
|
+
}
|
|
400
|
+
async function proxyRenderFromBackend(res, jobId, ctx) {
|
|
401
|
+
const backendUrl = `${ctx.renderBackendBaseUrl}/render/${jobId}`;
|
|
402
|
+
const response = await ffsFetch(backendUrl);
|
|
403
|
+
if (!response.ok) {
|
|
404
|
+
res.status(response.status).json({ error: "Backend render failed" });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const contentType = response.headers.get("content-type") || "";
|
|
408
|
+
if (contentType.includes("text/event-stream")) {
|
|
409
|
+
setupSSEResponse(res);
|
|
410
|
+
const sendEvent = createSSEEventSender(res);
|
|
411
|
+
const reader = response.body?.getReader();
|
|
412
|
+
if (!reader) {
|
|
413
|
+
sendEvent("error", { message: "No response body from backend" });
|
|
414
|
+
res.end();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const decoder = new TextDecoder();
|
|
418
|
+
let buffer = "";
|
|
419
|
+
try {
|
|
420
|
+
while (true) {
|
|
421
|
+
const { done, value } = await reader.read();
|
|
422
|
+
if (done) break;
|
|
423
|
+
if (res.destroyed) {
|
|
424
|
+
reader.cancel();
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
buffer += decoder.decode(value, { stream: true });
|
|
428
|
+
const lines = buffer.split("\n");
|
|
429
|
+
buffer = lines.pop() || "";
|
|
430
|
+
let currentEvent = "";
|
|
431
|
+
let currentData = "";
|
|
432
|
+
for (const line of lines) {
|
|
433
|
+
if (line.startsWith("event: ")) {
|
|
434
|
+
currentEvent = line.slice(7);
|
|
435
|
+
} else if (line.startsWith("data: ")) {
|
|
436
|
+
currentData = line.slice(6);
|
|
437
|
+
} else if (line === "" && currentEvent && currentData) {
|
|
438
|
+
try {
|
|
439
|
+
const data = JSON.parse(currentData);
|
|
440
|
+
sendEvent(currentEvent, data);
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
currentEvent = "";
|
|
444
|
+
currentData = "";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
reader.releaseLock();
|
|
450
|
+
res.end();
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
await proxyBinaryStream(response, res);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/handlers/orchestrating.ts
|
|
458
|
+
async function createWarmupAndRenderJob(req, res, ctx) {
|
|
459
|
+
try {
|
|
460
|
+
const options = req.body;
|
|
461
|
+
let rawEffieData;
|
|
462
|
+
if (typeof options.effie === "string") {
|
|
463
|
+
const response = await ffsFetch(options.effie);
|
|
464
|
+
if (!response.ok) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`Failed to fetch Effie data: ${response.status} ${response.statusText}`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
rawEffieData = await response.json();
|
|
470
|
+
} else {
|
|
471
|
+
rawEffieData = options.effie;
|
|
472
|
+
}
|
|
473
|
+
let effie;
|
|
474
|
+
if (!ctx.skipValidation) {
|
|
475
|
+
const result = effieDataSchema3.safeParse(rawEffieData);
|
|
476
|
+
if (!result.success) {
|
|
477
|
+
res.status(400).json({
|
|
478
|
+
error: "Invalid effie data",
|
|
479
|
+
issues: result.error.issues.map((issue) => ({
|
|
480
|
+
path: issue.path.join("."),
|
|
481
|
+
message: issue.message
|
|
482
|
+
}))
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
effie = result.data;
|
|
487
|
+
} else {
|
|
488
|
+
const data = rawEffieData;
|
|
489
|
+
if (!data?.segments) {
|
|
490
|
+
res.status(400).json({ error: "Invalid effie data: missing segments" });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
effie = data;
|
|
494
|
+
}
|
|
495
|
+
const sources = extractEffieSourcesWithTypes(effie);
|
|
496
|
+
const scale = options.scale ?? 1;
|
|
497
|
+
const upload = options.upload;
|
|
498
|
+
const jobId = randomUUID2();
|
|
499
|
+
const warmupJobId = randomUUID2();
|
|
500
|
+
const renderJobId = randomUUID2();
|
|
501
|
+
const job = {
|
|
502
|
+
effie,
|
|
503
|
+
sources,
|
|
504
|
+
scale,
|
|
505
|
+
upload,
|
|
506
|
+
warmupJobId,
|
|
507
|
+
renderJobId,
|
|
508
|
+
createdAt: Date.now()
|
|
509
|
+
};
|
|
510
|
+
await ctx.transientStore.putJson(
|
|
511
|
+
storeKeys.warmupAndRenderJob(jobId),
|
|
512
|
+
job,
|
|
513
|
+
ctx.transientStore.jobMetadataTtlMs
|
|
514
|
+
);
|
|
515
|
+
await ctx.transientStore.putJson(
|
|
516
|
+
storeKeys.warmupJob(warmupJobId),
|
|
517
|
+
{ sources },
|
|
518
|
+
ctx.transientStore.jobMetadataTtlMs
|
|
519
|
+
);
|
|
520
|
+
await ctx.transientStore.putJson(
|
|
521
|
+
storeKeys.renderJob(renderJobId),
|
|
522
|
+
{
|
|
523
|
+
effie,
|
|
524
|
+
scale,
|
|
525
|
+
upload,
|
|
526
|
+
createdAt: Date.now()
|
|
527
|
+
},
|
|
528
|
+
ctx.transientStore.jobMetadataTtlMs
|
|
529
|
+
);
|
|
530
|
+
res.json({
|
|
531
|
+
id: jobId,
|
|
532
|
+
url: `${ctx.baseUrl}/warmup-and-render/${jobId}`
|
|
533
|
+
});
|
|
534
|
+
} catch (error) {
|
|
535
|
+
console.error("Error creating warmup-and-render job:", error);
|
|
536
|
+
res.status(500).json({ error: "Failed to create warmup-and-render job" });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async function streamWarmupAndRenderJob(req, res, ctx) {
|
|
540
|
+
try {
|
|
541
|
+
setupCORSHeaders(res);
|
|
542
|
+
const jobId = req.params.id;
|
|
543
|
+
const jobCacheKey = storeKeys.warmupAndRenderJob(jobId);
|
|
544
|
+
const job = await ctx.transientStore.getJson(jobCacheKey);
|
|
545
|
+
ctx.transientStore.delete(jobCacheKey);
|
|
546
|
+
if (!job) {
|
|
547
|
+
res.status(404).json({ error: "Job not found" });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
setupSSEResponse(res);
|
|
551
|
+
const sendEvent = createSSEEventSender(res);
|
|
552
|
+
let keepalivePhase = "warmup";
|
|
553
|
+
const keepalive = setInterval(() => {
|
|
554
|
+
sendEvent("keepalive", { phase: keepalivePhase });
|
|
555
|
+
}, 25e3);
|
|
556
|
+
try {
|
|
557
|
+
if (ctx.warmupBackendBaseUrl) {
|
|
558
|
+
await proxyRemoteSSE(
|
|
559
|
+
`${ctx.warmupBackendBaseUrl}/warmup/${job.warmupJobId}`,
|
|
560
|
+
sendEvent,
|
|
561
|
+
"warmup:",
|
|
562
|
+
res
|
|
563
|
+
);
|
|
564
|
+
} else {
|
|
565
|
+
const warmupSender = prefixEventSender(sendEvent, "warmup:");
|
|
566
|
+
await warmupSources(job.sources, warmupSender, ctx);
|
|
567
|
+
warmupSender("complete", { status: "ready" });
|
|
568
|
+
}
|
|
569
|
+
keepalivePhase = "render";
|
|
570
|
+
if (ctx.renderBackendBaseUrl) {
|
|
571
|
+
await proxyRemoteSSE(
|
|
572
|
+
`${ctx.renderBackendBaseUrl}/render/${job.renderJobId}`,
|
|
573
|
+
sendEvent,
|
|
574
|
+
"render:",
|
|
575
|
+
res
|
|
576
|
+
);
|
|
577
|
+
} else {
|
|
578
|
+
const renderSender = prefixEventSender(sendEvent, "render:");
|
|
579
|
+
if (job.upload) {
|
|
580
|
+
renderSender("started", { status: "rendering" });
|
|
581
|
+
const timings = await renderAndUploadInternal(
|
|
582
|
+
job.effie,
|
|
583
|
+
job.scale,
|
|
584
|
+
job.upload,
|
|
585
|
+
renderSender,
|
|
586
|
+
ctx
|
|
587
|
+
);
|
|
588
|
+
renderSender("complete", { status: "uploaded", timings });
|
|
589
|
+
} else {
|
|
590
|
+
const videoUrl = `${ctx.baseUrl}/render/${job.renderJobId}`;
|
|
591
|
+
sendEvent("complete", { status: "ready", videoUrl });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (job.upload && !ctx.renderBackendBaseUrl) {
|
|
595
|
+
sendEvent("complete", { status: "done" });
|
|
596
|
+
}
|
|
597
|
+
} catch (error) {
|
|
598
|
+
sendEvent("error", {
|
|
599
|
+
phase: keepalivePhase,
|
|
600
|
+
message: String(error)
|
|
601
|
+
});
|
|
602
|
+
} finally {
|
|
603
|
+
clearInterval(keepalive);
|
|
604
|
+
res.end();
|
|
605
|
+
}
|
|
606
|
+
} catch (error) {
|
|
607
|
+
console.error("Error in warmup-and-render streaming:", error);
|
|
608
|
+
if (!res.headersSent) {
|
|
609
|
+
res.status(500).json({ error: "Warmup-and-render streaming failed" });
|
|
610
|
+
} else {
|
|
611
|
+
res.end();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function prefixEventSender(sendEvent, prefix) {
|
|
616
|
+
return (event, data) => {
|
|
617
|
+
sendEvent(`${prefix}${event}`, data);
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
async function proxyRemoteSSE(url, sendEvent, prefix, res) {
|
|
621
|
+
const response = await ffsFetch(url, {
|
|
622
|
+
headers: {
|
|
623
|
+
Accept: "text/event-stream"
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
if (!response.ok) {
|
|
627
|
+
throw new Error(`Remote backend error: ${response.status}`);
|
|
628
|
+
}
|
|
629
|
+
const reader = response.body?.getReader();
|
|
630
|
+
if (!reader) {
|
|
631
|
+
throw new Error("No response body from remote backend");
|
|
632
|
+
}
|
|
633
|
+
const decoder = new TextDecoder();
|
|
634
|
+
let buffer = "";
|
|
635
|
+
try {
|
|
636
|
+
while (true) {
|
|
637
|
+
const { done, value } = await reader.read();
|
|
638
|
+
if (done) break;
|
|
639
|
+
if (res.destroyed) {
|
|
640
|
+
reader.cancel();
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
buffer += decoder.decode(value, { stream: true });
|
|
644
|
+
const lines = buffer.split("\n");
|
|
645
|
+
buffer = lines.pop() || "";
|
|
646
|
+
let currentEvent = "";
|
|
647
|
+
let currentData = "";
|
|
648
|
+
for (const line of lines) {
|
|
649
|
+
if (line.startsWith("event: ")) {
|
|
650
|
+
currentEvent = line.slice(7);
|
|
651
|
+
} else if (line.startsWith("data: ")) {
|
|
652
|
+
currentData = line.slice(6);
|
|
653
|
+
} else if (line === "" && currentEvent && currentData) {
|
|
654
|
+
try {
|
|
655
|
+
const data = JSON.parse(currentData);
|
|
656
|
+
sendEvent(`${prefix}${currentEvent}`, data);
|
|
657
|
+
} catch {
|
|
658
|
+
}
|
|
659
|
+
currentEvent = "";
|
|
660
|
+
currentData = "";
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} finally {
|
|
665
|
+
reader.releaseLock();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async function proxyBinaryStream(response, res) {
|
|
669
|
+
const contentType = response.headers.get("content-type");
|
|
670
|
+
if (contentType) res.set("Content-Type", contentType);
|
|
671
|
+
const contentLength = response.headers.get("content-length");
|
|
672
|
+
if (contentLength) res.set("Content-Length", contentLength);
|
|
673
|
+
const reader = response.body?.getReader();
|
|
674
|
+
if (!reader) {
|
|
675
|
+
throw new Error("No response body");
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
while (true) {
|
|
679
|
+
const { done, value } = await reader.read();
|
|
680
|
+
if (done) break;
|
|
681
|
+
if (res.destroyed) {
|
|
682
|
+
reader.cancel();
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
res.write(value);
|
|
686
|
+
}
|
|
687
|
+
} finally {
|
|
688
|
+
reader.releaseLock();
|
|
689
|
+
res.end();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/handlers/caching.ts
|
|
694
|
+
function shouldSkipWarmup(source) {
|
|
695
|
+
return source.type === "video" || source.type === "audio";
|
|
696
|
+
}
|
|
697
|
+
var inFlightFetches = /* @__PURE__ */ new Map();
|
|
698
|
+
async function createWarmupJob(req, res, ctx) {
|
|
699
|
+
try {
|
|
700
|
+
const parseResult = parseEffieData(req.body, ctx.skipValidation);
|
|
701
|
+
if ("error" in parseResult) {
|
|
702
|
+
res.status(400).json(parseResult);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const sources = extractEffieSourcesWithTypes2(parseResult.effie);
|
|
706
|
+
const jobId = randomUUID3();
|
|
707
|
+
await ctx.transientStore.putJson(
|
|
708
|
+
storeKeys.warmupJob(jobId),
|
|
709
|
+
{ sources },
|
|
710
|
+
ctx.transientStore.jobMetadataTtlMs
|
|
711
|
+
);
|
|
712
|
+
res.json({
|
|
713
|
+
id: jobId,
|
|
714
|
+
url: `${ctx.baseUrl}/warmup/${jobId}`
|
|
715
|
+
});
|
|
716
|
+
} catch (error) {
|
|
717
|
+
console.error("Error creating warmup job:", error);
|
|
718
|
+
res.status(500).json({ error: "Failed to create warmup job" });
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
async function streamWarmupJob(req, res, ctx) {
|
|
722
|
+
try {
|
|
723
|
+
setupCORSHeaders(res);
|
|
724
|
+
const jobId = req.params.id;
|
|
725
|
+
if (ctx.warmupBackendBaseUrl) {
|
|
726
|
+
setupSSEResponse(res);
|
|
727
|
+
const sendEvent2 = createSSEEventSender(res);
|
|
728
|
+
try {
|
|
729
|
+
await proxyRemoteSSE(
|
|
730
|
+
`${ctx.warmupBackendBaseUrl}/warmup/${jobId}`,
|
|
731
|
+
sendEvent2,
|
|
732
|
+
"",
|
|
733
|
+
res
|
|
734
|
+
);
|
|
735
|
+
} finally {
|
|
736
|
+
res.end();
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const jobCacheKey = storeKeys.warmupJob(jobId);
|
|
741
|
+
const job = await ctx.transientStore.getJson(jobCacheKey);
|
|
742
|
+
ctx.transientStore.delete(jobCacheKey);
|
|
743
|
+
if (!job) {
|
|
744
|
+
res.status(404).json({ error: "Job not found" });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
setupSSEResponse(res);
|
|
748
|
+
const sendEvent = createSSEEventSender(res);
|
|
749
|
+
try {
|
|
750
|
+
await warmupSources(job.sources, sendEvent, ctx);
|
|
751
|
+
sendEvent("complete", { status: "ready" });
|
|
752
|
+
} catch (error) {
|
|
753
|
+
sendEvent("error", { message: String(error) });
|
|
754
|
+
} finally {
|
|
755
|
+
res.end();
|
|
756
|
+
}
|
|
757
|
+
} catch (error) {
|
|
758
|
+
console.error("Error in warmup streaming:", error);
|
|
759
|
+
if (!res.headersSent) {
|
|
760
|
+
res.status(500).json({ error: "Warmup streaming failed" });
|
|
761
|
+
} else {
|
|
762
|
+
res.end();
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function purgeCache(req, res, ctx) {
|
|
767
|
+
try {
|
|
768
|
+
const parseResult = parseEffieData(req.body, ctx.skipValidation);
|
|
769
|
+
if ("error" in parseResult) {
|
|
770
|
+
res.status(400).json(parseResult);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const sources = extractEffieSources(parseResult.effie);
|
|
774
|
+
let purged = 0;
|
|
775
|
+
for (const url of sources) {
|
|
776
|
+
const ck = storeKeys.source(url);
|
|
777
|
+
if (await ctx.transientStore.exists(ck)) {
|
|
778
|
+
await ctx.transientStore.delete(ck);
|
|
779
|
+
purged++;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
res.json({ purged, total: sources.length });
|
|
783
|
+
} catch (error) {
|
|
784
|
+
console.error("Error purging cache:", error);
|
|
785
|
+
res.status(500).json({ error: "Failed to purge cache" });
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
async function warmupSources(sources, sendEvent, ctx) {
|
|
789
|
+
const total = sources.length;
|
|
790
|
+
sendEvent("start", { total });
|
|
791
|
+
let cached = 0;
|
|
792
|
+
let failed = 0;
|
|
793
|
+
let skipped = 0;
|
|
794
|
+
const sourcesToCache = [];
|
|
795
|
+
for (const source of sources) {
|
|
796
|
+
if (shouldSkipWarmup(source)) {
|
|
797
|
+
skipped++;
|
|
798
|
+
sendEvent("progress", {
|
|
799
|
+
url: source.url,
|
|
800
|
+
status: "skipped",
|
|
801
|
+
reason: "http-video-audio-passthrough",
|
|
802
|
+
cached,
|
|
803
|
+
failed,
|
|
804
|
+
skipped,
|
|
805
|
+
total
|
|
806
|
+
});
|
|
807
|
+
} else {
|
|
808
|
+
sourcesToCache.push(source);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const sourceCacheKeys = sourcesToCache.map((s) => storeKeys.source(s.url));
|
|
812
|
+
const existsMap = await ctx.transientStore.existsMany(sourceCacheKeys);
|
|
813
|
+
for (let i = 0; i < sourcesToCache.length; i++) {
|
|
814
|
+
if (existsMap.get(sourceCacheKeys[i])) {
|
|
815
|
+
cached++;
|
|
816
|
+
sendEvent("progress", {
|
|
817
|
+
url: sourcesToCache[i].url,
|
|
818
|
+
status: "hit",
|
|
819
|
+
cached,
|
|
820
|
+
failed,
|
|
821
|
+
skipped,
|
|
822
|
+
total
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const uncached = sourcesToCache.filter(
|
|
827
|
+
(_, i) => !existsMap.get(sourceCacheKeys[i])
|
|
828
|
+
);
|
|
829
|
+
if (uncached.length === 0) {
|
|
830
|
+
sendEvent("summary", { cached, failed, skipped, total });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const keepalive = setInterval(() => {
|
|
834
|
+
sendEvent("keepalive", { cached, failed, skipped, total });
|
|
835
|
+
}, 25e3);
|
|
836
|
+
const queue = [...uncached];
|
|
837
|
+
const workers = Array.from(
|
|
838
|
+
{ length: Math.min(ctx.warmupConcurrency, queue.length) },
|
|
839
|
+
async () => {
|
|
840
|
+
while (queue.length > 0) {
|
|
841
|
+
const source = queue.shift();
|
|
842
|
+
const cacheKey = storeKeys.source(source.url);
|
|
843
|
+
const startTime = Date.now();
|
|
844
|
+
try {
|
|
845
|
+
let fetchPromise = inFlightFetches.get(cacheKey);
|
|
846
|
+
if (!fetchPromise) {
|
|
847
|
+
fetchPromise = fetchAndCache(source.url, cacheKey, sendEvent, ctx);
|
|
848
|
+
inFlightFetches.set(cacheKey, fetchPromise);
|
|
849
|
+
}
|
|
850
|
+
await fetchPromise;
|
|
851
|
+
inFlightFetches.delete(cacheKey);
|
|
852
|
+
cached++;
|
|
853
|
+
sendEvent("progress", {
|
|
854
|
+
url: source.url,
|
|
855
|
+
status: "cached",
|
|
856
|
+
cached,
|
|
857
|
+
failed,
|
|
858
|
+
skipped,
|
|
859
|
+
total,
|
|
860
|
+
ms: Date.now() - startTime
|
|
861
|
+
});
|
|
862
|
+
} catch (error) {
|
|
863
|
+
inFlightFetches.delete(cacheKey);
|
|
864
|
+
failed++;
|
|
865
|
+
sendEvent("progress", {
|
|
866
|
+
url: source.url,
|
|
867
|
+
status: "error",
|
|
868
|
+
error: String(error),
|
|
869
|
+
cached,
|
|
870
|
+
failed,
|
|
871
|
+
skipped,
|
|
872
|
+
total,
|
|
873
|
+
ms: Date.now() - startTime
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
);
|
|
879
|
+
await Promise.all(workers);
|
|
880
|
+
clearInterval(keepalive);
|
|
881
|
+
sendEvent("summary", { cached, failed, skipped, total });
|
|
882
|
+
}
|
|
883
|
+
async function fetchAndCache(url, cacheKey, sendEvent, ctx) {
|
|
884
|
+
const response = await ffsFetch(url, {
|
|
885
|
+
headersTimeout: 10 * 60 * 1e3,
|
|
886
|
+
// 10 minutes
|
|
887
|
+
bodyTimeout: 20 * 60 * 1e3
|
|
888
|
+
// 20 minutes
|
|
889
|
+
});
|
|
890
|
+
if (!response.ok) {
|
|
891
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
892
|
+
}
|
|
893
|
+
sendEvent("downloading", { url, status: "started", bytesReceived: 0 });
|
|
894
|
+
const sourceStream = Readable2.fromWeb(
|
|
895
|
+
response.body
|
|
896
|
+
);
|
|
897
|
+
let totalBytes = 0;
|
|
898
|
+
let lastEventTime = Date.now();
|
|
899
|
+
const PROGRESS_INTERVAL = 1e4;
|
|
900
|
+
const progressStream = new Transform({
|
|
901
|
+
transform(chunk, _encoding, callback) {
|
|
902
|
+
totalBytes += chunk.length;
|
|
903
|
+
const now = Date.now();
|
|
904
|
+
if (now - lastEventTime >= PROGRESS_INTERVAL) {
|
|
905
|
+
sendEvent("downloading", {
|
|
906
|
+
url,
|
|
907
|
+
status: "downloading",
|
|
908
|
+
bytesReceived: totalBytes
|
|
909
|
+
});
|
|
910
|
+
lastEventTime = now;
|
|
911
|
+
}
|
|
912
|
+
callback(null, chunk);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
const trackedStream = sourceStream.pipe(progressStream);
|
|
916
|
+
await ctx.transientStore.put(
|
|
917
|
+
cacheKey,
|
|
918
|
+
trackedStream,
|
|
919
|
+
ctx.transientStore.sourceTtlMs
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
export {
|
|
924
|
+
createServerContext,
|
|
925
|
+
createRenderJob,
|
|
926
|
+
streamRenderJob,
|
|
927
|
+
createWarmupAndRenderJob,
|
|
928
|
+
streamWarmupAndRenderJob,
|
|
929
|
+
proxyRemoteSSE,
|
|
930
|
+
proxyBinaryStream,
|
|
931
|
+
createWarmupJob,
|
|
932
|
+
streamWarmupJob,
|
|
933
|
+
purgeCache
|
|
934
|
+
};
|
|
935
|
+
//# sourceMappingURL=chunk-XSCNUWZJ.js.map
|