@twick/render-server 0.15.14 → 0.15.15

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/index.js CHANGED
@@ -26,10 +26,42 @@ module.exports = __toCommonJS(index_exports);
26
26
 
27
27
  // src/renderer.ts
28
28
  var import_renderer = require("@twick/renderer");
29
+ var activeRenders = 0;
30
+ var renderQueue = [];
31
+ var getMaxConcurrentRenders = () => {
32
+ const fromEnv = process.env.TWICK_MAX_CONCURRENT_RENDERS;
33
+ const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;
34
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
35
+ };
36
+ async function withRenderSlot(fn) {
37
+ const maxConcurrent = getMaxConcurrentRenders();
38
+ if (activeRenders >= maxConcurrent) {
39
+ await new Promise((resolve) => {
40
+ renderQueue.push(resolve);
41
+ });
42
+ }
43
+ activeRenders++;
44
+ try {
45
+ return await fn();
46
+ } finally {
47
+ activeRenders--;
48
+ const next = renderQueue.shift();
49
+ if (next) {
50
+ next();
51
+ }
52
+ }
53
+ }
29
54
  var renderTwickVideo = async (variables, settings) => {
55
+ const start = Date.now();
30
56
  try {
31
57
  const { input } = variables;
32
58
  const { properties } = input;
59
+ const maxWidth = parseInt(process.env.TWICK_MAX_RENDER_WIDTH ?? "3840", 10) || 3840;
60
+ const maxHeight = parseInt(process.env.TWICK_MAX_RENDER_HEIGHT ?? "2160", 10) || 2160;
61
+ const maxFps = parseInt(process.env.TWICK_MAX_RENDER_FPS ?? "60", 10) || 60;
62
+ const width = Math.min(properties.width, maxWidth);
63
+ const height = Math.min(properties.height, maxHeight);
64
+ const fps = Math.min(properties.fps ?? 30, maxFps);
33
65
  const mergedSettings = {
34
66
  logProgress: true,
35
67
  outDir: "./output",
@@ -40,22 +72,34 @@ var renderTwickVideo = async (variables, settings) => {
40
72
  name: "@twick/core/wasm"
41
73
  },
42
74
  size: {
43
- x: properties.width,
44
- y: properties.height
45
- }
75
+ x: width,
76
+ y: height
77
+ },
78
+ fps
46
79
  },
47
80
  ...settings
48
81
  // Allow user settings to override defaults
49
82
  };
