mulmocast 2.21.0 → 2.22.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.
Files changed (32) hide show
  1. package/lib/actions/images.js +2 -1
  2. package/lib/data/animationRuntime.d.ts +5 -0
  3. package/lib/data/animationRuntime.js +639 -0
  4. package/lib/methods/mulmo_beat.d.ts +2 -7
  5. package/lib/methods/mulmo_beat.js +1 -12
  6. package/lib/methods/mulmo_beat_node.d.ts +2 -8
  7. package/lib/utils/animation_config.d.ts +15 -0
  8. package/lib/utils/animation_config.js +17 -0
  9. package/lib/utils/audio_probe_source.d.ts +5 -0
  10. package/lib/utils/audio_probe_source.js +16 -0
  11. package/lib/utils/beat_html/chart.js +2 -1
  12. package/lib/utils/beat_html/decoration.d.ts +16 -0
  13. package/lib/utils/beat_html/decoration.js +55 -0
  14. package/lib/utils/beat_html/html_tailwind.d.ts +6 -6
  15. package/lib/utils/beat_html/html_tailwind.js +13 -7
  16. package/lib/utils/beat_html/index.js +3 -2
  17. package/lib/utils/beat_html/mermaid.js +2 -1
  18. package/lib/utils/beat_html/slide.d.ts +7 -2
  19. package/lib/utils/beat_html/slide.js +7 -22
  20. package/lib/utils/beat_html/type.d.ts +43 -1
  21. package/lib/utils/html_render.js +1 -1
  22. package/lib/utils/image_plugins/bg_image_css.d.ts +19 -0
  23. package/lib/utils/image_plugins/bg_image_css.js +75 -0
  24. package/lib/utils/image_plugins/bg_image_util.d.ts +0 -6
  25. package/lib/utils/image_plugins/bg_image_util.js +7 -69
  26. package/lib/utils/image_plugins/html_tailwind.js +2 -39
  27. package/lib/utils/image_plugins/html_tailwind_script.d.ts +41 -0
  28. package/lib/utils/image_plugins/html_tailwind_script.js +111 -0
  29. package/lib/utils/image_plugins/slide.js +2 -5
  30. package/lib/utils/image_plugins/slide_branding.d.ts +7 -0
  31. package/lib/utils/image_plugins/slide_branding.js +10 -0
  32. package/package.json +1 -1
@@ -5,8 +5,8 @@ import { getHTMLFile, getJSFile } from "../file.js";
5
5
  import { renderHTMLToImage, interpolate, renderHTMLToFrames, renderHTMLToVideo, renderHTMLToFinalFrame } from "../html_render.js";
6
6
  import { framesToVideo } from "../ffmpeg_utils.js";
7
7
  import { parrotingImagePath } from "./utils.js";
8
- import { swipeElementsToHtml, swipeElementsToScript } from "../swipe_to_html.js";
9
8
  import { htmlTailwindMarkup } from "./html_tailwind_markup.js";
9
+ import { FINAL_FRAME_TOTAL, animationFps, buildUserScript, resolveHtmlAndScript } from "./html_tailwind_script.js";
10
10
  import { MulmoStudioContextMethods } from "../../methods/mulmo_studio_context.js";
11
11
  export const imageType = "html_tailwind";
