mulmocast 2.7.0 → 2.7.2

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.
@@ -7,8 +7,9 @@ import { ZipBuilder } from "../utils/zip.js";
7
7
  import { bundleTargetLang } from "../types/const.js";
8
8
  import { createSilentAudio } from "../utils/ffmpeg_utils.js";
9
9
  import { silentMp3 } from "../utils/context.js";
10
+ import { safeFetch, FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS } from "../utils/fetch.js";
10
11
  const downloadFile = async (url, destPath) => {
11
- const response = await fetch(url);
12
+ const response = await safeFetch(url, {}, FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS);
12
13
  if (!response.ok) {
13
14
  throw new Error(`Failed to download file from ${url}: ${response.statusText}`);
14
15
  }
@@ -6,6 +6,7 @@ import { MulmoPresentationStyleMethods } from "../methods/index.js";
6
6
  import { localizedText, isHttp } from "../utils/utils.js";
7
7
  import { getOutputPdfFilePath, writingMessage, getHTMLFile, mulmoCreditPath } from "../utils/file.js";
8
8
  import { interpolate } from "../utils/html_render.js";
9
+ import { safeFetch, FETCH_DOWNLOAD_TIMEOUT_MS } from "../utils/fetch.js";
9
10
  import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
10
11
  const isCI = process.env.CI === "true";
11
12
  const getPdfSize = (pdfSize) => {
@@ -13,7 +14,9 @@ const getPdfSize = (pdfSize) => {
13
14
  };
14
15
  const loadImage = async (imagePath, index) => {
15
16
  try {
16
- const imageData = isHttp(imagePath) ? Buffer.from(await (await fetch(imagePath)).arrayBuffer()) : fs.readFileSync(imagePath);
17
+ const imageData = isHttp(imagePath)
18
+ ? Buffer.from(await (await safeFetch(imagePath, {}, FETCH_DOWNLOAD_TIMEOUT_MS)).arrayBuffer())
19
+ : fs.readFileSync(imagePath);
17
20
  const ext = path.extname(imagePath).toLowerCase().replace(".", "");
18
21
  const mimeType = ext === "jpg" ? "jpeg" : ext;
19
22
  return `data:image/${mimeType};base64,${imageData.toString("base64")}`;
@@ -5,6 +5,9 @@ import { apiKeyMissingError, agentIncorrectAPIKeyError, agentGenerationError, ag
5
5
  import { getAspectRatio } from "../utils/utils.js";
6
6
  import { ASPECT_RATIOS, PRO_ASPECT_RATIOS } from "../types/const.js";
7
7
  import { GoogleGenAI, PersonGeneration } from "@google/genai";
8
+ // Per-request timeout so a stalled GenAI image call rejects (and GraphAI retry
9
+ // can recover) instead of hanging. Generous vs. normal generation time.
10
+ const GENAI_REQUEST_TIMEOUT_MS = 120_000;
8
11
  const isDeprecatedGoogleImageModel = (model) => model in deprecatedGoogleImageModelHints;
9
12
  export const buildDeprecatedGoogleImageModelMessage = (model) => {
10
13
  if (!isDeprecatedGoogleImageModel(model))
@@ -86,7 +89,7 @@ export const imageGenAIAgent = async ({ namedInputs, params, config, }) => {
86
89
  if (vertexAIGlobalOnlyImageModels.has(model) && location !== "global") {
87
90
  GraphAILogger.warn(`imageGenAIAgent: model "${model}" on Vertex AI is only available in location "global", but got "${location}". Set imageParams.vertexai_location to "global".`);
88
91
  }
89
- return new GoogleGenAI({ vertexai: true, project: params.vertexai_project, location });
92
+ return new GoogleGenAI({ vertexai: true, project: params.vertexai_project, location, httpOptions: { timeout: GENAI_REQUEST_TIMEOUT_MS } });
90
93
  })()
91
94
  : (() => {
92
95
  if (!apiKey) {
@@ -94,7 +97,7 @@ export const imageGenAIAgent = async ({ namedInputs, params, config, }) => {
94
97
  cause: apiKeyMissingError("imageGenAIAgent", imageAction, "GEMINI_API_KEY"),
95
98
  });
96
99
  }
97
- return new GoogleGenAI({ apiKey });
100
+ return new GoogleGenAI({ apiKey, httpOptions: { timeout: GENAI_REQUEST_TIMEOUT_MS } });
98
101
  })();
99
102
  if (model === "gemini-2.5-flash-image" || model === "gemini-3.1-flash-image-preview" || model === "gemini-3-pro-image-preview") {
100
103
  const contentParams = (() => {
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import { GraphAILogger } from "graphai";
4
4
  import { toFile, AuthenticationError, RateLimitError, APIError } from "openai";
5
5
  import { createOpenAIClient } from "../utils/openai_client.js";
6
+ import { safeFetch, FETCH_DOWNLOAD_TIMEOUT_MS } from "../utils/fetch.js";
6
7
  import { provider2ImageAgent, gptImages, deprecatedOpenAIImageModelHints } from "../types/provider2agent.js";
7
8
  import { apiKeyMissingError, agentGenerationError, openAIAgentGenerationError, agentIncorrectAPIKeyError, agentAPIRateLimitError, agentInvalidResponseError, imageAction, imageFileTarget, unsupportedModelTarget, } from "../utils/error_cause.js";
8
9
  const isDeprecatedOpenAIImageModel = (model) => model in deprecatedOpenAIImageModelHints;
@@ -142,7 +143,7 @@ export const imageOpenaiAgent = async ({ namedInputs, params, config, }) => {
142
143
  return { buffer: Buffer.from(image_base64, "base64"), usage };
143
144
  }
144
145
  // URL response handling (legacy OpenAI image API response format)
145
- const res = await fetch(url);
146
+ const res = await safeFetch(url, {}, FETCH_DOWNLOAD_TIMEOUT_MS);
146
147
  if (!res.ok) {
147
148
  throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`, {
148
149
  cause: agentGenerationError("imageOpenaiAgent", imageAction, imageFileTarget),
@@ -5,6 +5,7 @@ import { getAspectRatio } from "./movie_replicate_agent.js";
5
5
  import { apiKeyMissingError, agentIncorrectAPIKeyError, agentGenerationError, agentInvalidResponseError, imageAction, imageFileTarget, hasCause, } from "../utils/error_cause.js";
6
6
  import { provider2ImageAgent } from "../types/provider2agent.js";
7
7
  import { runReplicateWithMetrics } from "../utils/replicate_usage.js";
8
+ import { safeFetch, FETCH_DOWNLOAD_TIMEOUT_MS } from "../utils/fetch.js";
8
9
  // Replicate image models return one of: FileOutput (object with url() method),
9
10
  // Array<FileOutput>, string URL, or { url: string }. Normalize to URL string/URL.
10
11
  const extractImageUrl = (output) => {
@@ -52,7 +53,7 @@ export const imageReplicateAgent = async ({ namedInputs, params, config, }) => {
52
53
  cause: agentInvalidResponseError("imageReplicateAgent", imageAction, imageFileTarget),
53
54
  });
54
55
  }
55
- const imageResponse = await fetch(imageUrl);
56
+ const imageResponse = await safeFetch(imageUrl, {}, FETCH_DOWNLOAD_TIMEOUT_MS);
56
57
  if (!imageResponse.ok) {
57
58
  throw new Error(`Error downloading image: ${imageResponse.status} - ${imageResponse.statusText}`, {
58
59
  cause: agentGenerationError("imageReplicateAgent", imageAction, imageFileTarget),
@@ -4,6 +4,7 @@ import Replicate from "replicate";
4
4
  import { provider2LipSyncAgent } from "../types/provider2agent.js";
5
5
  import { apiKeyMissingError, agentGenerationError, agentFileNotExistError, imageAction, movieFileTarget, audioFileTarget, hasCause, } from "../utils/error_cause.js";
6
6
  import { runReplicateWithMetrics } from "../utils/replicate_usage.js";
7
+ import { safeFetch, FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS } from "../utils/fetch.js";
7
8
  export const lipSyncReplicateAgent = async ({ namedInputs, params, config, }) => {
8
9
  const { movieFile, audioFile, imageFile } = namedInputs;
9
10
  const apiKey = config?.apiKey;
@@ -64,7 +65,7 @@ export const lipSyncReplicateAgent = async ({ namedInputs, params, config, }) =>
64
65
  const { output, predictSec } = await runReplicateWithMetrics(replicate, model_identifier, input);
65
66
  if (output && typeof output === "object" && "url" in output) {
66
67
  const videoUrl = output.url();
67
- const videoResponse = await fetch(videoUrl);
68
+ const videoResponse = await safeFetch(videoUrl, {}, FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS);
68
69
  if (!videoResponse.ok) {
69
70
  throw new Error(`Error downloading video: ${videoResponse.status} - ${videoResponse.statusText}`, {
70
71
  cause: agentGenerationError("lipSyncReplicateAgent", imageAction, movieFileTarget),
@@ -1,6 +1,7 @@
1
1
  import { GraphAILogger } from "graphai";
2
2
  import fs from "fs";
3
3
  import { silent60secPath, mulmoCreditPath } from "../utils/file.js";
4
+ import { safeFetch, FETCH_DOWNLOAD_TIMEOUT_MS } from "../utils/fetch.js";
4
5
  export const mediaMockAgent = async ({ namedInputs }) => {
5
6
  if (namedInputs.media === "audio") {
6
7
  const buffer = fs.readFileSync(silent60secPath());
@@ -12,7 +13,7 @@ export const mediaMockAgent = async ({ namedInputs }) => {
12
13
  }
13
14
  if (namedInputs.media === "movie") {
14
15
  const url = "https://github.com/receptron/mulmocast-media/raw/refs/heads/main/test/pingpong.mov";
15
- const res = await fetch(url);
16
+ const res = await safeFetch(url, {}, FETCH_DOWNLOAD_TIMEOUT_MS);
16
17
  if (!res.ok) {
17
18
  throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`);
18
19
  }
@@ -6,9 +6,19 @@ import { getAspectRatio } from "../utils/utils.js";
6
6
  import { ffmpegGetMediaDuration } from "../utils/ffmpeg_utils.js";
7
7
  import { ASPECT_RATIOS } from "../types/const.js";
8
8
  import { getModelDuration, provider2MovieAgent, AUDIO_MODE_NEVER, AUDIO_MODE_ALWAYS } from "../types/provider2agent.js";
9
+ // Per-request timeout so a stalled GenAI video API call rejects instead of hanging.
10
+ const GENAI_REQUEST_TIMEOUT_MS = 120_000;
11
+ // Wall-clock cap on the long-running video operation poll loop (Veo runs minutes).
12
+ const VIDEO_POLL_TIMEOUT_MS = 1_200_000;
9
13
  const pollUntilDone = async (ai, operation) => {
10
14
  const response = { operation };
15
+ const deadline = Date.now() + VIDEO_POLL_TIMEOUT_MS;
11
16
  while (!response.operation.done) {
17
+ if (Date.now() > deadline) {
18
+ throw new Error(`Video generation did not complete within ${VIDEO_POLL_TIMEOUT_MS}ms`, {
19
+ cause: agentGenerationError("movieGenAIAgent", imageAction, movieFileTarget),
20
+ });
21
+ }
12
22
  await sleep(5000);
13
23
  response.operation = await ai.operations.getVideosOperation(response);
14
24
  }
@@ -190,6 +200,7 @@ export const movieGenAIAgent = async ({ namedInputs, params, config, }) => {
190
200
  vertexai: true,
191
201
  project: params.vertexai_project,
192
202
  location: params.vertexai_location ?? "us-central1",
203
+ httpOptions: { timeout: GENAI_REQUEST_TIMEOUT_MS },
193
204
  })
194
205
  : (() => {
195
206
  if (!apiKey) {
@@ -197,7 +208,7 @@ export const movieGenAIAgent = async ({ namedInputs, params, config, }) => {
197
208
  cause: apiKeyMissingError("movieGenAIAgent", imageAction, "GEMINI_API_KEY"),
198
209
  });
199
210
  }
200
- return new GoogleGenAI({ apiKey });
211
+ return new GoogleGenAI({ apiKey, httpOptions: { timeout: GENAI_REQUEST_TIMEOUT_MS } });
201
212
  })();
202
213
  // Veo 3.1: Video extension mode for videos longer than 8s
203
214
  if (model === "veo-3.1-generate-preview" && requestedDuration > 8 && params.canvasSize) {
@@ -207,11 +218,14 @@ export const movieGenAIAgent = async ({ namedInputs, params, config, }) => {
207
218
  return generateStandardVideo(ai, model, prompt, aspectRatio, imagePath, lastFrameImagePath, referenceImages, duration, movieFile, isVertexAI);
208
219
  }
209
220
  catch (error) {
210
- GraphAILogger.info("Failed to generate movie:", error.message);
211
221
  if (hasCause(error) && error.cause) {
212
222
  throw error;
213
223
  }
214
- throw new Error("Failed to generate movie with Google GenAI", {
224
+ GraphAILogger.info("Failed to generate movie:", error.message);
225
+ // Preserve the underlying message (e.g. a timeout/abort deadline) instead of
226
+ // collapsing every failure to a static label. (Same template as #1452.)
227
+ const detail = error instanceof Error ? error.message : String(error);
228
+ throw new Error(`Failed to generate movie with Google GenAI: ${detail}`, {
215
229
  cause: agentGenerationError("movieGenAIAgent", imageAction, movieFileTarget),
216
230
  });
217
231
  }
@@ -4,6 +4,7 @@ import Replicate from "replicate";
4
4
  import { apiKeyMissingError, agentGenerationError, agentInvalidResponseError, hasCause, imageAction, movieFileTarget, videoDurationTarget, unsupportedModelTarget, } from "../utils/error_cause.js";
5
5
  import { provider2MovieAgent, getModelDuration, AUDIO_MODE_OPTIONAL, AUDIO_MODE_NEVER, AUDIO_MODE_ALWAYS } from "../types/provider2agent.js";
6
6
  import { runReplicateWithMetrics } from "../utils/replicate_usage.js";
7
+ import { safeFetch, FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS } from "../utils/fetch.js";
7
8
  function replicate_get_videoUrl(output) {
8
9
  if (typeof output === "string")
9
10
  return output;
@@ -99,7 +100,7 @@ async function generateMovie(model, apiKey, prompt, imagePath, lastFrameImagePat
99
100
  // Some models return a FileOutput object with a url() method; others return a plain string URL.
100
101
  const videoUrl = replicate_get_videoUrl(output);
101
102
  if (videoUrl) {
102
- const videoResponse = await fetch(videoUrl);
103
+ const videoResponse = await safeFetch(videoUrl, {}, FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS);
103
104
  if (!videoResponse.ok) {
104
105
  throw new Error(`Error downloading video: ${videoResponse.status} - ${videoResponse.statusText}`, {
105
106
  cause: agentGenerationError("movieReplicateAgent", imageAction, movieFileTarget),
@@ -4,6 +4,7 @@ import Replicate from "replicate";
4
4
  import { provider2SoundEffectAgent } from "../types/provider2agent.js";
5
5
  import { apiKeyMissingError, agentGenerationError, imageAction, movieFileTarget, hasCause } from "../utils/error_cause.js";
6
6
  import { runReplicateWithMetrics } from "../utils/replicate_usage.js";
7
+ import { safeFetch, FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS } from "../utils/fetch.js";
7
8
  export const soundEffectReplicateAgent = async ({ namedInputs, params, config }) => {
8
9
  const { prompt, movieFile } = namedInputs;
9
10
  const apiKey = config?.apiKey;
@@ -32,7 +33,7 @@ export const soundEffectReplicateAgent = async ({ namedInputs, params, config })
32
33
  const { output, predictSec } = await runReplicateWithMetrics(replicate, model_identifier, input);
33
34
  if (output && typeof output === "object" && "url" in output) {
34
35
  const videoUrl = output.url();
35
- const videoResponse = await fetch(videoUrl);
36
+ const videoResponse = await safeFetch(videoUrl, {}, FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS);
36
37
  if (!videoResponse.ok) {
37
38
  throw new Error(`Error downloading video: ${videoResponse.status} - ${videoResponse.statusText}`, {
38
39
  cause: agentGenerationError("soundEffectReplicateAgent", imageAction, movieFileTarget),
@@ -49,7 +50,10 @@ export const soundEffectReplicateAgent = async ({ namedInputs, params, config })
49
50
  if (hasCause(error) && error.cause) {
50
51
  throw error;
51
52
  }
52
- throw new Error("Failed to generate sound effect with Replicate", {
53
+ // Preserve the underlying message (e.g. a safeFetch timeout) rather than
54
+ // collapsing every failure to a static label. (Same template as #1452.)
55
+ const detail = error instanceof Error ? error.message : String(error);
56
+ throw new Error(`Failed to generate sound effect with Replicate: ${detail}`, {
53
57
  cause: agentGenerationError("soundEffectReplicateAgent", imageAction, movieFileTarget),
54
58
  });
55
59
  }
@@ -1,6 +1,7 @@
1
1
  import { GraphAILogger } from "graphai";
2
2
  import { provider2TTSAgent } from "../types/provider2agent.js";
3
3
  import { apiKeyMissingError, agentVoiceLimitReachedError, agentIncorrectAPIKeyError, agentGenerationError, audioAction, audioFileTarget, } from "../utils/error_cause.js";
4
+ import { safeFetch, FETCH_API_TIMEOUT_MS } from "../utils/fetch.js";
4
5
  export const ttsElevenlabsAgent = async ({ namedInputs, params, config, }) => {
5
6
  const { text } = namedInputs;
6
7
  const { voice, model, stability, similarityBoost, speed, suppressError } = params;
@@ -27,7 +28,7 @@ export const ttsElevenlabsAgent = async ({ namedInputs, params, config, }) => {
27
28
  GraphAILogger.log("ElevenLabs TTS options", requestBody);
28
29
  const response = await (async () => {
29
30
  try {
30
- return await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voice}`, {
31
+ return await safeFetch(`https://api.elevenlabs.io/v1/text-to-speech/${voice}`, {
31
32
  method: "POST",
32
33
  headers: {
33
34
  Accept: "audio/mpeg",
@@ -35,7 +36,7 @@ export const ttsElevenlabsAgent = async ({ namedInputs, params, config, }) => {
35
36
  "xi-api-key": apiKey,
36
37
  },
37
38
  body: JSON.stringify(requestBody),
38
- });
39
+ }, FETCH_API_TIMEOUT_MS);
39
40
  }
40
41
  catch (e) {
41
42
  if (suppressError) {
@@ -64,7 +65,7 @@ export const ttsElevenlabsAgent = async ({ namedInputs, params, config, }) => {
64
65
  });
65
66
  }
66
67
  const errorDetail = await response.json();
67
- if (errorDetail.detail.status === "voice_limit_reached") {
68
+ if (errorDetail.detail?.status === "voice_limit_reached") {
68
69
  throw new Error("Failed to generate audio: 400 You have reached your maximum amount of custom voices with ElevenLabs", {
69
70
  cause: agentVoiceLimitReachedError("ttsElevenlabsAgent", audioAction, audioFileTarget),
70
71
  });
@@ -3,6 +3,9 @@ import { GoogleGenAI } from "@google/genai";
3
3
  import { provider2TTSAgent } from "../types/provider2agent.js";
4
4
  import { agentIncorrectAPIKeyError, apiKeyMissingError, agentGenerationError, audioAction, audioFileTarget, getGenAIErrorReason, } from "../utils/error_cause.js";
5
5
  import { pcmToMp3 } from "../utils/ffmpeg_utils.js";
6
+ // Per-request timeout so a stalled GenAI TTS call rejects (and GraphAI retry can
7
+ // recover) instead of hanging. Generous vs. normal generation time.
8
+ const GENAI_REQUEST_TIMEOUT_MS = 120_000;
6
9
  const getPrompt = (text, instructions) => {
7
10
  // https://ai.google.dev/gemini-api/docs/speech-generation?hl=ja#controllable
8
11
  if (instructions) {
@@ -21,7 +24,7 @@ export const ttsGeminiAgent = async ({ namedInputs, params, config, }) => {
21
24
  }
22
25
  const geminiResult = await (async () => {
23
26
  try {
24
- const ai = new GoogleGenAI({ apiKey });
27
+ const ai = new GoogleGenAI({ apiKey, httpOptions: { timeout: GENAI_REQUEST_TIMEOUT_MS } });
25
28
  const response = await ai.models.generateContent({
26
29
  model: model ?? provider2TTSAgent.gemini.defaultModel,
27
30
  contents: [{ parts: [{ text: getPrompt(text, instructions) }] }],
@@ -1,6 +1,7 @@
1
1
  import { GraphAILogger } from "graphai";
2
2
  import { provider2TTSAgent } from "../types/provider2agent.js";
3
3
  import { apiKeyMissingError, agentIncorrectAPIKeyError, agentGenerationError, audioAction, audioFileTarget } from "../utils/error_cause.js";
4
+ import { safeFetch, FETCH_API_TIMEOUT_MS } from "../utils/fetch.js";
4
5
  export const ttsKotodamaAgent = async ({ namedInputs, params, config, }) => {
5
6
  const { text } = namedInputs;
6
7
  const { voice, decoration, suppressError } = params;
@@ -18,14 +19,14 @@ export const ttsKotodamaAgent = async ({ namedInputs, params, config, }) => {
18
19
  audio_format: "mp3",
19
20
  };
20
21
  try {
21
- const response = await fetch(url, {
22
+ const response = await safeFetch(url, {
22
23
  method: "POST",
23
24
  headers: {
24
25
  "Content-Type": "application/json",
25
26
  "X-API-Key": apiKey,
26
27
  },
27
28
  body: JSON.stringify(body),
28
- });
29
+ }, FETCH_API_TIMEOUT_MS);
29
30
  if (!response.ok) {
30
31
  if (response.status === 401) {
31
32
  throw new Error("Failed to generate audio: 401 Incorrect API key provided with Kotodama", {
@@ -64,7 +65,10 @@ export const ttsKotodamaAgent = async ({ namedInputs, params, config, }) => {
64
65
  if (error && typeof error === "object" && "cause" in error) {
65
66
  throw error;
66
67
  }
67
- throw new Error("TTS Kotodama Error", {
68
+ // Preserve the underlying message (e.g. a safeFetch timeout) rather than
69
+ // collapsing every failure to a static label. (Same template as #1452.)
70
+ const detail = error instanceof Error ? error.message : String(error);
71
+ throw new Error(`TTS Kotodama Error: ${detail}`, {
68
72
  cause: agentGenerationError("ttsKotodamaAgent", audioAction, audioFileTarget),
69
73
  });
70
74
  }
@@ -1,5 +1,5 @@
1
1
  export * from "./mulmo_presentation_style.js";
2
2
  export * from "./mulmo_studio_context.js";
3
3
  export * from "./mulmo_media_source.js";
4
- export * from "./mulmo_beat.js";
4
+ export * from "./mulmo_beat_node.js";
5
5
  export * from "./mulmo_script.js";
@@ -1,5 +1,7 @@
1
1
  export * from "./mulmo_presentation_style.js";
2
2
  export * from "./mulmo_studio_context.js";
3
3
  export * from "./mulmo_media_source.js";
4
- export * from "./mulmo_beat.js";
4
+ // Node-only MulmoBeatMethods (includes getPlugin). The browser-safe subset lives in
5
+ // ./mulmo_beat.js and is re-exported by ../index.common.js for mulmocast/browser.
6
+ export * from "./mulmo_beat_node.js";
5
7
  export * from "./mulmo_script.js";
@@ -9,13 +9,6 @@ export declare const MulmoBeatMethods: {
9
9
  isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
10
10
  isMovieMode: (animation: unknown) => boolean;
11
11
  getHtmlPrompt(beat: MulmoBeat): string | undefined;
12
- getPlugin(beat: MulmoBeat): {
13
- imageType: string;
14
- process: (params: import("../types/type.js").ImageProcessorParams) => Promise<string | undefined> | void;
15
- path: (params: import("../types/type.js").ImageProcessorParams) => string | undefined;
16
- markdown?: (params: import("../types/type.js").ImageProcessorParams) => string | undefined;
17
- html?: (params: import("../types/type.js").ImageProcessorParams) => Promise<string | undefined>;
18
- };
19
12
  getImageReferenceForImageGenerator(beat: MulmoBeat, imageRefs: Record<string, string>): string[];
20
13
  };
21
14
  export {};
@@ -1,4 +1,3 @@
1
- import { findImagePlugin } from "../utils/image_plugins/index.js";
2
1
  /** Type guard: checks if animation value is an object config like { fps: 30 } */
3
2
  const isAnimationObject = (animation) => {
4
3
  return typeof animation === "object" && animation !== null && !Array.isArray(animation);
@@ -29,13 +28,6 @@ export const MulmoBeatMethods = {
29
28
  }
30
29
  return beat?.htmlPrompt?.prompt;
31
30
  },
32
- getPlugin(beat) {
33
- const plugin = findImagePlugin(beat?.image?.type);
34
- if (!plugin) {
35
- throw new Error(`invalid beat image type: ${beat.image}`); // TODO: cause
36
- }
37
- return plugin;
38
- },
39
31
  getImageReferenceForImageGenerator(beat, imageRefs) {
40
32
  const imageNames = beat.imageNames ?? Object.keys(imageRefs); // use all images if imageNames is not specified
41
33
  const sources = imageNames.map((name) => imageRefs[name]);
@@ -0,0 +1,22 @@
1
+ import { MulmoBeat } from "../types/index.js";
2
+ export declare const MulmoBeatMethods: {
3
+ getPlugin(beat: MulmoBeat): {
4
+ imageType: string;
5
+ process: (params: import("../types/type.js").ImageProcessorParams) => Promise<string | undefined> | void;
6
+ path: (params: import("../types/type.js").ImageProcessorParams) => string | undefined;
7
+ markdown?: (params: import("../types/type.js").ImageProcessorParams) => string | undefined;
8
+ html?: (params: import("../types/type.js").ImageProcessorParams) => Promise<string | undefined>;
9
+ };
10
+ isAnimationEnabled: (animation: unknown) => animation is true | {
11
+ fps?: number;
12
+ movie?: boolean;
13
+ };
14
+ isAnimationObject: (animation: unknown) => animation is {
15
+ fps?: number;
16
+ movie?: boolean;
17
+ };
18
+ isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
19
+ isMovieMode: (animation: unknown) => boolean;
20
+ getHtmlPrompt(beat: MulmoBeat): string | undefined;
21
+ getImageReferenceForImageGenerator(beat: MulmoBeat, imageRefs: Record<string, string>): string[];
22
+ };
@@ -0,0 +1,10 @@
1
+ import { MulmoBeatMethods as MulmoBeatMethodsBrowser } from "./mulmo_beat.js";
2
+ import { getBeatPlugin } from "../utils/image_plugins/index.js";
3
+ // Node-only extension of MulmoBeatMethods. Kept out of methods/mulmo_beat.ts so
4
+ // browser bundles (mulmocast/browser) do not pull in image_plugins → mulmocast-vision → puppeteer.
5
+ export const MulmoBeatMethods = {
6
+ ...MulmoBeatMethodsBrowser,
7
+ getPlugin(beat) {
8
+ return getBeatPlugin(beat);
9
+ },
10
+ };
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import { GraphAILogger, assert } from "graphai";
3
3
  import { getFullPath, getReferenceImagePath, resolveAssetPath } from "../utils/file.js";
4
+ import { safeFetch, DEFAULT_FETCH_TIMEOUT_MS } from "../utils/fetch.js";
4
5
  import { downLoadReferenceImageError, getTextError, imageReferenceUnknownMediaError, downloadImagePluginError, imagePluginUnknownMediaError, mediaSourceToDataUrlError, mediaSourceFileNotFoundError, mediaSourceUnknownKindError, } from "../utils/error_cause.js";
5
6
  // for image reference
6
7
  export const getExtention = (contentType, url) => {
@@ -18,7 +19,7 @@ export const getExtention = (contentType, url) => {
18
19
  return "png"; // default
19
20
  };
20
21
  const downLoadReferenceImage = async (context, key, url) => {
21
- const response = await fetch(url);
22
+ const response = await safeFetch(url);
22
23
  assert(response.ok, `Failed to download reference image: ${url}`, false, downLoadReferenceImageError(key, url));
23
24
  const buffer = Buffer.from(await response.arrayBuffer());
24
25
  // Detect file extension from Content-Type header or URL
@@ -38,26 +39,21 @@ function pluginSourceFixExtention(path, imageType) {
38
39
  return path;
39
40
  }
40
41
  // end of util
41
- const DEFAULT_FETCH_TIMEOUT_MS = 30000;
42
42
  // Convert URL to data URL (base64 encoded)
43
43
  const urlToDataUrl = async (url, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) => {
44
- const controller = new AbortController();
45
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
46
44
  try {
47
- const response = await fetch(url, { signal: controller.signal });
45
+ const response = await safeFetch(url, {}, timeoutMs);
48
46
  assert(response.ok, `Failed to fetch: ${url}`, false, mediaSourceToDataUrlError(url));
49
47
  const buffer = Buffer.from(await response.arrayBuffer());
50
48
  const contentType = response.headers.get("content-type") || "image/png";
51
49
  return `data:${contentType};base64,${buffer.toString("base64")}`;
52
50
  }
53
51
  catch (error) {
54
- if (error instanceof Error && error.name === "AbortError") {
55
- throw new Error(`Fetch timeout: ${url}`, { cause: mediaSourceToDataUrlError(url) });
52
+ // assert() failures already carry the structured cause — keep it as-is.
53
+ if (error instanceof Error && error.cause) {
54
+ throw error;
56
55
  }
57
- throw new Error(`Fetch failed: ${url}`, { cause: mediaSourceToDataUrlError(url) });
58
- }
59
- finally {
60
- clearTimeout(timeoutId);
56
+ throw new Error(`Fetch failed: ${url}: ${error instanceof Error ? error.message : String(error)}`, { cause: mediaSourceToDataUrlError(url) });
61
57
  }
62
58
  };
63
59
  /** Map file extension to MIME type for data URLs */
@@ -92,7 +88,7 @@ export const MulmoMediaSourceMethods = {
92
88
  return mediaSource.text;
93
89
  }
94
90
  if (mediaSource.kind === "url") {
95
- const response = await fetch(mediaSource.url);
91
+ const response = await safeFetch(mediaSource.url);
96
92
  assert(response.ok, `Failed to download mermaid code text: ${mediaSource.url}`, false, getTextError(mediaSource.url)); // TODO: index
97
93
  return await response.text();
98
94
  }
@@ -126,7 +122,7 @@ export const MulmoMediaSourceMethods = {
126
122
  },
127
123
  async imagePluginSource(mediaSource, context, expectImagePath, imageType) {
128
124
  if (mediaSource.kind === "url") {
129
- const response = await fetch(mediaSource.url);
125
+ const response = await safeFetch(mediaSource.url);
130
126
  assert(response.ok, `Failed to download image plugin: ${imageType} ${mediaSource.url}`, false, downloadImagePluginError(mediaSource.url, imageType)); // TODO: key, id, index
131
127
  const buffer = Buffer.from(await response.arrayBuffer());
132
128
  // Detect file extension from Content-Type header or URL
@@ -423,8 +423,16 @@ export const audioParamsSchema = z
423
423
  .object({
424
424
  padding: z.number().optional().default(0.3).describe("Padding between beats"), // seconds
425
425
  introPadding: z.number().optional().default(1.0).describe("Padding at the beginning of the audio"), // seconds
426
- closingPadding: z.number().optional().default(0.8).describe("Padding before the last beat"), // seconds
427
- outroPadding: z.number().optional().default(1.0).describe("Padding at the end of the audio"), // seconds
426
+ closingPadding: z
427
+ .number()
428
+ .optional()
429
+ .default(0.8)
430
+ .describe("Padding before the last beat (typically the closing credit). For padding after the last beat, use outroPadding"), // seconds
431
+ outroPadding: z
432
+ .number()
433
+ .optional()
434
+ .default(1.0)
435
+ .describe("Padding after the last beat, at the very end of the audio where the BGM fades out (distinct from closingPadding, which comes before the last beat)"), // seconds
428
436
  bgm: mediaSourceSchema.optional(),
429
437
  bgmVolume: z.number().optional().default(0.2).describe("Volume of the background music"),
430
438
  audioVolume: z.number().optional().default(1.0).describe("Volume of the audio"),
@@ -0,0 +1,28 @@
1
+ export declare const DEFAULT_FETCH_TIMEOUT_MS = 30000;
2
+ export declare const FETCH_DOWNLOAD_TIMEOUT_MS = 60000;
3
+ export declare const FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS = 180000;
4
+ export declare const FETCH_API_TIMEOUT_MS = 120000;
5
+ export type SafeFetchResponse = {
6
+ ok: boolean;
7
+ status: number;
8
+ statusText: string;
9
+ headers: {
10
+ get(name: string): string | null;
11
+ };
12
+ arrayBuffer(): Promise<ArrayBuffer>;
13
+ text(): Promise<string>;
14
+ json<T = unknown>(): Promise<T>;
15
+ };
16
+ /**
17
+ * fetch() with an AbortController timeout that covers the whole exchange —
18
+ * headers AND body.
19
+ *
20
+ * A stalled connection otherwise yields a promise that never resolves or
21
+ * rejects; callers relying on GraphAI `retry` never recover because retry only
22
+ * fires on rejection. The body is read once under the same deadline (a server
23
+ * that sends headers and then stalls mid-body would otherwise hang the caller's
24
+ * body read), and the bytes are handed back through a lightweight result so the
25
+ * caller does not buffer them a second time. An optional caller `signal` is
26
+ * composed with the timeout so upstream cancellation still works.
27
+ */
28
+ export declare const safeFetch: (url: string | URL, init?: Parameters<typeof fetch>[1], timeoutMs?: number) => Promise<SafeFetchResponse>;
@@ -0,0 +1,52 @@
1
+ // Timeout budgets for the different kinds of network waits. Generous safety
2
+ // nets against a stalled socket — NOT tight deadlines — so legitimate slow
3
+ // responses still succeed while a genuine hang becomes a rejection.
4
+ export const DEFAULT_FETCH_TIMEOUT_MS = 30_000; // small assets (reference/background images, JSON)
5
+ export const FETCH_DOWNLOAD_TIMEOUT_MS = 60_000; // generated image download
6
+ export const FETCH_MEDIA_DOWNLOAD_TIMEOUT_MS = 180_000; // large video/audio download
7
+ export const FETCH_API_TIMEOUT_MS = 120_000; // generation API POST (e.g. TTS text -> audio)
8
+ /**
9
+ * fetch() with an AbortController timeout that covers the whole exchange —
10
+ * headers AND body.
11
+ *
12
+ * A stalled connection otherwise yields a promise that never resolves or
13
+ * rejects; callers relying on GraphAI `retry` never recover because retry only
14
+ * fires on rejection. The body is read once under the same deadline (a server
15
+ * that sends headers and then stalls mid-body would otherwise hang the caller's
16
+ * body read), and the bytes are handed back through a lightweight result so the
17
+ * caller does not buffer them a second time. An optional caller `signal` is
18
+ * composed with the timeout so upstream cancellation still works.
19
+ */
20
+ export const safeFetch = async (url, init = {}, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) => {
21
+ const controller = new AbortController();
22
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
23
+ const externalSignal = init?.signal ?? undefined;
24
+ const forwardAbort = () => controller.abort();
25
+ externalSignal?.addEventListener("abort", forwardAbort, { once: true });
26
+ if (externalSignal?.aborted)
27
+ controller.abort();
28
+ try {
29
+ const response = await fetch(url, { ...init, signal: controller.signal });
30
+ const body = await response.arrayBuffer();
31
+ const decode = () => new TextDecoder().decode(body);
32
+ return {
33
+ ok: response.ok,
34
+ status: response.status,
35
+ statusText: response.statusText,
36
+ headers: response.headers,
37
+ arrayBuffer: async () => body,
38
+ text: async () => decode(),
39
+ json: async () => JSON.parse(decode()),
40
+ };
41
+ }
42
+ catch (error) {
43
+ if (error instanceof Error && error.name === "AbortError") {
44
+ throw new Error(`Fetch timeout after ${timeoutMs}ms: ${url}`);
45
+ }
46
+ throw error;
47
+ }
48
+ finally {
49
+ clearTimeout(timeoutId);
50
+ externalSignal?.removeEventListener("abort", forwardAbort);
51
+ }
52
+ };
package/lib/utils/file.js CHANGED
@@ -8,6 +8,7 @@ import { MulmoStudioContextMethods } from "../methods/index.js";
8
8
  import { mulmoPromptTemplateSchema } from "../types/schema.js";
9
9
  import { getMulmoScriptTemplateSystemPrompt } from "./prompt.js";
10
10
  import { resolveAssetFile } from "./asset_import.js";
11
+ import { safeFetch } from "./fetch.js";
11
12
  const promptTemplateDirName = "./assets/templates";
12
13
  const scriptTemplateDirName = "./scripts/templates";
13
14
  const __filename = fileURLToPath(import.meta.url);
@@ -43,7 +44,7 @@ export function readMulmoScriptFile(arg2, errorMessage) {
43
44
  }
44
45
  export const fetchMulmoScriptFile = async (url) => {
45
46
  try {
46
- const res = await fetch(url);
47
+ const res = await safeFetch(url);
47
48
  if (!res.ok) {
48
49
  return { result: false, status: res.status };
49
50
  }
@@ -1,6 +1,6 @@
1
1
  import { MulmoMediaSourceMethods } from "../../methods/mulmo_media_source.js";
2
2
  import { resolveStyle } from "./utils.js";
3
- const DEFAULT_FETCH_TIMEOUT_MS = 30000;
3
+ import { safeFetch, DEFAULT_FETCH_TIMEOUT_MS } from "../fetch.js";
4
4
  /**
5
5
  * Resolve background image from beat level and global level settings.
6
6
  * Beat level takes precedence over global level.
@@ -25,26 +25,13 @@ export const resolveBackgroundImage = (beatBackgroundImage, globalBackgroundImag
25
25
  * Fetch URL and convert to data URL with timeout
26
26
  */
27
27
  const fetchUrlAsDataUrl = async (url, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) => {
28
- const controller = new AbortController();
29
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
30
- try {
31
- const response = await fetch(url, { signal: controller.signal });
32
- if (!response.ok) {
33
- throw new Error(`Failed to fetch background image: ${url} (${response.status})`);
34
- }
35
- const buffer = Buffer.from(await response.arrayBuffer());
36
- const contentType = response.headers.get("content-type") || "image/png";
37
- return `data:${contentType};base64,${buffer.toString("base64")}`;
38
- }
39
- catch (error) {
40
- if (error instanceof Error && error.name === "AbortError") {
41
- throw new Error(`Fetch timeout for background image: ${url}`);
42
- }
43
- throw error;
44
- }
45
- finally {
46
- clearTimeout(timeoutId);
28
+ const response = await safeFetch(url, {}, timeoutMs);
29
+ if (!response.ok) {
30
+ throw new Error(`Failed to fetch background image: ${url} (${response.status})`);
47
31
  }
32
+ const buffer = Buffer.from(await response.arrayBuffer());
33
+ const contentType = response.headers.get("content-type") || "image/png";
34
+ return `data:${contentType};base64,${buffer.toString("base64")}`;
48
35
  };
49
36
  /**
50
37
  * Convert BackgroundImage to CSS string
@@ -1,4 +1,4 @@
1
- import { ImageProcessorParams } from "../../types/index.js";
1
+ import { ImageProcessorParams, MulmoBeat } from "../../types/index.js";
2
2
  export declare const findImagePlugin: (imageType?: string) => {
3
3
  imageType: string;
4
4
  process: (params: ImageProcessorParams) => Promise<string | undefined> | void;
@@ -6,3 +6,10 @@ export declare const findImagePlugin: (imageType?: string) => {
6
6
  markdown?: (params: ImageProcessorParams) => string | undefined;
7
7
  html?: (params: ImageProcessorParams) => Promise<string | undefined>;
8
8
  } | undefined;
9
+ export declare const getBeatPlugin: (beat: MulmoBeat) => {
10
+ imageType: string;
11
+ process: (params: ImageProcessorParams) => Promise<string | undefined> | void;
12
+ path: (params: ImageProcessorParams) => string | undefined;
13
+ markdown?: (params: ImageProcessorParams) => string | undefined;
14
+ html?: (params: ImageProcessorParams) => Promise<string | undefined>;
15
+ };
@@ -25,3 +25,10 @@ const imagePlugins = [
25
25
  export const findImagePlugin = (imageType) => {
26
26
  return imagePlugins.find((plugin) => plugin.imageType === imageType);
27
27
  };
28
+ export const getBeatPlugin = (beat) => {
29
+ const plugin = findImagePlugin(beat?.image?.type);
30
+ if (!plugin) {
31
+ throw new Error(`invalid beat image type: ${beat.image}`);
32
+ }
33
+ return plugin;
34
+ };
@@ -1,4 +1,8 @@
1
1
  import OpenAI, { AzureOpenAI } from "openai";
2
+ // Explicit per-request timeout so a stalled connection rejects (and the SDK's
3
+ // default maxRetries=2 re-issues it) instead of hanging on the SDK's 10-minute
4
+ // default. gpt-image-1 generation is typically well under this.
5
+ const OPENAI_REQUEST_TIMEOUT_MS = 120_000;
2
6
  /**
3
7
  * Detects if the given URL is an Azure OpenAI endpoint
4
8
  * Safely parses the URL and checks if the hostname ends with ".openai.azure.com"
@@ -26,10 +30,12 @@ export const createOpenAIClient = (options) => {
26
30
  apiKey,
27
31
  endpoint: baseURL,
28
32
  apiVersion: apiVersion ?? "2025-04-01-preview",
33
+ timeout: OPENAI_REQUEST_TIMEOUT_MS,
29
34
  });
30
35
  }
31
36
  return new OpenAI({
32
37
  apiKey,
33
38
  baseURL,
39
+ timeout: OPENAI_REQUEST_TIMEOUT_MS,
34
40
  });
35
41
  };
@@ -1,5 +1,8 @@
1
1
  import type Replicate from "replicate";
2
- export declare const runReplicateWithMetrics: (replicate: Replicate, identifier: `${string}/${string}` | `${string}/${string}:${string}`, input: object, signal?: AbortSignal) => Promise<{
2
+ export declare const runReplicateWithMetrics: (replicate: Replicate, identifier: `${string}/${string}` | `${string}/${string}:${string}`, input: object, options?: {
3
+ timeoutMs?: number;
4
+ signal?: AbortSignal;
5
+ }) => Promise<{
3
6
  output: unknown;
4
7
  predictSec?: number;
5
8
  }>;
@@ -1,11 +1,31 @@
1
+ // Generous safety net against a stalled run — video models (seedance/kling)
2
+ // legitimately run for minutes — not a tight deadline.
3
+ const REPLICATE_RUN_TIMEOUT_MS = 600_000;
1
4
  // Capture Replicate's exact billed seconds (`metrics.predict_time`) via the
2
5
  // progress callback on `replicate.run()`. Avoids switching the four
3
6
  // replicate agents from `.run()` to `predictions.create()` + poll, which
4
7
  // would be a much bigger blast radius.
5
- export const runReplicateWithMetrics = async (replicate, identifier, input, signal) => {
8
+ //
9
+ // A timeout aborts the run so a stalled job rejects (and the caller's GraphAI
10
+ // `retry` can recover) instead of hanging forever. An optional caller `signal`
11
+ // is composed with the timeout so upstream cancellation still works.
12
+ export const runReplicateWithMetrics = async (replicate, identifier, input, options = {}) => {
13
+ const { timeoutMs = REPLICATE_RUN_TIMEOUT_MS, signal: externalSignal } = options;
14
+ const controller = new AbortController();
15
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
16
+ const forwardAbort = () => controller.abort();
17
+ externalSignal?.addEventListener("abort", forwardAbort, { once: true });
18
+ if (externalSignal?.aborted)
19
+ controller.abort();
6
20
  let lastPrediction;
7
- const output = await replicate.run(identifier, { input, signal }, (prediction) => {
8
- lastPrediction = prediction;
9
- });
10
- return { output, predictSec: lastPrediction?.metrics?.predict_time };
21
+ try {
22
+ const output = await replicate.run(identifier, { input, signal: controller.signal }, (prediction) => {
23
+ lastPrediction = prediction;
24
+ });
25
+ return { output, predictSec: lastPrediction?.metrics?.predict_time };
26
+ }
27
+ finally {
28
+ clearTimeout(timeoutId);
29
+ externalSignal?.removeEventListener("abort", forwardAbort);
30
+ }
11
31
  };
@@ -0,0 +1,3 @@
1
+ export declare const GENAI_REQUEST_TIMEOUT_MS = 120000;
2
+ export declare const REPLICATE_RUN_TIMEOUT_MS = 600000;
3
+ export declare const VIDEO_POLL_TIMEOUT_MS = 1200000;
@@ -0,0 +1,7 @@
1
+ // Timeout budgets for provider SDK calls (OpenAI has its own in openai_client.ts).
2
+ // Generous safety nets against a stalled connection or a never-completing
3
+ // operation — NOT tight deadlines. A stall becomes a rejection so the caller's
4
+ // GraphAI `retry` can recover instead of hanging forever.
5
+ export const GENAI_REQUEST_TIMEOUT_MS = 120_000; // per @google/genai API call (kick-off / each poll)
6
+ export const REPLICATE_RUN_TIMEOUT_MS = 600_000; // whole replicate.run() job (video models run minutes)
7
+ export const VIDEO_POLL_TIMEOUT_MS = 1_200_000; // wall-clock cap for a long-running Veo video operation
package/lib/utils/zip.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import fs from "fs";
2
- import archiver from "archiver";
2
+ import { ZipArchive } from "archiver";
3
3
  import path from "path";
4
4
  /**
5
5
  * Promise-based ZIP archive builder
@@ -18,7 +18,7 @@ export class ZipBuilder {
18
18
  constructor(outputPath) {
19
19
  this.outputPath = outputPath;
20
20
  this.output = fs.createWriteStream(outputPath);
21
- this.archive = archiver("zip", {
21
+ this.archive = new ZipArchive({
22
22
  zlib: { level: 9 }, // Maximum compression level
23
23
  });
24
24
  // Pipe archive data to the file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.7.0",
3
+ "version": "2.7.2",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -106,7 +106,7 @@
106
106
  "@mozilla/readability": "^0.6.0",
107
107
  "@mulmocast/deck": "^1.1.0",
108
108
  "@tavily/core": "^0.7.6",
109
- "archiver": "^7.0.1",
109
+ "archiver": "^8.0.0",
110
110
  "clipboardy": "^5.3.1",
111
111
  "dotenv": "^17.4.2",
112
112
  "graphai": "^2.0.18",
@@ -124,20 +124,20 @@
124
124
  "devDependencies": {
125
125
  "@eslint/js": "^10.0.1",
126
126
  "@receptron/test_utils": "^2.0.3",
127
- "@types/archiver": "^7.0.0",
127
+ "@types/archiver": "^8.0.0",
128
128
  "@types/jsdom": "^28.0.3",
129
129
  "@types/yargs": "^17.0.35",
130
130
  "cross-env": "^10.1.0",
131
- "eslint": "^10.6.0",
131
+ "eslint": "^10.7.0",
132
132
  "eslint-config-prettier": "^10.1.8",
133
133
  "eslint-plugin-import": "^2.32.0",
134
134
  "eslint-plugin-prettier": "^5.5.6",
135
135
  "eslint-plugin-sonarjs": "^4.1.0",
136
136
  "globals": "^17.7.0",
137
- "prettier": "^3.9.4",
138
- "tsx": "^4.22.4",
137
+ "prettier": "^3.9.5",
138
+ "tsx": "^4.23.0",
139
139
  "typescript": "6.0.3",
140
- "typescript-eslint": "^8.62.1"
140
+ "typescript-eslint": "^8.63.0"
141
141
  },
142
142
  "engines": {
143
143
  "node": ">=22.0.0"