50
- const file = await (0, import_renderer.renderVideo)({
51
- projectFile: "@twick/visualizer/dist/project.js",
52
- variables,
53
- settings: mergedSettings
83
+ const result = await withRenderSlot(async () => {
84
+ const file = await (0, import_renderer.renderVideo)({
85
+ projectFile: "@twick/visualizer/dist/project.js",
86
+ variables,
87
+ settings: mergedSettings
88
+ });
89
+ return file;
54
90
  });
55
- console.log("Successfully rendered: ", file);
56
- return file;
91
+ const elapsedMs = Date.now() - start;
92
+ console.log(
93
+ `[RenderServer] Render completed in ${elapsedMs}ms (active=${activeRenders}) ->`,
94
+ result
95
+ );
96
+ return result;
57
97
  } catch (error) {
58
- console.error("Render error:", error);
98
+ const elapsedMs = Date.now() - start;
99
+ console.error(
100
+ `[RenderServer] Render error after ${elapsedMs}ms (active=${activeRenders}):`,
101
+ error
102
+ );
59
103
  throw error;
60
104
  }
61
105
  };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/renderer.ts"],"sourcesContent":["// Export the renderer function\nexport { default as renderTwickVideo } from \"./renderer\";\n\n","import { renderVideo } from \"@twick/renderer\";\n\n/**\n * Renders a Twick video with the provided variables and settings.\n * Processes project variables, merges settings with defaults, and\n * generates a video file using the Twick renderer.\n *\n * @param variables - Project variables containing input configuration\n * @param settings - Optional render settings to override defaults\n * @returns Promise resolving to the path of the rendered video file\n * \n * @example\n * ```js\n * const videoPath = await renderTwickVideo(\n * { input: { properties: { width: 1920, height: 1080 } } },\n * { quality: \"high\", outFile: \"my-video.mp4\" }\n * );\n * // videoPath = \"./output/my-video.mp4\"\n * ```\n */\nconst renderTwickVideo = async (variables: any, settings: any) => {\n try {\n const { input } = variables;\n const { properties } = input;\n // Merge user settings with defaults\n const mergedSettings = {\n logProgress: true,\n outDir: \"./output\",\n outFile: properties.reqesutId ?? `video-${Date.now()}` + \".mp4\",\n quality: \"medium\",\n projectSettings: {\n exporter: {\n name: \"@twick/core/wasm\",\n },\n size: {\n x: properties.width,\n y: properties.height,\n },\n },\n ...settings, // Allow user settings to override defaults\n };\n\n const file = await renderVideo({\n projectFile: \"@twick/visualizer/dist/project.js\",\n variables: variables,\n settings: mergedSettings,\n });\n console.log(\"Successfully rendered: \", file);\n return file;\n } catch (error) {\n console.error(\"Render error:\", error);\n throw error;\n }\n};\n\nexport default renderTwickVideo;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,sBAA4B;AAoB5B,IAAM,mBAAmB,OAAO,WAAgB,aAAkB;AAChE,MAAI;AACF,UAAM,EAAE,MAAM,IAAI;AAClB,UAAM,EAAE,WAAW,IAAI;AAEvB,UAAM,iBAAiB;AAAA,MACrB,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,SAAS,WAAW,aAAa,SAAS,KAAK,IAAI,CAAC;AAAA,MACpD,SAAS;AAAA,MACT,iBAAiB;AAAA,QACf,UAAU;AAAA,UACR,MAAM;AAAA,QACR;AAAA,QACA,MAAM;AAAA,UACJ,GAAG,WAAW;AAAA,UACd,GAAG,WAAW;AAAA,QAChB;AAAA,MACF;AAAA,MACA,GAAG;AAAA;AAAA,IACL;AAEA,UAAM,OAAO,UAAM,6BAAY;AAAA,MAC7B,aAAa;AAAA,MACb;AAAA,MACA,UAAU;AAAA,IACZ,CAAC;AACD,YAAQ,IAAI,2BAA2B,IAAI;AAC3C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,YAAQ,MAAM,iBAAiB,KAAK;AACpC,UAAM;AAAA,EACR;AACF;AAEA,IAAO,mBAAQ;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/renderer.ts"],"sourcesContent":["// Export the renderer function\nexport { default as renderTwickVideo } from \"./renderer\";\n\n","import { renderVideo } from \"@twick/renderer\";\n\n// Simple in-process concurrency control for render jobs.\n// This helps avoid overloading a single server instance.\nlet activeRenders = 0;\nconst renderQueue: Array<() => void> = [];\n\nconst getMaxConcurrentRenders = () => {\n const fromEnv = process.env.TWICK_MAX_CONCURRENT_RENDERS;\n const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;\n};\n\nasync function withRenderSlot<T>(fn: () => Promise<T>): Promise<T> {\n const maxConcurrent = getMaxConcurrentRenders();\n\n if (activeRenders >= maxConcurrent) {\n await new Promise<void>((resolve) => {\n renderQueue.push(resolve);\n });\n }\n\n activeRenders++;\n try {\n return await fn();\n } finally {\n activeRenders--;\n const next = renderQueue.shift();\n if (next) {\n next();\n }\n }\n}\n\n/**\n * Renders a Twick video with the provided variables and settings.\n * Processes project variables, merges settings with defaults, and\n * generates a video file using the Twick renderer.\n *\n * @param variables - Project variables containing input configuration\n * @param settings - Optional render settings to override defaults\n * @returns Promise resolving to the path of the rendered video file\n * \n * @example\n * ```js\n * const videoPath = await renderTwickVideo(\n * { input: { properties: { width: 1920, height: 1080 } } },\n * { quality: \"high\", outFile: \"my-video.mp4\" }\n * );\n * // videoPath = \"./output/my-video.mp4\"\n * ```\n */\nconst renderTwickVideo = async (variables: any, settings: any) => {\n const start = Date.now();\n try {\n const { input } = variables;\n const { properties } = input;\n\n // Basic safety limits (can be overridden via env)\n const maxWidth =\n parseInt(process.env.TWICK_MAX_RENDER_WIDTH ?? \"3840\", 10) || 3840;\n const maxHeight =\n parseInt(process.env.TWICK_MAX_RENDER_HEIGHT ?? \"2160\", 10) || 2160;\n const maxFps =\n parseInt(process.env.TWICK_MAX_RENDER_FPS ?? \"60\", 10) || 60;\n\n const width = Math.min(properties.width, maxWidth);\n const height = Math.min(properties.height, maxHeight);\n const fps = Math.min(properties.fps ?? 30, maxFps);\n\n // Merge user settings with defaults\n const mergedSettings = {\n logProgress: true,\n outDir: \"./output\",\n outFile: properties.reqesutId ?? `video-${Date.now()}` + \".mp4\",\n quality: \"medium\",\n projectSettings: {\n exporter: {\n name: \"@twick/core/wasm\",\n },\n size: {\n x: width,\n y: height,\n },\n fps,\n },\n ...settings, // Allow user settings to override defaults\n };\n\n const result = await withRenderSlot(async () => {\n const file = await renderVideo({\n projectFile: \"@twick/visualizer/dist/project.js\",\n variables,\n settings: mergedSettings,\n });\n return file;\n });\n\n const elapsedMs = Date.now() - start;\n console.log(\n `[RenderServer] Render completed in ${elapsedMs}ms (active=${activeRenders}) ->`,\n result,\n );\n return result;\n } catch (error) {\n const elapsedMs = Date.now() - start;\n console.error(\n `[RenderServer] Render error after ${elapsedMs}ms (active=${activeRenders}):`,\n error,\n );\n throw error;\n }\n};\n\nexport default renderTwickVideo;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,sBAA4B;AAI5B,IAAI,gBAAgB;AACpB,IAAM,cAAiC,CAAC;AAExC,IAAM,0BAA0B,MAAM;AACpC,QAAM,UAAU,QAAQ,IAAI;AAC5B,QAAM,SAAS,UAAU,SAAS,SAAS,EAAE,IAAI;AACjD,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAEA,eAAe,eAAkB,IAAkC;AACjE,QAAM,gBAAgB,wBAAwB;AAE9C,MAAI,iBAAiB,eAAe;AAClC,UAAM,IAAI,QAAc,CAAC,YAAY;AACnC,kBAAY,KAAK,OAAO;AAAA,IAC1B,CAAC;AAAA,EACH;AAEA;AACA,MAAI;AACF,WAAO,MAAM,GAAG;AAAA,EAClB,UAAE;AACA;AACA,UAAM,OAAO,YAAY,MAAM;AAC/B,QAAI,MAAM;AACR,WAAK;AAAA,IACP;AAAA,EACF;AACF;AAoBA,IAAM,mBAAmB,OAAO,WAAgB,aAAkB;AAChE,QAAM,QAAQ,KAAK,IAAI;AACvB,MAAI;AACF,UAAM,EAAE,MAAM,IAAI;AAClB,UAAM,EAAE,WAAW,IAAI;AAGvB,UAAM,WACJ,SAAS,QAAQ,IAAI,0BAA0B,QAAQ,EAAE,KAAK;AAChE,UAAM,YACJ,SAAS,QAAQ,IAAI,2BAA2B,QAAQ,EAAE,KAAK;AACjE,UAAM,SACJ,SAAS,QAAQ,IAAI,wBAAwB,MAAM,EAAE,KAAK;AAE5D,UAAM,QAAQ,KAAK,IAAI,WAAW,OAAO,QAAQ;AACjD,UAAM,SAAS,KAAK,IAAI,WAAW,QAAQ,SAAS;AACpD,UAAM,MAAM,KAAK,IAAI,WAAW,OAAO,IAAI,MAAM;AAGjD,UAAM,iBAAiB;AAAA,MACrB,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,SAAS,WAAW,aAAa,SAAS,KAAK,IAAI,CAAC;AAAA,MACpD,SAAS;AAAA,MACT,iBAAiB;AAAA,QACf,UAAU;AAAA,UACR,MAAM;AAAA,QACR;AAAA,QACA,MAAM;AAAA,UACJ,GAAG;AAAA,UACH,GAAG;AAAA,QACL;AAAA,QACA;AAAA,MACF;AAAA,MACA,GAAG;AAAA;AAAA,IACL;AAEA,UAAM,SAAS,MAAM,eAAe,YAAY;AAC9C,YAAM,OAAO,UAAM,6BAAY;AAAA,QAC7B,aAAa;AAAA,QACb;AAAA,QACA,UAAU;AAAA,MACZ,CAAC;AACD,aAAO;AAAA,IACT,CAAC;AAED,UAAM,YAAY,KAAK,IAAI,IAAI;AAC/B,YAAQ;AAAA,MACN,sCAAsC,SAAS,cAAc,aAAa;AAAA,MAC1E;AAAA,IACF;AACA,WAAO;AAAA,EACT,SAAS,OAAO;AACd,UAAM,YAAY,KAAK,IAAI,IAAI;AAC/B,YAAQ;AAAA,MACN,qCAAqC,SAAS,cAAc,aAAa;AAAA,MACzE;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;AAEA,IAAO,mBAAQ;","names":[]}