12
12
  /**
@@ -53,22 +53,6 @@ export const resolveRelativeImagePaths = (html, baseDirPath) => {
53
53
  return `${prefix}${quote}file://${absolutePath}${quote}`;
54
54
  });
55
55
  };
56
- const DEFAULT_ANIMATION_FPS = 30;
57
- /** Join html field into a single string (handles both string and string[]) */
58
- const joinHtml = (html) => {
59
- return Array.isArray(html) ? html.join("\n") : html;
60
- };
61
- const buildUserScript = (script) => {
62
- if (!script)
63
- return "";
64
- const code = Array.isArray(script) ? script.join("\n") : script;
65
- // If user script contains ESM import/export, emit module script so imports work.
66
- const isModule = code.split("\n").some((line) => {
67
- const trimmed = line.trimStart();
68
- return trimmed.startsWith("import ") || trimmed.startsWith("export ");
69
- });
70
- return isModule ? `<script type="module">\n${code}\n</script>` : `<script>\n${code}\n</script>`;
71
- };
72
56
  /**
73
57
  * Resolve relative modelUrl assignment in user scripts to file:// absolute paths.
74
58
  * e.g. const modelUrl = "models/a.glb" -> const modelUrl = "file:///abs/models/a.glb"
@@ -105,25 +89,6 @@ export const resolveRelativeModelPathsInScript = (html, baseDirPath) => {
105
89
  })
106
90
  .join("\n");
107
91
  };
108
- /**
109
- * Resolve HTML and script from beat image data.
110
- * If `elements` (Swipe-style) is provided, convert to HTML + script.
111
- * Otherwise, use raw `html` and `script` fields.
112
- */
113
- const resolveHtmlAndScript = (imageData) => {
114
- if (imageData.elements && Array.isArray(imageData.elements) && imageData.elements.length > 0) {
115
- const html = swipeElementsToHtml(imageData.elements);
116
- const generatedScript = swipeElementsToScript(imageData.elements);
117
- // Merge with user-provided script if any
118
- const userScript = imageData.script ? joinHtml(imageData.script) : "";
119
- const combinedScript = [generatedScript, userScript].filter(Boolean).join("\n");
120
- return { html, script: combinedScript || undefined };
121
- }
122
- return {
123
- html: joinHtml(imageData.html ?? ""),
124
- script: imageData.script,
125
- };
126
- };
127
92
  const getAnimationConfig = (params) => {
128
93
  const { beat } = params;
129
94
  if (!beat.image || beat.image.type !== imageType)
@@ -131,12 +96,10 @@ const getAnimationConfig = (params) => {
131
96
  const animation = beat.image.animation;
132
97
  if (!MulmoBeatMethods.isAnimationEnabled(animation))
133
98
  return null;
134
- const fps = MulmoBeatMethods.isAnimationObject(animation) ? (animation.fps ?? DEFAULT_ANIMATION_FPS) : DEFAULT_ANIMATION_FPS;
99
+ const fps = animationFps(animation);
135
100
  const movie = MulmoBeatMethods.isMovieMode(animation);
136
101
  return { fps, movie };
137
102
  };
138
- /** Large frame count to ensure all animations reach their end state when exact duration is unknown */
139
- const FINAL_FRAME_TOTAL = 9000;
140
103
  /**
141
104
  * Build the animated HTML string from beat data and template.
142
105
  */
@@ -0,0 +1,41 @@
1
+ import { type SwipeElement } from "../swipe_to_html.js";
2
+ /** How long the final frame waits for `<video>` elements to be playable, as the PNG render does. */
3
+ export declare const VIDEO_LOAD_TIMEOUT_MS = 15000;
4
+ /** `tailwind_animated.html`'s rules: CSS animations and transitions stay still, so frames come from `render` alone. */
5
+ export declare const ANIMATED_DOCUMENT_CSS = "*, *::before, *::after {\n animation-play-state: paused !important;\n transition: none !important;\n}";
6
+ /** Large frame count to ensure all animations reach their end state when exact duration is unknown */
7
+ export declare const FINAL_FRAME_TOTAL = 9000;
8
+ /** Frames per second of an enabled `animation` config. */
9
+ export declare const animationFps: (animation: unknown) => number;
10
+ export declare const buildUserScript: (script: string | string[] | undefined) => string;
11
+ /**
12
+ * Resolve HTML and script from beat image data.
13
+ * If `elements` (Swipe-style) is provided, convert to HTML + script.
14
+ * Otherwise, use raw `html` and `script` fields.
15
+ */
16
+ export declare const resolveHtmlAndScript: (imageData: {
17
+ html?: string | string[];
18
+ script?: string | string[];
19
+ elements?: SwipeElement[];
20
+ }) => {
21
+ html: string;
22
+ script: string | string[] | undefined;
23
+ };
24
+ export type HtmlTailwindScriptSource = {
25
+ script?: string | string[];
26
+ elements?: unknown;
27
+ animation?: unknown;
28
+ };
29
+ /**
30
+ * The scripts that bring an html_tailwind beat to the state its PNG shows, as `<script>` elements
31
+ * for the end of the beat's own document; undefined when it runs none.
32
+ *
33
+ * Without `animation` that is the author script and swipe driver, as `tailwind.html` runs them.
34
+ * With it, the runtime, frame state and registration `tailwind_animated.html` loads, then the
35
+ * final frame — what `renderHTMLToFinalFrame` captures. The final frame is drawn on `load`, after
36
+ * a module author script (deferred) has defined its `render`, as Puppeteer calls it after load;
37
+ * scripts a host adds to an already-loaded document draw it once `render` exists (bounded wait).
38
+ */
39
+ export declare const htmlTailwindDocumentScript: (image: HtmlTailwindScriptSource) => string | undefined;
40
+ /** `documentCss` for an html_tailwind beat: the animated template's pause rules, when it animates. */
41
+ export declare const htmlTailwindDocumentCss: (image: HtmlTailwindScriptSource) => string | undefined;
@@ -0,0 +1,111 @@
1
+ import { isAnimationEnabled, isAnimationObject } from "../animation_config.js";
2
+ import { isSwipeElements, swipeElementsToHtml, swipeElementsToScript } from "../swipe_to_html.js";
3
+ import { animationRuntime } from "../../data/animationRuntime.js";
4
+ /**
5
+ * The scripts an html_tailwind beat runs. Pure — no Node, no filesystem — because the PNG render
6
+ * and the browser fragment path run the same author script, swipe driver and animation runtime,
7
+ * and must assemble them the same way.
8
+ */
9
+ const DEFAULT_ANIMATION_FPS = 30;
10
+ /** How long the final frame waits for `<video>` elements to be playable, as the PNG render does. */
11
+ export const VIDEO_LOAD_TIMEOUT_MS = 15000;
12
+ /** `tailwind_animated.html`'s rules: CSS animations and transitions stay still, so frames come from `render` alone. */
13
+ export const ANIMATED_DOCUMENT_CSS = `*, *::before, *::after {
14
+ animation-play-state: paused !important;
15
+ transition: none !important;
16
+ }`;
17
+ // How long scripts added to an already-loaded document wait for a module author script to define `render`.
18
+ const LATE_RENDER_WAIT_MS = 5000;
19
+ const LATE_RENDER_POLL_MS = 50;
20
+ /** Large frame count to ensure all animations reach their end state when exact duration is unknown */
21
+ export const FINAL_FRAME_TOTAL = 9000;
22
+ /** Frames per second of an enabled `animation` config. */
23
+ export const animationFps = (animation) => (isAnimationObject(animation) ? (animation.fps ?? DEFAULT_ANIMATION_FPS) : DEFAULT_ANIMATION_FPS);
24
+ /** Join html field into a single string (handles both string and string[]) */
25
+ const joinHtml = (html) => {
26
+ return Array.isArray(html) ? html.join("\n") : html;
27
+ };
28
+ // A `</script` anywhere in the code ends the element early, even inside a string; `<\/script` is the same JS.
29
+ const neutralizeScriptTerminator = (code) => code.replace(/<\/script/gi, (terminator) => `<\\${terminator.slice(1)}`);
30
+ export const buildUserScript = (script) => {
31
+ if (!script)
32
+ return "";
33
+ const code = neutralizeScriptTerminator(Array.isArray(script) ? script.join("\n") : script);
34
+ // If user script contains ESM import/export, emit module script so imports work.
35
+ const isModule = code.split("\n").some((line) => {
36
+ const trimmed = line.trimStart();
37
+ return trimmed.startsWith("import ") || trimmed.startsWith("export ");
38
+ });
39
+ return isModule ? `<script type="module">\n${code}\n</script>` : `<script>\n${code}\n</script>`;
40
+ };
41
+ /**
42
+ * Resolve HTML and script from beat image data.
43
+ * If `elements` (Swipe-style) is provided, convert to HTML + script.
44
+ * Otherwise, use raw `html` and `script` fields.
45
+ */
46
+ export const resolveHtmlAndScript = (imageData) => {
47
+ if (imageData.elements && Array.isArray(imageData.elements) && imageData.elements.length > 0) {
48
+ const html = swipeElementsToHtml(imageData.elements);
49
+ const generatedScript = swipeElementsToScript(imageData.elements);
50
+ // Merge with user-provided script if any
51
+ const userScript = imageData.script ? joinHtml(imageData.script) : "";
52
+ const combinedScript = [generatedScript, userScript].filter(Boolean).join("\n");
53
+ return { html, script: combinedScript || undefined };
54
+ }
55
+ return {
56
+ html: joinHtml(imageData.html ?? ""),
57
+ script: imageData.script,
58
+ };
59
+ };
60
+ const inlineScript = (code) => `<script>\n${neutralizeScriptTerminator(code)}\n</script>`;
61
+ /**
62
+ * The scripts that bring an html_tailwind beat to the state its PNG shows, as `<script>` elements
63
+ * for the end of the beat's own document; undefined when it runs none.
64
+ *
65
+ * Without `animation` that is the author script and swipe driver, as `tailwind.html` runs them.
66
+ * With it, the runtime, frame state and registration `tailwind_animated.html` loads, then the
67
+ * final frame — what `renderHTMLToFinalFrame` captures. The final frame is drawn on `load`, after
68
+ * a module author script (deferred) has defined its `render`, as Puppeteer calls it after load;
69
+ * scripts a host adds to an already-loaded document draw it once `render` exists (bounded wait).
70
+ */
71
+ export const htmlTailwindDocumentScript = (image) => {
72
+ // The schema leaves `elements` unknown[]; the markup reads it through the same guard.
73
+ const elements = isSwipeElements(image.elements) ? image.elements : undefined;
74
+ const userScript = buildUserScript(resolveHtmlAndScript({ script: image.script, ...(elements ? { elements } : {}) }).script);
75
+ if (!isAnimationEnabled(image.animation))
76
+ return userScript || undefined;
77
+ const frameState = `window.__MULMO = { frame: 0, totalFrames: ${FINAL_FRAME_TOTAL}, fps: ${animationFps(image.animation)} };`;
78
+ // On `load` when the scripts are part of the document as it loads. Added to a loaded document, a module author
79
+ // script (one with imports above all) may run later, so wait for its `render`, bounded, before drawing. Either
80
+ // way the videos get to be playable first, as `waitForVideosReady` lets them before the PNG's final frame.
81
+ const finalFrame = [
82
+ "(function () {",
83
+ " var whenVideosReady = function (done) {",
84
+ ' var pending = Array.prototype.slice.call(document.querySelectorAll("video")).filter(function (video) { return video.readyState < 3; });',
85
+ " if (pending.length === 0) return done();",
86
+ " var remaining = pending.length, finished = false;",
87
+ " var finish = function () { if (!finished) { finished = true; done(); } };",
88
+ ` setTimeout(finish, ${VIDEO_LOAD_TIMEOUT_MS});`,
89
+ ' pending.forEach(function (video) { video.addEventListener("canplaythrough", function () { remaining -= 1; if (remaining <= 0) finish(); }, { once: true }); });',
90
+ " };",
91
+ " var drawFinal = function () { whenVideosReady(function () { Promise.resolve(window.renderFinal()).catch(console.error); }); };",
92
+ " var whenRenderReady = function (startedAt) {",
93
+ ` if (typeof window.render === "function" || Date.now() - startedAt > ${LATE_RENDER_WAIT_MS}) drawFinal();`,
94
+ ` else setTimeout(function () { whenRenderReady(startedAt); }, ${LATE_RENDER_POLL_MS});`,
95
+ " };",
96
+ ' if (document.readyState === "complete") whenRenderReady(Date.now());',
97
+ ' else window.addEventListener("load", drawFinal);',
98
+ "})();",
99
+ ].join("\n");
100
+ return [
101
+ inlineScript(`${animationRuntime.animation_runtime}\n${frameState}`),
102
+ userScript,
103
+ inlineScript(animationRuntime.data_attribute_registration),
104
+ inlineScript(animationRuntime.auto_render),
105
+ inlineScript(finalFrame),
106
+ ]
107
+ .filter(Boolean)
108
+ .join("\n");
109
+ };
110
+ /** `documentCss` for an html_tailwind beat: the animated template's pause rules, when it animates. */
111
+ export const htmlTailwindDocumentCss = (image) => isAnimationEnabled(image.animation) ? ANIMATED_DOCUMENT_CSS : undefined;
@@ -8,6 +8,7 @@ import { MulmoMediaSourceMethods } from "../../methods/mulmo_media_source.js";
8
8
  import { MulmoPresentationStyleMethods } from "../../methods/mulmo_presentation_style.js";
9
9
  import { imageAction, imageFileTarget, unknownMediaType } from "../error_cause.js";
10
10
  import { MulmoStudioContextMethods } from "../../methods/mulmo_studio_context.js";
11
+ import { pickBranding } from "./slide_branding.js";
11
12
  export const imageType = "slide";
12
13
  const slideImageRefError = (refKey) => ({
13
14
  type: unknownMediaType,
@@ -124,11 +125,7 @@ const resolveBranding = (params) => {
124
125
  const { beat, context } = params;
125
126
  if (!beat.image || beat.image.type !== imageType)
126
127
  return undefined;
127
- const beatBranding = beat.image.branding;
128
- // null means explicitly disabled
129
- if (beatBranding === null)
130
- return undefined;
131
- return beatBranding ?? context.presentationStyle.slideParams?.branding;
128
+ return pickBranding(beat.image.branding, context.presentationStyle.slideParams?.branding);
132
129
  };
133
130
  /**
134
131
  * Convert SlideBranding to ResolvedBranding (all sources → data URLs).
@@ -0,0 +1,7 @@
1
+ import type { SlideBranding } from "@mulmocast/deck";
2
+ /**
3
+ * Which branding a slide beat gets. Pure — the PNG render and the browser fragment path pick it
4
+ * the same way: the beat's own wins, a beat-level `null` switches the deck's off, and otherwise
5
+ * the deck's `slideParams.branding` applies.
6
+ */
7
+ export declare const pickBranding: (beatBranding: SlideBranding | null | undefined, deckBranding: SlideBranding | undefined) => SlideBranding | undefined;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Which branding a slide beat gets. Pure — the PNG render and the browser fragment path pick it
3
+ * the same way: the beat's own wins, a beat-level `null` switches the deck's off, and otherwise
4
+ * the deck's `slideParams.branding` applies.
5
+ */
6
+ export const pickBranding = (beatBranding, deckBranding) => {
7
+ if (beatBranding === null)
8
+ return undefined;
9
+ return beatBranding ?? deckBranding;
10
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmocast",
3
- "version": "2.21.0",
3
+ "version": "2.22.1",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "lib/index.node.js",