mulmocast 2.11.0 → 2.12.1

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.
@@ -1,9 +1,21 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { isNull } from "graphai";
4
+ import { escapeHtml, slideUtilityCss } from "@mulmocast/deck";
4
5
  import { localizedText } from "../utils/utils.js";
5
6
  import { writingMessage } from "../utils/file.js";
6
7
  import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
8
+ /**
9
+ * The HTML document for a studio.
10
+ *
11
+ * Every value that reaches an attribute or `<title>` comes from the script, and a beat with a
12
+ * `source: { kind: "path" }` image puts the author's own path into `src`, so each one is
13
+ * escaped for the context it lands in.
14
+ *
15
+ * Not exported: `actions/index.ts` re-exports this module with `export *` and `index.node.ts`
16
+ * re-exports that, so anything exported here becomes public API of the published package. The
17
+ * tests drive it through `html()` instead.
18
+ */
7
19
  const generateHtmlContent = (context, imageWidth) => {
8
20
  const { studio, multiLingual, lang = "en" } = context;
9
21
  const title = studio.script.title || "MulmoCast Content";
@@ -20,11 +32,14 @@ const generateHtmlContent = (context, imageWidth) => {
20
32
  html += `${studioBeat.html}\n\n`;
21
33
  }
22
34
  else if (studioBeat?.imageFile && isNull(studioBeat.html)) {
23
- const imagePath = path.relative(context.fileDirs.outDirPath, studioBeat.imageFile);
24
- const altText = `Beat ${index + 1}`;
35
+ // A `source: { kind: "path" }` beat puts the author's own path here, so it reaches an
36
+ // HTML attribute unfiltered unless it is escaped. Every value in these attributes goes
37
+ // through the same rule rather than each being argued safe on its own.
38
+ const imagePath = escapeHtml(path.relative(context.fileDirs.outDirPath, studioBeat.imageFile));
39
+ const altText = escapeHtml(`Beat ${index + 1}`);
25
40
  if (imageWidth) {
26
41
  // Use HTML img tag for width control
27
- html += `<img src="${imagePath}" alt="${altText}" width="${imageWidth}" />\n\n`;
42
+ html += `<img src="${imagePath}" alt="${altText}" width="${escapeHtml(imageWidth)}" />\n\n`;
28
43
  }
29
44
  else {
30
45
  // Use standard html image syntax
@@ -42,13 +57,16 @@ const generateHtmlContent = (context, imageWidth) => {
42
57
  <head>
43
58
  <meta charset="UTF-8" />
44
59
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
45
- <title>${title}</title>
60
+ <title>${escapeHtml(title)}</title>
46
61
  <!-- Tailwind CSS CDN -->
47
62
  <script src="https://cdn.tailwindcss.com"></script>
48
63
  <!-- Chart.js CDN -->
49
64
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
50
65
  <!-- Mermaid CDN -->
51
66
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
67
+ <!-- The utilities every slide fragment is written against. Shared, so it belongs to the
68
+ page: a slide beat emits only its own scoped rules. -->
69
+ <style>${slideUtilityCss}</style>
52
70
  </head>
53
71
  <body class="min-h-screen flex flex-col">
54
72
  ${html}
@@ -3,6 +3,7 @@ import { localizedText } from "../utils/utils.js";
3
3
  import { writingMessage } from "../utils/file.js";
4
4
  import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
5
5
  import path from "path";
6
+ import { escapeHtml } from "@mulmocast/deck";
6
7
  const generateMarkdownContent = (context, imageWidth) => {
7
8
  const { studio, multiLingual, lang = "en" } = context;
8
9
  const title = studio.script.title || "MulmoCast Content";
@@ -21,9 +22,11 @@ const generateMarkdownContent = (context, imageWidth) => {
21
22
  else if (studioBeat?.imageFile) {
22
23
  const imagePath = path.relative(context.fileDirs.outDirPath, studioBeat.imageFile);
23
24
  if (imageWidth) {
24
- // Use HTML img tag for width control
25
+ // An HTML tag is HTML wherever the markdown is rendered, and a
26
+ // `source: { kind: "path" }` beat puts the author's own path here — the same shape
27
+ // and the same input as the `<img>` in actions/html.ts.
25
28
  const altText = `Beat ${index + 1}`;
26
- markdown += `<img src="${imagePath}" alt="${altText}" width="${imageWidth}" />\n\n`;
29
+ markdown += `<img src="${escapeHtml(imagePath)}" alt="${escapeHtml(altText)}" width="${escapeHtml(imageWidth)}" />\n\n`;
27
30
  }
28
31
  else {
29
32
  // Use standard markdown image syntax
package/lib/mcp/server.js CHANGED
@@ -15,8 +15,10 @@ import { MulmoScriptMethods } from "../methods/index.js";
15
15
  dotenv.config({ quiet: true });
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = path.dirname(__filename);
18
- // Load MulmoScript JSON Schema from file
19
- const MULMO_SCRIPT_JSON_SCHEMA = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../assets/schemas/html_prompt.json"), "utf8"));
18
+ // The `mulmoScript` argument's shape, as advertised to an MCP client. It is html_prompt.json,
19
+ // not mulmo_script.json — the switch was deliberate, but the old name outlived it and is why
20
+ // nobody noticed that mulmo_script.json had no reader left for fourteen months.
21
+ const HTML_PROMPT_JSON_SCHEMA = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../assets/schemas/html_prompt.json"), "utf8"));
20
22
  const server = new Server({
21
23
  name: "mulmocast-mcp",
22
24
  version: "0.1.0",
@@ -53,7 +55,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
53
55
  enum: ["movie", "pdf"],
54
56
  description: "Command to execute: 'movie' to generate video, 'pdf' to generate PDF",
55
57
  },
56
- mulmoScript: MULMO_SCRIPT_JSON_SCHEMA,
58
+ mulmoScript: HTML_PROMPT_JSON_SCHEMA,
57
59
  options: {
58
60
  type: "object",
59
61
  description: "Optional generation parameters",
@@ -21,7 +21,14 @@ export type BeatHtmlFragment = {
21
21
  * since it has no DOM and must stay dependency-light to remain bundleable.
22
22
  */
23
23
  html: string;
24
- /** Rules the markup needs, scoped by the caller. Absent when the markup needs none. */
24
+ /**
25
+ * Rules the markup needs, scoped by the caller. Absent when the markup needs none.
26
+ *
27
+ * It cannot carry everything: an `html_tailwind` beat written with `elements` is a root
28
+ * asking for 100% of its parent, and how tall that parent should be is the host's layout,
29
+ * not the beat's. The root is marked `data-mulmo-swipe-root` so a host can give it one —
30
+ * and a host that does not gets an empty box, silently.
31
+ */
25
32
  css?: string;
26
33
  /** External runtimes the host must load once for the page, not once per beat. */
27
34
  requires?: BeatRuntime[];
@@ -11,4 +11,6 @@
11
11
  */
12
12
  /** Neutralize the output of JSON.stringify for embedding inside an inline `<script>`. */
13
13
  export declare const escapeJsonForScript: (json: string) => string;
14
+ /** Neutralize a value placed inside a quoted CSS string, such as the argument of `url()`. */
15
+ export declare const escapeCssString: (value: string) => string;
14
16
  export declare const neutralizeStyleTerminator: (css: string) => string;
@@ -22,6 +22,22 @@ const SCRIPT_JSON_ESCAPES = new Map([
22
22
  ]);
23
23
  /** Neutralize the output of JSON.stringify for embedding inside an inline `<script>`. */
24
24
  export const escapeJsonForScript = (json) => json.replace(/[<>&\u2028\u2029]/g, (character) => SCRIPT_JSON_ESCAPES.get(character) ?? character);
25
+ // A CSS string ends at the first unescaped quote of the kind that opened it, and a raw
26
+ // newline ends the declaration outright, so a value carrying either escapes the string it
27
+ // was placed in. Backslash is what CSS defines for both; a line break becomes its hex escape
28
+ // with the terminating space CSS requires. Both quote characters are escaped so a caller may
29
+ // use either, and every other character — including the base64 body of a data URL — is left
30
+ // exactly as it was.
31
+ const CSS_STRING_ESCAPES = new Map([
32
+ ["\\", "\\\\"],
33
+ ["'", "\\'"],
34
+ ['"', '\\"'],
35
+ ["\n", "\\a "],
36
+ ["\r", "\\d "],
37
+ ["\f", "\\c "],
38
+ ]);
39
+ /** Neutralize a value placed inside a quoted CSS string, such as the argument of `url()`. */
40
+ export const escapeCssString = (value) => value.replace(/[\\'"\n\r\f]/g, (character) => CSS_STRING_ESCAPES.get(character) ?? character);
25
41
  // A <style> element holds raw text: nothing inside it is parsed as markup except the closing
26
42
  // tag, so `</style` is the only sequence that can end it. Neutralizing just that keeps
27
43
  // author-supplied CSS working, which escaping the whole string would not. `\3c ` is the CSS
@@ -1,7 +1,7 @@
1
1
  import { MulmoMediaSourceMethods } from "../../methods/mulmo_media_source.js";
2
2
  import { resolveStyle } from "./utils.js";
3
3
  import { safeFetch, DEFAULT_FETCH_TIMEOUT_MS } from "../fetch.js";
4
- import { neutralizeStyleTerminator } from "../html_escape.js";
4
+ import { escapeCssString, neutralizeStyleTerminator } from "../html_escape.js";
5
5
  /**
6
6
  * Resolve background image from beat level and global level settings.
7
7
  * Beat level takes precedence over global level.
@@ -58,6 +58,8 @@ export const backgroundImageToCSS = async (backgroundImage, context) => {
58
58
  return "";
59
59
  }
60
60
  const isSimpleUrl = typeof backgroundImage === "string";
61
+ // Escaped at each use rather than here: the base64 body cannot carry a quote, but the
62
+ // content-type in front of it is whatever the remote server's response header said.
61
63
  const imageUrl = isSimpleUrl ? await fetchUrlAsDataUrl(backgroundImage) : await MulmoMediaSourceMethods.toDataUrl(backgroundImage.source, context);
62
64
  const size = sizeToCSS(isSimpleUrl ? "cover" : (backgroundImage.size ?? "cover"));
63
65
  const opacity = isSimpleUrl ? 1 : (backgroundImage.opacity ?? 1);
@@ -75,7 +77,7 @@ export const backgroundImageToCSS = async (backgroundImage, context) => {
75
77
  left: 0;
76
78
  right: 0;
77
79
  bottom: 0;
78
- background-image: url('${imageUrl}');
80
+ background-image: url('${escapeCssString(imageUrl)}');
79
81
  background-size: ${size};
80
82
  background-position: center;
81
83
  background-repeat: no-repeat;
@@ -90,7 +92,7 @@ export const backgroundImageToCSS = async (backgroundImage, context) => {
90
92
  margin: 0;
91
93
  }
92
94
  body {
93
- background-image: url('${imageUrl}');
95
+ background-image: url('${escapeCssString(imageUrl)}');
94
96
  background-size: ${size};
95
97
  background-position: center;
96
98
  background-repeat: no-repeat;
@@ -1,6 +1,6 @@
1
1
  import nodePath from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
- import { generateSlideHTML } from "@mulmocast/deck";
3
+ import { generateSlideFragment, generateSlideHTML } from "@mulmocast/deck";
4
4
  import { renderHTMLToImage } from "../html_render.js";
5
5
  import { parrotingImagePath } from "./utils.js";
6
6
  import { pathToDataUrl } from "../../methods/mulmo_media_source.js";
@@ -170,6 +170,24 @@ const processSlide = async (params) => {
170
170
  await renderHTMLToImage(html, imagePath, canvasSize.width, canvasSize.height);
171
171
  return imagePath;
172
172
  };
173
+ /**
174
+ * A slide beat as markup for the export document, not as a document of its own.
175
+ *
176
+ * `generateSlideHTML` above is right for the PNG path, where the slide IS the page. Here the
177
+ * result is concatenated into one `<body>` alongside every other beat, so a full document per
178
+ * slide meant N Tailwind CDN loads, N `tailwind.config` assignments racing each other, and an
179
+ * `html, body { overflow: hidden }` escaping into the export page — which made a document of
180
+ * stacked slides unscrollable. `beat_html/slide.ts` already draws the browser path from
181
+ * `generateSlideFragment`; this is the same change for the dump path.
182
+ *
183
+ * The fragment's own css is emitted next to it because it is confined to `scopeClass`, which
184
+ * deck makes unique per call. What is SHARED — Tailwind, the chart and mermaid runtimes,
185
+ * `slideUtilityCss` — belongs to the page and is `actions/html.ts`'s job, once.
186
+ *
187
+ * The box is here rather than left to the host: the fragment is `w-full h-full`, so without
188
+ * one it collapses to nothing, and `actions/html.ts` concatenates beats without knowing which
189
+ * of them is a slide.
190
+ */
173
191
  const dumpHtml = async (params) => {
174
192
  const { beat } = params;
175
193
  if (!beat.image || beat.image.type !== imageType)
@@ -178,7 +196,8 @@ const dumpHtml = async (params) => {
178
196
  const slide = resolveSlide(params);
179
197
  const reference = beat.image.reference;
180
198
  const resolvedBranding = await resolveAndConvertBranding(params);
181
- return generateSlideHTML(theme, slide, reference, resolvedBranding);
199
+ const fragment = generateSlideFragment(theme, slide, { reference, branding: resolvedBranding });
200
+ return `<style>${fragment.css}</style>\n<div style="aspect-ratio: 16 / 9;">${fragment.html}</div>`;
182
201
  };
183
202
  export const process = processSlide;
184
203
  export const path = parrotingImagePath;
@@ -67,6 +67,13 @@ export interface SwipeElement {
67
67
  * this a sound narrowing rather than a cast wearing a guard's clothes.
68
68
  */
69
69
  export declare const isSwipeElements: (value: unknown) => value is SwipeElement[];
70
+ /**
71
+ * The root the elements are positioned inside. Named so a browser host can give it a height:
72
+ * the root asks for 100% of its parent, and an inline height wins over any rule aimed at the
73
+ * root itself, so the parent is where the box has to come from. The document path has a
74
+ * viewport to fill and needs nothing.
75
+ */
76
+ export declare const SWIPE_ROOT_ATTRIBUTE = "data-mulmo-swipe-root";
70
77
  /** Generate HTML from Swipe elements */
71
78
  export declare const swipeElementsToHtml: (elements: SwipeElement[]) => string;
72
79
  /** Generate render() script from Swipe element animations */
@@ -113,10 +113,17 @@ const elementToHtml = (el, index) => {
113
113
  lines.push("</div>");
114
114
  return lines.join("\n");
115
115
  };
116
+ /**
117
+ * The root the elements are positioned inside. Named so a browser host can give it a height:
118
+ * the root asks for 100% of its parent, and an inline height wins over any rule aimed at the
119
+ * root itself, so the parent is where the box has to come from. The document path has a
120
+ * viewport to fill and needs nothing.
121
+ */
122
+ export const SWIPE_ROOT_ATTRIBUTE = "data-mulmo-swipe-root";
116
123
  /** Generate HTML from Swipe elements */
117
124
  export const swipeElementsToHtml = (elements) => {
118
125
  const html = elements.map((el, i) => elementToHtml(el, i)).join("\n");
119
- return `<div style="position:relative; width:100%; height:100%; overflow:hidden;">\n${html}\n</div>`;
126
+ return `<div ${SWIPE_ROOT_ATTRIBUTE} style="position:relative; width:100%; height:100%; overflow:hidden;">\n${html}\n</div>`;
120
127
  };
121
128
  const collectAnimations = (elements, entries, indexBase = 0) => {
122
129
  elements.forEach((el, i) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.11.0",
3
+ "version": "2.12.1",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",
@@ -66,6 +66,7 @@
66
66
  "scripting": "npx tsx ./src/cli/bin.ts tool scripting",
67
67
  "prompt": "npx tsx ./src/cli/bin.ts tool prompt",
68
68
  "schema": "npx tsx ./src/cli/bin.ts tool schema",
69
+ "schema:write": "npx tsx ./src/cli/bin.ts tool schema > assets/schemas/mulmo_script.json",
69
70
  "markdown": "npx tsx ./src/cli/bin.ts markdown",
70
71
  "html": "npx tsx ./src/cli/bin.ts html",
71
72
  "story_to_script": "npx tsx ./src/cli/bin.ts tool story_to_script",
@@ -100,46 +101,47 @@
100
101
  "@graphai/stream_agent_filter": "^2.0.3",
101
102
  "@graphai/vanilla": "^2.0.12",
102
103
  "@graphai/vanilla_node_agents": "^2.0.4",
103
- "@inquirer/input": "^5.1.2",
104
- "@inquirer/select": "^5.2.1",
104
+ "@inquirer/input": "^5.1.6",
105
+ "@inquirer/select": "^5.2.5",
105
106
  "@modelcontextprotocol/sdk": "^1.29.0",
106
107
  "@modernized/fluent-ffmpeg": "^1.0.3",
107
108
  "@mozilla/readability": "^0.6.0",
108
- "@mulmocast/deck": "^2.0.0",
109
- "@tavily/core": "^0.7.6",
109
+ "@mulmocast/deck": "^2.0.2",
110
+ "@tavily/core": "^0.7.10",
110
111
  "archiver": "^8.0.0",
111
112
  "clipboardy": "^5.3.2",
112
113
  "dotenv": "^17.4.2",
113
114
  "graphai": "^2.0.18",
114
115
  "js-tiktoken": "^1.0.21",
115
116
  "jsdom": "^29.1.1",
116
- "marked": "^18.0.10",
117
+ "marked": "^18.0.13",
117
118
  "mulmocast-vision": "^1.0.12",
118
119
  "ora": "^9.4.1",
119
- "puppeteer": "^25.8.0",
120
+ "puppeteer": "^25.11.0",
120
121
  "replicate": "^1.4.0",
121
- "yaml": "^2.9.0",
122
+ "yaml": "^2.9.1",
122
123
  "yargs": "^18.1.0",
123
- "zod": "^4.4.3"
124
+ "zod": "^4.6.5"
124
125
  },
125
126
  "devDependencies": {
126
127
  "@eslint/js": "^10.0.1",
127
128
  "@receptron/test_utils": "^2.0.3",
128
129
  "@types/archiver": "^8.0.0",
129
130
  "@types/jsdom": "^28.0.3",
131
+ "@types/node": "^22.20.2",
130
132
  "@types/yargs": "^17.0.35",
131
133
  "cross-env": "^10.1.0",
132
134
  "esbuild": "^0.28.2",
133
- "eslint": "^10.8.1",
135
+ "eslint": "^10.11.0",
134
136
  "eslint-config-prettier": "^10.1.8",
135
137
  "eslint-plugin-import": "^2.32.0",
136
138
  "eslint-plugin-prettier": "^5.5.6",
137
- "eslint-plugin-sonarjs": "^4.2.0",
138
- "globals": "^17.11.0",
139
- "prettier": "^3.9.6",
140
- "tsx": "^4.23.12",
139
+ "eslint-plugin-sonarjs": "^4.2.1",
140
+ "globals": "^17.12.0",
141
+ "prettier": "^3.9.8",
142
+ "tsx": "^4.23.13",
141
143
  "typescript": "6.0.3",
142
- "typescript-eslint": "^8.67.0"
144
+ "typescript-eslint": "^8.70.0"
143
145
  },
144
146
  "engines": {
145
147
  "node": ">=22.0.0"