package/dist/index.mjs CHANGED
@@ -1,9 +1,41 @@
1
1
  // src/renderer.ts
2
2
  import { renderVideo } from "@twick/renderer";
3
+ var activeRenders = 0;
4
+ var renderQueue = [];
5
+ var getMaxConcurrentRenders = () => {
6
+ const fromEnv = process.env.TWICK_MAX_CONCURRENT_RENDERS;
7
+ const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;
8
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
9
+ };
10
+ async function withRenderSlot(fn) {
11
+ const maxConcurrent = getMaxConcurrentRenders();
12
+ if (activeRenders >= maxConcurrent) {
13
+ await new Promise((resolve) => {
14
+ renderQueue.push(resolve);
15
+ });
16
+ }
17
+ activeRenders++;
18
+ try {
19
+ return await fn();
20
+ } finally {
21
+ activeRenders--;
22
+ const next = renderQueue.shift();
23
+ if (next) {
24
+ next();
25
+ }
26
+ }
27
+ }
3
28
  var renderTwickVideo = async (variables, settings) => {
29
+ const start = Date.now();
4
30
  try {
5
31
  const { input } = variables;
6
32
  const { properties } = input;
33
+ const maxWidth = parseInt(process.env.TWICK_MAX_RENDER_WIDTH ?? "3840", 10) || 3840;
34
+ const maxHeight = parseInt(process.env.TWICK_MAX_RENDER_HEIGHT ?? "2160", 10) || 2160;
35
+ const maxFps = parseInt(process.env.TWICK_MAX_RENDER_FPS ?? "60", 10) || 60;
36
+ const width = Math.min(properties.width, maxWidth);
37
+ const height = Math.min(properties.height, maxHeight);
38
+ const fps = Math.min(properties.fps ?? 30, maxFps);
7
39
  const mergedSettings = {
8
40
  logProgress: true,
9
41
  outDir: "./output",
@@ -14,22 +46,34 @@ var renderTwickVideo = async (variables, settings) => {
14
46
  name: "@twick/core/wasm"
15
47
  },
16
48
  size: {
17
- x: properties.width,
18
- y: properties.height
19
- }
49
+ x: width,
50
+ y: height
51
+ },
52
+ fps
20
53
  },
21
54
  ...settings
22
55
  // Allow user settings to override defaults
23
56
  };
24
- const file = await renderVideo({
25
- projectFile: "@twick/visualizer/dist/project.js",
26
- variables,
27
- settings: mergedSettings
57
+ const result = await withRenderSlot(async () => {
58
+ const file = await renderVideo({
59
+ projectFile: "@twick/visualizer/dist/project.js",
60
+ variables,
61
+ settings: mergedSettings
62
+ });
63
+ return file;
28
64
  });
29
- console.log("Successfully rendered: ", file);
30
- return file;
65
+ const elapsedMs = Date.now() - start;
66
+ console.log(
67
+ `[RenderServer] Render completed in ${elapsedMs}ms (active=${activeRenders}) ->`,
68
+ result
69
+ );
70
+ return result;
31
71
  } catch (error) {
32
- console.error("Render error:", error);
72
+ const elapsedMs = Date.now() - start;
73
+ console.error(
74
+ `[RenderServer] Render error after ${elapsedMs}ms (active=${activeRenders}):`,
75
+ error
76
+ );
33
77
  throw error;
34
78
  }
35
79
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/renderer.ts"],"sourcesContent":["import { renderVideo } from \"@twick/renderer\";\n\n/**\n * Renders a Twick video with the provided variables and settings.\n * Processes project variables, merges settings with defaults, and\n * generates a video file using the Twick renderer.\n *\n * @param variables - Project variables containing input configuration\n * @param settings - Optional render settings to override defaults\n * @returns Promise resolving to the path of the rendered video file\n * \n * @example\n * ```js\n * const videoPath = await renderTwickVideo(\n * { input: { properties: { width: 1920, height: 1080 } } },\n * { quality: \"high\", outFile: \"my-video.mp4\" }\n * );\n * // videoPath = \"./output/my-video.mp4\"\n * ```\n */\nconst renderTwickVideo = async (variables: any, settings: any) => {\n try {\n const { input } = variables;\n const { properties } = input;\n // Merge user settings with defaults\n const mergedSettings = {\n logProgress: true,\n outDir: \"./output\",\n outFile: properties.reqesutId ?? `video-${Date.now()}` + \".mp4\",\n quality: \"medium\",\n projectSettings: {\n exporter: {\n name: \"@twick/core/wasm\",\n },\n size: {\n x: properties.width,\n y: properties.height,\n },\n },\n ...settings, // Allow user settings to override defaults\n };\n\n const file = await renderVideo({\n projectFile: \"@twick/visualizer/dist/project.js\",\n variables: variables,\n settings: mergedSettings,\n });\n console.log(\"Successfully rendered: \", file);\n return file;\n } catch (error) {\n console.error(\"Render error:\", error);\n throw error;\n }\n};\n\nexport default renderTwickVideo;\n"],"mappings":";AAAA,SAAS,mBAAmB;AAoB5B,IAAM,mBAAmB,OAAO,WAAgB,aAAkB;AAChE,MAAI;AACF,UAAM,EAAE,MAAM,IAAI;AAClB,UAAM,EAAE,WAAW,IAAI;AAEvB,UAAM,iBAAiB;AAAA,MACrB,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,SAAS,WAAW,aAAa,SAAS,KAAK,IAAI,CAAC;AAAA,MACpD,SAAS;AAAA,MACT,iBAAiB;AAAA,QACf,UAAU;AAAA,UACR,MAAM;AAAA,QACR;AAAA,QACA,MAAM;AAAA,UACJ,GAAG,WAAW;AAAA,UACd,GAAG,WAAW;AAAA,QAChB;AAAA,MACF;AAAA,MACA,GAAG;AAAA;AAAA,IACL;AAEA,UAAM,OAAO,MAAM,YAAY;AAAA,MAC7B,aAAa;AAAA,MACb;AAAA,MACA,UAAU;AAAA,IACZ,CAAC;AACD,YAAQ,IAAI,2BAA2B,IAAI;AAC3C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,YAAQ,MAAM,iBAAiB,KAAK;AACpC,UAAM;AAAA,EACR;AACF;AAEA,IAAO,mBAAQ;","names":[]}
1
+ {"version":3,"sources":["../src/renderer.ts"],"sourcesContent":["import { renderVideo } from \"@twick/renderer\";\n\n// Simple in-process concurrency control for render jobs.\n// This helps avoid overloading a single server instance.\nlet activeRenders = 0;\nconst renderQueue: Array<() => void> = [];\n\nconst getMaxConcurrentRenders = () => {\n const fromEnv = process.env.TWICK_MAX_CONCURRENT_RENDERS;\n const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;\n};\n\nasync function withRenderSlot<T>(fn: () => Promise<T>): Promise<T> {\n const maxConcurrent = getMaxConcurrentRenders();\n\n if (activeRenders >= maxConcurrent) {\n await new Promise<void>((resolve) => {\n renderQueue.push(resolve);\n });\n }\n\n activeRenders++;\n try {\n return await fn();\n } finally {\n activeRenders--;\n const next = renderQueue.shift();\n if (next) {\n next();\n }\n }\n}\n\n/**\n * Renders a Twick video with the provided variables and settings.\n * Processes project variables, merges settings with defaults, and\n * generates a video file using the Twick renderer.\n *\n * @param variables - Project variables containing input configuration\n * @param settings - Optional render settings to override defaults\n * @returns Promise resolving to the path of the rendered video file\n * \n * @example\n * ```js\n * const videoPath = await renderTwickVideo(\n * { input: { properties: { width: 1920, height: 1080 } } },\n * { quality: \"high\", outFile: \"my-video.mp4\" }\n * );\n * // videoPath = \"./output/my-video.mp4\"\n * ```\n */\nconst renderTwickVideo = async (variables: any, settings: any) => {\n const start = Date.now();\n try {\n const { input } = variables;\n const { properties } = input;\n\n // Basic safety limits (can be overridden via env)\n const maxWidth =\n parseInt(process.env.TWICK_MAX_RENDER_WIDTH ?? \"3840\", 10) || 3840;\n const maxHeight =\n parseInt(process.env.TWICK_MAX_RENDER_HEIGHT ?? \"2160\", 10) || 2160;\n const maxFps =\n parseInt(process.env.TWICK_MAX_RENDER_FPS ?? \"60\", 10) || 60;\n\n const width = Math.min(properties.width, maxWidth);\n const height = Math.min(properties.height, maxHeight);\n const fps = Math.min(properties.fps ?? 30, maxFps);\n\n // Merge user settings with defaults\n const mergedSettings = {\n logProgress: true,\n outDir: \"./output\",\n outFile: properties.reqesutId ?? `video-${Date.now()}` + \".mp4\",\n quality: \"medium\",\n projectSettings: {\n exporter: {\n name: \"@twick/core/wasm\",\n },\n size: {\n x: width,\n y: height,\n },\n fps,\n },\n ...settings, // Allow user settings to override defaults\n };\n\n const result = await withRenderSlot(async () => {\n const file = await renderVideo({\n projectFile: \"@twick/visualizer/dist/project.js\",\n variables,\n settings: mergedSettings,\n });\n return file;\n });\n\n const elapsedMs = Date.now() - start;\n console.log(\n `[RenderServer] Render completed in ${elapsedMs}ms (active=${activeRenders}) ->`,\n result,\n );\n return result;\n } catch (error) {\n const elapsedMs = Date.now() - start;\n console.error(\n `[RenderServer] Render error after ${elapsedMs}ms (active=${activeRenders}):`,\n error,\n );\n throw error;\n }\n};\n\nexport default renderTwickVideo;\n"],"mappings":";AAAA,SAAS,mBAAmB;AAI5B,IAAI,gBAAgB;AACpB,IAAM,cAAiC,CAAC;AAExC,IAAM,0BAA0B,MAAM;AACpC,QAAM,UAAU,QAAQ,IAAI;AAC5B,QAAM,SAAS,UAAU,SAAS,SAAS,EAAE,IAAI;AACjD,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAEA,eAAe,eAAkB,IAAkC;AACjE,QAAM,gBAAgB,wBAAwB;AAE9C,MAAI,iBAAiB,eAAe;AAClC,UAAM,IAAI,QAAc,CAAC,YAAY;AACnC,kBAAY,KAAK,OAAO;AAAA,IAC1B,CAAC;AAAA,EACH;AAEA;AACA,MAAI;AACF,WAAO,MAAM,GAAG;AAAA,EAClB,UAAE;AACA;AACA,UAAM,OAAO,YAAY,MAAM;AAC/B,QAAI,MAAM;AACR,WAAK;AAAA,IACP;AAAA,EACF;AACF;AAoBA,IAAM,mBAAmB,OAAO,WAAgB,aAAkB;AAChE,QAAM,QAAQ,KAAK,IAAI;AACvB,MAAI;AACF,UAAM,EAAE,MAAM,IAAI;AAClB,UAAM,EAAE,WAAW,IAAI;AAGvB,UAAM,WACJ,SAAS,QAAQ,IAAI,0BAA0B,QAAQ,EAAE,KAAK;AAChE,UAAM,YACJ,SAAS,QAAQ,IAAI,2BAA2B,QAAQ,EAAE,KAAK;AACjE,UAAM,SACJ,SAAS,QAAQ,IAAI,wBAAwB,MAAM,EAAE,KAAK;AAE5D,UAAM,QAAQ,KAAK,IAAI,WAAW,OAAO,QAAQ;AACjD,UAAM,SAAS,KAAK,IAAI,WAAW,QAAQ,SAAS;AACpD,UAAM,MAAM,KAAK,IAAI,WAAW,OAAO,IAAI,MAAM;AAGjD,UAAM,iBAAiB;AAAA,MACrB,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,SAAS,WAAW,aAAa,SAAS,KAAK,IAAI,CAAC;AAAA,MACpD,SAAS;AAAA,MACT,iBAAiB;AAAA,QACf,UAAU;AAAA,UACR,MAAM;AAAA,QACR;AAAA,QACA,MAAM;AAAA,UACJ,GAAG;AAAA,UACH,GAAG;AAAA,QACL;AAAA,QACA;AAAA,MACF;AAAA,MACA,GAAG;AAAA;AAAA,IACL;AAEA,UAAM,SAAS,MAAM,eAAe,YAAY;AAC9C,YAAM,OAAO,MAAM,YAAY;AAAA,QAC7B,aAAa;AAAA,QACb;AAAA,QACA,UAAU;AAAA,MACZ,CAAC;AACD,aAAO;AAAA,IACT,CAAC;AAED,UAAM,YAAY,KAAK,IAAI,IAAI;AAC/B,YAAQ;AAAA,MACN,sCAAsC,SAAS,cAAc,aAAa;AAAA,MAC1E;AAAA,IACF;AACA,WAAO;AAAA,EACT,SAAS,OAAO;AACd,UAAM,YAAY,KAAK,IAAI,IAAI;AAC/B,YAAQ;AAAA,MACN,qCAAqC,SAAS,cAAc,aAAa;AAAA,MACzE;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;AAEA,IAAO,mBAAQ;","names":[]}
package/dist/server.mjs CHANGED
@@ -6,10 +6,42 @@ import { fileURLToPath } from "url";
6
6
 
7
7
  // src/renderer.ts
8
8
  import { renderVideo } from "@twick/renderer";
9
+ var activeRenders = 0;
10
+ var renderQueue = [];
11
+ var getMaxConcurrentRenders = () => {
12
+ const fromEnv = process.env.TWICK_MAX_CONCURRENT_RENDERS;
13
+ const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;
14
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
15
+ };
16
+ async function withRenderSlot(fn) {
17
+ const maxConcurrent = getMaxConcurrentRenders();
18
+ if (activeRenders >= maxConcurrent) {
19
+ await new Promise((resolve) => {
20
+ renderQueue.push(resolve);
21
+ });
22
+ }
23
+ activeRenders++;
24
+ try {
25
+ return await fn();
26
+ } finally {
27
+ activeRenders--;
28
+ const next = renderQueue.shift();
29
+ if (next) {
30
+ next();
31
+ }
32
+ }
33
+ }
9
34
  var renderTwickVideo = async (variables, settings) => {
35
+ const start = Date.now();
10
36
  try {
11
37
  const { input } = variables;
12
38
  const { properties } = input;
39
+ const maxWidth = parseInt(process.env.TWICK_MAX_RENDER_WIDTH ?? "3840", 10) || 3840;
40
+ const maxHeight = parseInt(process.env.TWICK_MAX_RENDER_HEIGHT ?? "2160", 10) || 2160;
41
+ const maxFps = parseInt(process.env.TWICK_MAX_RENDER_FPS ?? "60", 10) || 60;
42
+ const width = Math.min(properties.width, maxWidth);
43
+ const height = Math.min(properties.height, maxHeight);
44
+ const fps = Math.min(properties.fps ?? 30, maxFps);
13
45
  const mergedSettings = {
14
46
  logProgress: true,
15
47
  outDir: "./output",
@@ -20,22 +52,34 @@ var renderTwickVideo = async (variables, settings) => {
20
52
  name: "@twick/core/wasm"
21
53
  },
22
54
  size: {
23
- x: properties.width,
24
- y: properties.height
25
- }
55
+ x: width,
56
+ y: height
57
+ },
58
+ fps
26
59
  },
27
60
  ...settings
28
61
  // Allow user settings to override defaults
29
62
  };
30
- const file = await renderVideo({
31
- projectFile: "@twick/visualizer/dist/project.js",
32
- variables,
33
- settings: mergedSettings
63
+ const result = await withRenderSlot(async () => {
64
+ const file = await renderVideo({
65
+ projectFile: "@twick/visualizer/dist/project.js",
66
+ variables,
67
+ settings: mergedSettings
68
+ });
69
+ return file;
34
70
  });
35
- console.log("Successfully rendered: ", file);
36
- return file;
71
+ const elapsedMs = Date.now() - start;
72
+ console.log(
73
+ `[RenderServer] Render completed in ${elapsedMs}ms (active=${activeRenders}) ->`,
74
+ result
75
+ );
76
+ return result;
37
77
  } catch (error) {
38
- console.error("Render error:", error);
78
+ const elapsedMs = Date.now() - start;
79
+ console.error(
80
+ `[RenderServer] Render error after ${elapsedMs}ms (active=${activeRenders}):`,
81
+ error
82
+ );
39
83
  throw error;
40
84
  }
41
85
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts","../src/renderer.ts"],"sourcesContent":["import express from \"express\";\nimport cors from \"cors\";\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\nimport renderTwickVideo from \"./renderer\";\n\nconst PORT = process.env.PORT || 5000;\nconst BASE_PATH = `http://localhost:${PORT}`;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Rate limiting configuration (all configurable via env)\nconst RATE_LIMIT_WINDOW_MS =\n parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? \"900000\", 10) || 15 * 60 * 1000; // default 15 min (ms)\nconst RATE_LIMIT_MAX_REQUESTS =\n parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? \"100\", 10) || 100; // max requests per window\nconst RATE_LIMIT_CLEANUP_INTERVAL_MS =\n parseInt(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS ?? \"60000\", 10) || 60 * 1000; // default 1 min (ms)\n\n// In-memory store for rate limiting\ninterface RateLimitEntry {\n count: number;\n resetTime: number;\n}\n\nconst rateLimitStore = new Map<string, RateLimitEntry>();\n\n// Cleanup expired entries periodically\nsetInterval(() => {\n const now = Date.now();\n for (const [key, entry] of rateLimitStore.entries()) {\n if (now > entry.resetTime) {\n rateLimitStore.delete(key);\n }\n }\n}, RATE_LIMIT_CLEANUP_INTERVAL_MS);\n\n/**\n * Rate limiting middleware for API endpoints.\n * Tracks request counts per IP address and enforces rate limits\n * to prevent abuse of the render server.\n *\n * @param req - Express request object\n * @param res - Express response object\n * @param next - Express next function\n * \n * @example\n * ```js\n * app.use('/api', rateLimitMiddleware);\n * // Applies rate limiting to all /api routes\n * ```\n */\nconst rateLimitMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {\n const clientIP = req.ip || req.connection.remoteAddress || 'unknown';\n const now = Date.now();\n \n // Get or create rate limit entry for this IP\n let entry = rateLimitStore.get(clientIP);\n \n if (!entry || now > entry.resetTime) {\n // New window or expired entry\n entry = {\n count: 1,\n resetTime: now + RATE_LIMIT_WINDOW_MS\n };\n rateLimitStore.set(clientIP, entry);\n } else {\n // Increment count in current window\n entry.count++;\n \n if (entry.count > RATE_LIMIT_MAX_REQUESTS) {\n // Rate limit exceeded\n const retryAfter = Math.ceil((entry.resetTime - now) / 1000);\n res.set('Retry-After', retryAfter.toString());\n res.set('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString());\n res.set('X-RateLimit-Remaining', '0');\n res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());\n \n return res.status(429).json({\n success: false,\n error: 'Too many requests',\n message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,\n retryAfter\n });\n }\n }\n \n // Add rate limit headers\n res.set('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString());\n res.set('X-RateLimit-Remaining', (RATE_LIMIT_MAX_REQUESTS - entry.count).toString());\n res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());\n \n next();\n};\n\nconst nodeApp: import(\"express\").Express = express();\n\nnodeApp.use(cors());\nnodeApp.use(express.json());\n\n// Serve static files from output directory\nnodeApp.use(\"/output\", express.static(path.join(__dirname, \"../output\")));\n\n/**\n * POST endpoint for video rendering requests.\n * Accepts project variables and settings, renders the video,\n * and returns a download URL for the completed video.\n *\n * @param req - Express request object containing variables and settings\n * @param res - Express response object\n * \n * @example\n * ```js\n * POST /api/render-video\n * Body: { variables: {...}, settings: {...} }\n * Response: { success: true, downloadUrl: \"...\" }\n * ```\n */\nnodeApp.post(\"/api/render-video\", async (req, res) => {\n const { variables, settings } = req.body;\n\n try {\n const outputPath = await renderTwickVideo(variables, settings);\n res.json({\n success: true,\n downloadUrl: `${BASE_PATH}/download/${path.basename(outputPath)}`,\n });\n } catch (error) {\n console.error(\"Render error:\", error);\n res.status(500).json({\n success: false,\n error: error instanceof Error ? error.message : \"Unknown error\",\n });\n }\n});\n\n/**\n * GET endpoint for downloading rendered videos.\n * Serves video files with rate limiting and security checks\n * to prevent path traversal attacks.\n *\n * @param req - Express request object with filename parameter\n * @param res - Express response object\n * \n * @example\n * ```js\n * GET /download/video-123.mp4\n * // Downloads the specified video file\n * ```\n */\nnodeApp.get(\"/download/:filename\", rateLimitMiddleware, (req, res) => {\n const outputDir = path.resolve(__dirname, \"../output\");\n const requestedPath = path.resolve(outputDir, req.params.filename);\n if (!requestedPath.startsWith(outputDir + path.sep)) {\n // Attempted path traversal or access outside output directory\n res.status(403).json({\n success: false,\n error: \"Forbidden\",\n });\n return;\n }\n res.download(requestedPath, (err) => {\n if (err) {\n res.status(404).json({\n success: false,\n error: \"File not found\",\n });\n }\n });\n});\n\n/**\n * Health check endpoint for monitoring server status.\n * Returns server status and current timestamp for health monitoring.\n *\n * @param req - Express request object\n * @param res - Express response object\n * \n * @example\n * ```js\n * GET /health\n * Response: { status: \"ok\", timestamp: \"2024-01-01T00:00:00.000Z\" }\n * ```\n */\nnodeApp.get(\"/health\", (req, res) => {\n res.json({\n status: \"ok\",\n timestamp: new Date().toISOString(),\n });\n});\n\n// Export the app for programmatic usage\nexport default nodeApp;\n\n// Start the server\nnodeApp.listen(PORT, () => {\n console.log(`Render server running on port ${PORT}`);\n console.log(`Health check: ${BASE_PATH}/health`);\n console.log(`API endpoint: ${BASE_PATH}/api/render-video`);\n console.log(`Download endpoint rate limited: ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_MS / 60000} minutes`);\n});\n","import { renderVideo } from \"@twick/renderer\";\n\n/**\n * Renders a Twick video with the provided variables and settings.\n * Processes project variables, merges settings with defaults, and\n * generates a video file using the Twick renderer.\n *\n * @param variables - Project variables containing input configuration\n * @param settings - Optional render settings to override defaults\n * @returns Promise resolving to the path of the rendered video file\n * \n * @example\n * ```js\n * const videoPath = await renderTwickVideo(\n * { input: { properties: { width: 1920, height: 1080 } } },\n * { quality: \"high\", outFile: \"my-video.mp4\" }\n * );\n * // videoPath = \"./output/my-video.mp4\"\n * ```\n */\nconst renderTwickVideo = async (variables: any, settings: any) => {\n try {\n const { input } = variables;\n const { properties } = input;\n // Merge user settings with defaults\n const mergedSettings = {\n logProgress: true,\n outDir: \"./output\",\n outFile: properties.reqesutId ?? `video-${Date.now()}` + \".mp4\",\n quality: \"medium\",\n projectSettings: {\n exporter: {\n name: \"@twick/core/wasm\",\n },\n size: {\n x: properties.width,\n y: properties.height,\n },\n },\n ...settings, // Allow user settings to override defaults\n };\n\n const file = await renderVideo({\n projectFile: \"@twick/visualizer/dist/project.js\",\n variables: variables,\n settings: mergedSettings,\n });\n console.log(\"Successfully rendered: \", file);\n return file;\n } catch (error) {\n console.error(\"Render error:\", error);\n throw error;\n }\n};\n\nexport default renderTwickVideo;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/server.ts","../src/renderer.ts"],"sourcesContent":["import express from \"express\";\nimport cors from \"cors\";\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\nimport renderTwickVideo from \"./renderer\";\n\nconst PORT = process.env.PORT || 5000;\nconst BASE_PATH = `http://localhost:${PORT}`;\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Rate limiting configuration (all configurable via env)\nconst RATE_LIMIT_WINDOW_MS =\n parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? \"900000\", 10) || 15 * 60 * 1000; // default 15 min (ms)\nconst RATE_LIMIT_MAX_REQUESTS =\n parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? \"100\", 10) || 100; // max requests per window\nconst RATE_LIMIT_CLEANUP_INTERVAL_MS =\n parseInt(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS ?? \"60000\", 10) || 60 * 1000; // default 1 min (ms)\n\n// In-memory store for rate limiting\ninterface RateLimitEntry {\n count: number;\n resetTime: number;\n}\n\nconst rateLimitStore = new Map<string, RateLimitEntry>();\n\n// Cleanup expired entries periodically\nsetInterval(() => {\n const now = Date.now();\n for (const [key, entry] of rateLimitStore.entries()) {\n if (now > entry.resetTime) {\n rateLimitStore.delete(key);\n }\n }\n}, RATE_LIMIT_CLEANUP_INTERVAL_MS);\n\n/**\n * Rate limiting middleware for API endpoints.\n * Tracks request counts per IP address and enforces rate limits\n * to prevent abuse of the render server.\n *\n * @param req - Express request object\n * @param res - Express response object\n * @param next - Express next function\n * \n * @example\n * ```js\n * app.use('/api', rateLimitMiddleware);\n * // Applies rate limiting to all /api routes\n * ```\n */\nconst rateLimitMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {\n const clientIP = req.ip || req.connection.remoteAddress || 'unknown';\n const now = Date.now();\n \n // Get or create rate limit entry for this IP\n let entry = rateLimitStore.get(clientIP);\n \n if (!entry || now > entry.resetTime) {\n // New window or expired entry\n entry = {\n count: 1,\n resetTime: now + RATE_LIMIT_WINDOW_MS\n };\n rateLimitStore.set(clientIP, entry);\n } else {\n // Increment count in current window\n entry.count++;\n \n if (entry.count > RATE_LIMIT_MAX_REQUESTS) {\n // Rate limit exceeded\n const retryAfter = Math.ceil((entry.resetTime - now) / 1000);\n res.set('Retry-After', retryAfter.toString());\n res.set('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString());\n res.set('X-RateLimit-Remaining', '0');\n res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());\n \n return res.status(429).json({\n success: false,\n error: 'Too many requests',\n message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,\n retryAfter\n });\n }\n }\n \n // Add rate limit headers\n res.set('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS.toString());\n res.set('X-RateLimit-Remaining', (RATE_LIMIT_MAX_REQUESTS - entry.count).toString());\n res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());\n \n next();\n};\n\nconst nodeApp: import(\"express\").Express = express();\n\nnodeApp.use(cors());\nnodeApp.use(express.json());\n\n// Serve static files from output directory\nnodeApp.use(\"/output\", express.static(path.join(__dirname, \"../output\")));\n\n/**\n * POST endpoint for video rendering requests.\n * Accepts project variables and settings, renders the video,\n * and returns a download URL for the completed video.\n *\n * @param req - Express request object containing variables and settings\n * @param res - Express response object\n * \n * @example\n * ```js\n * POST /api/render-video\n * Body: { variables: {...}, settings: {...} }\n * Response: { success: true, downloadUrl: \"...\" }\n * ```\n */\nnodeApp.post(\"/api/render-video\", async (req, res) => {\n const { variables, settings } = req.body;\n\n try {\n const outputPath = await renderTwickVideo(variables, settings);\n res.json({\n success: true,\n downloadUrl: `${BASE_PATH}/download/${path.basename(outputPath)}`,\n });\n } catch (error) {\n console.error(\"Render error:\", error);\n res.status(500).json({\n success: false,\n error: error instanceof Error ? error.message : \"Unknown error\",\n });\n }\n});\n\n/**\n * GET endpoint for downloading rendered videos.\n * Serves video files with rate limiting and security checks\n * to prevent path traversal attacks.\n *\n * @param req - Express request object with filename parameter\n * @param res - Express response object\n * \n * @example\n * ```js\n * GET /download/video-123.mp4\n * // Downloads the specified video file\n * ```\n */\nnodeApp.get(\"/download/:filename\", rateLimitMiddleware, (req, res) => {\n const outputDir = path.resolve(__dirname, \"../output\");\n const requestedPath = path.resolve(outputDir, req.params.filename);\n if (!requestedPath.startsWith(outputDir + path.sep)) {\n // Attempted path traversal or access outside output directory\n res.status(403).json({\n success: false,\n error: \"Forbidden\",\n });\n return;\n }\n res.download(requestedPath, (err) => {\n if (err) {\n res.status(404).json({\n success: false,\n error: \"File not found\",\n });\n }\n });\n});\n\n/**\n * Health check endpoint for monitoring server status.\n * Returns server status and current timestamp for health monitoring.\n *\n * @param req - Express request object\n * @param res - Express response object\n * \n * @example\n * ```js\n * GET /health\n * Response: { status: \"ok\", timestamp: \"2024-01-01T00:00:00.000Z\" }\n * ```\n */\nnodeApp.get(\"/health\", (req, res) => {\n res.json({\n status: \"ok\",\n timestamp: new Date().toISOString(),\n });\n});\n\n// Export the app for programmatic usage\nexport default nodeApp;\n\n// Start the server\nnodeApp.listen(PORT, () => {\n console.log(`Render server running on port ${PORT}`);\n console.log(`Health check: ${BASE_PATH}/health`);\n console.log(`API endpoint: ${BASE_PATH}/api/render-video`);\n console.log(`Download endpoint rate limited: ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_MS / 60000} minutes`);\n});\n","import { renderVideo } from \"@twick/renderer\";\n\n// Simple in-process concurrency control for render jobs.\n// This helps avoid overloading a single server instance.\nlet activeRenders = 0;\nconst renderQueue: Array<() => void> = [];\n\nconst getMaxConcurrentRenders = () => {\n const fromEnv = process.env.TWICK_MAX_CONCURRENT_RENDERS;\n const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;\n};\n\nasync function withRenderSlot<T>(fn: () => Promise<T>): Promise<T> {\n const maxConcurrent = getMaxConcurrentRenders();\n\n if (activeRenders >= maxConcurrent) {\n await new Promise<void>((resolve) => {\n renderQueue.push(resolve);\n });\n }\n\n activeRenders++;\n try {\n return await fn();\n } finally {\n activeRenders--;\n const next = renderQueue.shift();\n if (next) {\n next();\n }\n }\n}\n\n/**\n * Renders a Twick video with the provided variables and settings.\n * Processes project variables, merges settings with defaults, and\n * generates a video file using the Twick renderer.\n *\n * @param variables - Project variables containing input configuration\n * @param settings - Optional render settings to override defaults\n * @returns Promise resolving to the path of the rendered video file\n * \n * @example\n * ```js\n * const videoPath = await renderTwickVideo(\n * { input: { properties: { width: 1920, height: 1080 } } },\n * { quality: \"high\", outFile: \"my-video.mp4\" }\n * );\n * // videoPath = \"./output/my-video.mp4\"\n * ```\n */\nconst renderTwickVideo = async (variables: any, settings: any) => {\n const start = Date.now();\n try {\n const { input } = variables;\n const { properties } = input;\n\n // Basic safety limits (can be overridden via env)\n const maxWidth =\n parseInt(process.env.TWICK_MAX_RENDER_WIDTH ?? \"3840\", 10) || 3840;\n const maxHeight =\n parseInt(process.env.TWICK_MAX_RENDER_HEIGHT ?? \"2160\", 10) || 2160;\n const maxFps =\n parseInt(process.env.TWICK_MAX_RENDER_FPS ?? \"60\", 10) || 60;\n\n const width = Math.min(properties.width, maxWidth);\n const height = Math.min(properties.height, maxHeight);\n const fps = Math.min(properties.fps ?? 30, maxFps);\n\n // Merge user settings with defaults\n const mergedSettings = {\n logProgress: true,\n outDir: \"./output\",\n outFile: properties.reqesutId ?? `video-${Date.now()}` + \".mp4\",\n quality: \"medium\",\n projectSettings: {\n exporter: {\n name: \"@twick/core/wasm\",\n },\n size: {\n x: width,\n y: height,\n },\n fps,\n },\n ...settings, // Allow user settings to override defaults\n };\n\n const result = await withRenderSlot(async () => {\n const file = await renderVideo({\n projectFile: \"@twick/visualizer/dist/project.js\",\n variables,\n settings: mergedSettings,\n });\n return file;\n });\n\n const elapsedMs = Date.now() - start;\n console.log(\n `[RenderServer] Render completed in ${elapsedMs}ms (active=${activeRenders}) ->`,\n result,\n );\n return result;\n } catch (error) {\n const elapsedMs = Date.now() - start;\n console.error(\n `[RenderServer] Render error after ${elapsedMs}ms (active=${activeRenders}):`,\n error,\n );\n throw error;\n }\n};\n\nexport default renderTwickVideo;\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twick/render-server",
3
- "version": "0.15.14",
3
+ "version": "0.15.15",
4
4
  "license": "https://github.com/ncounterspecialist/twick/blob/main/LICENSE.md",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -26,12 +26,12 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@twick/2d": "^0.15.14",
30
- "@twick/core": "^0.15.14",
31
- "@twick/ffmpeg": "^0.15.14",
32
- "@twick/renderer": "^0.15.14",
33
- "@twick/ui": "^0.15.14",
34
- "@twick/visualizer": "0.15.14",
29
+ "@twick/2d": "^0.15.15",
30
+ "@twick/core": "^0.15.15",
31
+ "@twick/ffmpeg": "^0.15.15",
32
+ "@twick/renderer": "^0.15.15",
33
+ "@twick/ui": "^0.15.15",
34
+ "@twick/visualizer": "0.15.15",
35
35
  "cors": "^2.8.5",
36
36
  "express": "^4.18.2",
37
37
  "express-rate-limit": "^8.0.1",
package/package.json.bak CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twick/render-server",
3
- "version": "0.15.14",
3
+ "version": "0.15.15",
4
4
  "license": "https://github.com/ncounterspecialist/twick/blob/main/LICENSE.md",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -26,12 +26,12 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@twick/2d": "^0.15.14",
30
- "@twick/core": "^0.15.14",
31
- "@twick/ffmpeg": "^0.15.14",
32
- "@twick/renderer": "^0.15.14",
33
- "@twick/ui": "^0.15.14",
34
- "@twick/visualizer": "0.15.14",
29
+ "@twick/2d": "^0.15.15",
30
+ "@twick/core": "^0.15.15",
31
+ "@twick/ffmpeg": "^0.15.15",
32
+ "@twick/renderer": "^0.15.15",
33
+ "@twick/ui": "^0.15.15",
34
+ "@twick/visualizer": "0.15.15",
35
35
  "cors": "^2.8.5",
36
36
  "express": "^4.18.2",
37
37
  "express-rate-limit": "^8.0.1",
package/src/renderer.ts CHANGED
@@ -1,5 +1,37 @@
1
1
  import { renderVideo } from "@twick/renderer";
2
2
 
3
+ // Simple in-process concurrency control for render jobs.
4
+ // This helps avoid overloading a single server instance.
5
+ let activeRenders = 0;
6
+ const renderQueue: Array<() => void> = [];
7
+
8
+ const getMaxConcurrentRenders = () => {
9
+ const fromEnv = process.env.TWICK_MAX_CONCURRENT_RENDERS;
10
+ const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;
11
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
12
+ };
13
+
14
+ async function withRenderSlot<T>(fn: () => Promise<T>): Promise<T> {
15
+ const maxConcurrent = getMaxConcurrentRenders();
16
+
17
+ if (activeRenders >= maxConcurrent) {
18
+ await new Promise<void>((resolve) => {
19
+ renderQueue.push(resolve);
20
+ });
21
+ }
22
+
23
+ activeRenders++;
24
+ try {
25
+ return await fn();
26
+ } finally {
27
+ activeRenders--;
28
+ const next = renderQueue.shift();
29
+ if (next) {
30
+ next();
31
+ }
32
+ }
33
+ }
34
+
3
35
  /**
4
36
  * Renders a Twick video with the provided variables and settings.
5
37
  * Processes project variables, merges settings with defaults, and
@@ -19,9 +51,23 @@ import { renderVideo } from "@twick/renderer";
19
51
  * ```
20
52
  */
21
53
  const renderTwickVideo = async (variables: any, settings: any) => {
54
+ const start = Date.now();
22
55
  try {
23
56
  const { input } = variables;
24
57
  const { properties } = input;
58
+
59
+ // Basic safety limits (can be overridden via env)
60
+ const maxWidth =
61
+ parseInt(process.env.TWICK_MAX_RENDER_WIDTH ?? "3840", 10) || 3840;
62
+ const maxHeight =
63
+ parseInt(process.env.TWICK_MAX_RENDER_HEIGHT ?? "2160", 10) || 2160;
64
+ const maxFps =
65
+ parseInt(process.env.TWICK_MAX_RENDER_FPS ?? "60", 10) || 60;
66
+
67
+ const width = Math.min(properties.width, maxWidth);
68
+ const height = Math.min(properties.height, maxHeight);
69
+ const fps = Math.min(properties.fps ?? 30, maxFps);
70
+
25
71
  // Merge user settings with defaults
26
72
  const mergedSettings = {
27
73
  logProgress: true,
@@ -33,22 +79,35 @@ const renderTwickVideo = async (variables: any, settings: any) => {
33
79
  name: "@twick/core/wasm",
34
80
  },
35
81
  size: {
36
- x: properties.width,
37
- y: properties.height,
82
+ x: width,
83
+ y: height,
38
84
  },
85
+ fps,
39
86
  },
40
87
  ...settings, // Allow user settings to override defaults
41
88
  };
42
89
 
43
- const file = await renderVideo({
44
- projectFile: "@twick/visualizer/dist/project.js",
45
- variables: variables,
46
- settings: mergedSettings,
90
+ const result = await withRenderSlot(async () => {
91
+ const file = await renderVideo({
92
+ projectFile: "@twick/visualizer/dist/project.js",
93
+ variables,
94
+ settings: mergedSettings,
95
+ });
96
+ return file;
47
97
  });
48
- console.log("Successfully rendered: ", file);
49
- return file;
98
+
99
+ const elapsedMs = Date.now() - start;
100
+ console.log(
101
+ `[RenderServer] Render completed in ${elapsedMs}ms (active=${activeRenders}) ->`,
102
+ result,
103
+ );
104
+ return result;
50
105
  } catch (error) {
51
- console.error("Render error:", error);
106
+ const elapsedMs = Date.now() - start;
107
+ console.error(
108
+ `[RenderServer] Render error after ${elapsedMs}ms (active=${activeRenders}):`,
109
+ error,
110
+ );
52
111
  throw error;
53
112
  }
54
113
  };