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
@@ -1,16 +1,5 @@
1
1
  import { ImageMediaType } from "../types/index.js";
2
- /** Type guard: checks if animation value is an object config like { fps: 30 } */
3
- const isAnimationObject = (animation) => {
4
- return typeof animation === "object" && animation !== null && !Array.isArray(animation);
5
- };
6
- /** Check if a value is a valid animation config (true or non-array object) */
7
- const isAnimationEnabled = (animation) => {
8
- return animation === true || isAnimationObject(animation);
9
- };
10
- /** Check if movie mode (CDP screencast) is enabled */
11
- const isMovieMode = (animation) => {
12
- return isAnimationObject(animation) && animation.movie === true;
13
- };
2
+ import { isAnimationEnabled, isAnimationObject, isMovieMode } from "../utils/animation_config.js";
14
3
  /** Check if a beat has html_tailwind animation enabled */
15
4
  const isAnimatedHtmlTailwind = (beat) => {
16
5
  if (!beat.image || beat.image.type !== "html_tailwind")
@@ -7,14 +7,8 @@ export declare const MulmoBeatMethods: {
7
7
  markdown?: (params: import("../types/type.js").BeatRenderParams) => string | undefined;
8
8
  html?: (params: import("../types/type.js").BeatRenderParams) => Promise<string | undefined>;
9
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
- };
10
+ isAnimationEnabled: (animation: unknown) => animation is true | import("../utils/animation_config.js").AnimationConfig;
11
+ isAnimationObject: (animation: unknown) => animation is import("../utils/animation_config.js").AnimationConfig;
18
12
  isAnimatedHtmlTailwind: (beat: MulmoBeat) => boolean;
19
13
  isPluginVideo: (beat: MulmoBeat) => boolean;
20
14
  isMovieMode: (animation: unknown) => boolean;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Reading an html_tailwind `animation` value. Pure and dependency-free — the PNG render and the
3
+ * browser fragment path decide "is this animated, at what fps" the same way, and the browser
4
+ * bundle must not reach the schema module to do it.
5
+ */
6
+ export type AnimationConfig = {
7
+ fps?: number;
8
+ movie?: boolean;
9
+ };
10
+ /** Type guard: checks if animation value is an object config like { fps: 30 } */
11
+ export declare const isAnimationObject: (animation: unknown) => animation is AnimationConfig;
12
+ /** Check if a value is a valid animation config (true or non-array object) */
13
+ export declare const isAnimationEnabled: (animation: unknown) => animation is true | AnimationConfig;
14
+ /** Check if movie mode (CDP screencast) is enabled */
15
+ export declare const isMovieMode: (animation: unknown) => boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Reading an html_tailwind `animation` value. Pure and dependency-free — the PNG render and the
3
+ * browser fragment path decide "is this animated, at what fps" the same way, and the browser
4
+ * bundle must not reach the schema module to do it.
5
+ */
6
+ /** Type guard: checks if animation value is an object config like { fps: 30 } */
7
+ export const isAnimationObject = (animation) => {
8
+ return typeof animation === "object" && animation !== null && !Array.isArray(animation);
9
+ };
10
+ /** Check if a value is a valid animation config (true or non-array object) */
11
+ export const isAnimationEnabled = (animation) => {
12
+ return animation === true || isAnimationObject(animation);
13
+ };
14
+ /** Check if movie mode (CDP screencast) is enabled */
15
+ export const isMovieMode = (animation) => {
16
+ return isAnimationObject(animation) && animation.movie === true;
17
+ };
@@ -0,0 +1,5 @@
1
+ export declare const isStillImageFile: (filePath: string) => boolean;
2
+ export declare const audioProbeSource: (files: {
3
+ movieFile?: string;
4
+ imageFile?: string;
5
+ }) => string | undefined;
@@ -0,0 +1,16 @@
1
+ import path from "node:path";
2
+ // A still image never carries an audio track, so probing it only makes ffprobe a requirement for image-only renders.
3
+ const STILL_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]);
4
+ export const isStillImageFile = (filePath) => {
5
+ const withoutQuery = filePath.split(/[?#]/)[0];
6
+ return STILL_IMAGE_EXTENSIONS.has(path.extname(withoutQuery).toLowerCase());
7
+ };
8
+ export const audioProbeSource = (files) => {
9
+ if (files.movieFile) {
10
+ return files.movieFile;
11
+ }
12
+ if (!files.imageFile || isStillImageFile(files.imageFile)) {
13
+ return undefined;
14
+ }
15
+ return files.imageFile;
16
+ };
@@ -1,4 +1,5 @@
1
1
  import { deckSlideToHtml } from "./slide.js";
2
+ import { backgroundDecoration } from "./decoration.js";
2
3
  /**
3
4
  * A chart beat as a deck `figure` slide, so it shares the `slide` beat's theme and its one
4
5
  * host code path: `slideUtilityCss`, and the `[data-mulmo-chart]` canvas driven from the
@@ -7,4 +8,4 @@ import { deckSlideToHtml } from "./slide.js";
7
8
  *
8
9
  * The PNG path still draws from its own template, so the browser and the PNG differ in look.
9
10
  */
10
- export const chartToHtml = (image, options) => deckSlideToHtml({ layout: "figure", title: image.title, figure: { type: "chart", chartData: image.chartData } }, undefined, options);
11
+ export const chartToHtml = (image, options) => deckSlideToHtml({ layout: "figure", title: image.title, figure: { type: "chart", chartData: image.chartData } }, undefined, options, backgroundDecoration(image, options));
@@ -0,0 +1,16 @@
1
+ import type { ResolvedBranding, SlideBranding } from "@mulmocast/deck";
2
+ import type { BackgroundImage } from "../../types/index.js";
3
+ import type { BeatHtmlFragment, BeatHtmlOptions } from "./type.js";
4
+ export declare const safeMediaUrl: (url: string) => string;
5
+ /** A textSlide / markdown fragment with `documentCss` for the background the PNG paints on `body`. */
6
+ export declare const withDocumentBackground: (fragment: BeatHtmlFragment | undefined, image: {
7
+ backgroundImage?: BackgroundImage;
8
+ }, options: BeatHtmlOptions) => BeatHtmlFragment | undefined;
9
+ /** A chart / mermaid beat's background, as the branding layer of the figure slide deck draws it on. */
10
+ export declare const backgroundDecoration: (image: {
11
+ backgroundImage?: BackgroundImage;
12
+ }, options: BeatHtmlOptions) => {
13
+ branding?: ResolvedBranding;
14
+ };
15
+ /** A slide beat's branding, resolved as `convertBrandingToResolved` does on the Node side. */
16
+ export declare const slideBranding: (beatBranding: SlideBranding | null | undefined, options: BeatHtmlOptions) => ResolvedBranding | undefined;
@@ -0,0 +1,55 @@
1
+ import { backgroundImageCss, backgroundImageLayout, resolveBackgroundImage } from "../image_plugins/bg_image_css.js";
2
+ import { pickBranding } from "../image_plugins/slide_branding.js";
3
+ import { mediaSrc } from "../image_plugins/media_html.js";
4
+ import { neutralizeStyleTerminator } from "../html_escape.js";
5
+ /**
6
+ * The decorations the PNG render adds around a beat's content — a background image, a slide's
7
+ * branding — resolved for the browser. The PNG path turns each image into a `data:` URI on disk;
8
+ * here the host says how its page reaches the file (`resolveMediaUrl`).
9
+ */
10
+ // deck writes these urls into `style="…url('…')"` and `src="…"` unescaped, and a url from a script can carry a quote
11
+ // (`z.url()` accepts one). Percent-encoding leaves a url that still loads and cannot leave either context.
12
+ const UNSAFE_URL_CHARACTERS = /["'()\\<>\s]/g;
13
+ export const safeMediaUrl = (url) => url.replace(UNSAFE_URL_CHARACTERS, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
14
+ const mediaUrl = (source, options) => {
15
+ const url = (options.resolveMediaUrl ?? mediaSrc)(source);
16
+ return url === undefined ? undefined : safeMediaUrl(url);
17
+ };
18
+ const paintedBackground = (beatBackgroundImage, options) => {
19
+ const backgroundImage = resolveBackgroundImage(beatBackgroundImage, options.backgroundImage);
20
+ if (!backgroundImage)
21
+ return undefined;
22
+ const url = typeof backgroundImage === "string" ? safeMediaUrl(backgroundImage) : mediaUrl(backgroundImage.source, options);
23
+ return url === undefined ? undefined : { url, ...backgroundImageLayout(backgroundImage) };
24
+ };
25
+ /** A textSlide / markdown fragment with `documentCss` for the background the PNG paints on `body`. */
26
+ export const withDocumentBackground = (fragment, image, options) => {
27
+ const background = paintedBackground(image.backgroundImage, options);
28
+ if (!fragment || !background)
29
+ return fragment;
30
+ return { ...fragment, documentCss: neutralizeStyleTerminator(backgroundImageCss(background.url, background.size, background.opacity)) };
31
+ };
32
+ /** A chart / mermaid beat's background, as the branding layer of the figure slide deck draws it on. */
33
+ export const backgroundDecoration = (image, options) => {
34
+ const background = paintedBackground(image.backgroundImage, options);
35
+ return background ? { branding: { backgroundImage: { dataUrl: background.url, size: background.size, opacity: background.opacity } } } : {};
36
+ };
37
+ const resolvedLogo = (logo, options) => {
38
+ const dataUrl = mediaUrl(logo.source, options);
39
+ return dataUrl === undefined ? undefined : { dataUrl, position: logo.position, width: logo.width };
40
+ };
41
+ const resolvedBrandingBackground = (backgroundImage, options) => {
42
+ const dataUrl = mediaUrl(backgroundImage.source, options);
43
+ if (dataUrl === undefined)
44
+ return undefined;
45
+ return { dataUrl, size: backgroundImage.size ?? "cover", opacity: backgroundImage.opacity ?? 1, bgOpacity: backgroundImage.bgOpacity };
46
+ };
47
+ /** A slide beat's branding, resolved as `convertBrandingToResolved` does on the Node side. */
48
+ export const slideBranding = (beatBranding, options) => {
49
+ const branding = pickBranding(beatBranding, options.branding);
50
+ if (!branding)
51
+ return undefined;
52
+ const logo = branding.logo && resolvedLogo(branding.logo, options);
53
+ const backgroundImage = branding.backgroundImage && resolvedBrandingBackground(branding.backgroundImage, options);
54
+ return { ...(logo ? { logo } : {}), ...(backgroundImage ? { backgroundImage } : {}) };
55
+ };
@@ -4,12 +4,12 @@ import type { BeatHtmlFragment } from "./type.js";
4
4
  /**
5
5
  * An html_tailwind beat as markup a host can inject.
6
6
  *
7
- * The beat at rest — the same thing the Node dump path shows. This module generates no
8
- * `<script>`: the author's `script` FIELD, the swipe animation driver and the frame-based
9
- * `animation` all need script execution, which a fragment injected through `innerHTML`
10
- * cannot do and which the dump path has never emitted either. A `<script>` the author wrote
11
- * inside `html` is markup, and passes through unsanitized — see below. A beat whose only content is an animation therefore shows
12
- * its elements in their starting positions rather than nothing.
7
+ * `html` is the beat at rest — the same thing the Node dump path shows, with no generated
8
+ * `<script>`: a fragment injected through `innerHTML` cannot run one. The author's `script`
9
+ * FIELD, the swipe driver and the frame-based `animation` come back separately as
10
+ * `documentScript`, for a host that gives the beat a document of its own and can run them; it
11
+ * brings the beat to what the PNG shows (an animation's final frame). A `<script>` the author
12
+ * wrote inside `html` is markup, and passes through unsanitized — see below.
13
13
  *
14
14
  * The author's html is emitted as written. On this beat type markup IS the content — the
15
15
  * schema also accepts `script` — so escaping it would remove the feature rather than a
@@ -1,13 +1,14 @@
1
1
  import { htmlTailwindMarkup } from "../image_plugins/html_tailwind_markup.js";
2
+ import { htmlTailwindDocumentCss, htmlTailwindDocumentScript } from "../image_plugins/html_tailwind_script.js";
2
3
  /**
3
4
  * An html_tailwind beat as markup a host can inject.
4
5
  *
5
- * The beat at rest — the same thing the Node dump path shows. This module generates no
6
- * `<script>`: the author's `script` FIELD, the swipe animation driver and the frame-based
7
- * `animation` all need script execution, which a fragment injected through `innerHTML`
8
- * cannot do and which the dump path has never emitted either. A `<script>` the author wrote
9
- * inside `html` is markup, and passes through unsanitized — see below. A beat whose only content is an animation therefore shows
10
- * its elements in their starting positions rather than nothing.
6
+ * `html` is the beat at rest — the same thing the Node dump path shows, with no generated
7
+ * `<script>`: a fragment injected through `innerHTML` cannot run one. The author's `script`
8
+ * FIELD, the swipe driver and the frame-based `animation` come back separately as
9
+ * `documentScript`, for a host that gives the beat a document of its own and can run them; it
10
+ * brings the beat to what the PNG shows (an animation's final frame). A `<script>` the author
11
+ * wrote inside `html` is markup, and passes through unsanitized — see below.
11
12
  *
12
13
  * The author's html is emitted as written. On this beat type markup IS the content — the
13
14
  * schema also accepts `script` — so escaping it would remove the feature rather than a
@@ -16,5 +17,10 @@ import { htmlTailwindMarkup } from "../image_plugins/html_tailwind_markup.js";
16
17
  */
17
18
  export const htmlTailwindToHtml = (image) => {
18
19
  const html = htmlTailwindMarkup(image);
19
- return html === "" ? undefined : { html };
20
+ const documentScript = htmlTailwindDocumentScript(image);
21
+ const documentCss = htmlTailwindDocumentCss(image);
22
+ // A script-only beat draws itself, so it is a fragment with empty markup rather than nothing.
23
+ if (!documentScript && html === "")
24
+ return undefined;
25
+ return { html, ...(documentCss ? { documentCss } : {}), ...(documentScript ? { documentScript } : {}) };
20
26
  };
@@ -6,6 +6,7 @@ import { imageToHtml, movieToHtml } from "./media.js";
6
6
  import { mermaidToHtml } from "./mermaid.js";
7
7
  import { slideToHtml } from "./slide.js";
8
8
  import { assertSafeElementId } from "../element_id.js";
9
+ import { withDocumentBackground } from "./decoration.js";
9
10
  export { textSlideToMarkdown, textSlideToHtml } from "./text_slide.js";
10
11
  export { markdownToHtml } from "./markdown.js";
11
12
  /**
@@ -42,9 +43,9 @@ export const beatToHtml = (beat, options) => {
42
43
  assertSafeElementId(options.idPrefix);
43
44
  switch (image.type) {
44
45
  case "textSlide":
45
- return textSlideToHtml(image);
46
+ return withDocumentBackground(textSlideToHtml(image), image, options);
46
47
  case "markdown":
47
- return markdownToHtml(image, options.idPrefix);
48
+ return withDocumentBackground(markdownToHtml(image, options.idPrefix), image, options);
48
49
  case "chart":
49
50
  return chartToHtml(image, options);
50
51
  case "mermaid":
@@ -1,4 +1,5 @@
1
1
  import { deckSlideToHtml } from "./slide.js";
2
+ import { backgroundDecoration } from "./decoration.js";
2
3
  /**
3
4
  * A mermaid beat as a deck `figure` slide — see `chart.ts` for why deck draws it.
4
5
  *
@@ -12,5 +13,5 @@ export const mermaidToHtml = (image, options) => {
12
13
  return undefined;
13
14
  const appendix = image.appendix?.join("\n") ?? "";
14
15
  const code = `${image.code.text}\n${appendix}`.trim();
15
- return deckSlideToHtml({ layout: "figure", title: image.title || "Diagram", figure: { type: "mermaid", code } }, undefined, options);
16
+ return deckSlideToHtml({ layout: "figure", title: image.title || "Diagram", figure: { type: "mermaid", code } }, undefined, options, backgroundDecoration(image, options));
16
17
  };
@@ -1,4 +1,4 @@
1
- import type { MulmoSlideMedia, SlideLayout, SlideTheme } from "@mulmocast/deck";
1
+ import type { MulmoSlideMedia, ResolvedBranding, SlideLayout, SlideTheme } from "@mulmocast/deck";
2
2
  import type { BeatHtmlFragment, BeatHtmlOptions } from "./type.js";
3
3
  /**
4
4
  * A deck slide as markup a host can inject. Shared by the beat types deck draws: `slide`,
@@ -19,5 +19,10 @@ import type { BeatHtmlFragment, BeatHtmlOptions } from "./type.js";
19
19
  * it on the root of `html`, verified in a browser: injecting `html` and attaching `css`, with
20
20
  * no wrapper class of the host's own, resolves `--d-bg` to the beat's theme.
21
21
  */
22
- export declare const deckSlideToHtml: (slide: SlideLayout, beatTheme: SlideTheme | undefined, options: BeatHtmlOptions) => BeatHtmlFragment;
22
+ /** What the PNG render draws around a slide's layout: the branding layers and the reference line. */
23
+ export type SlideDecoration = {
24
+ branding?: ResolvedBranding;
25
+ reference?: string;
26
+ };
27
+ export declare const deckSlideToHtml: (slide: SlideLayout, beatTheme: SlideTheme | undefined, options: BeatHtmlOptions, decoration?: SlideDecoration) => BeatHtmlFragment;
23
28
  export declare const slideToHtml: (image: MulmoSlideMedia, options: BeatHtmlOptions) => BeatHtmlFragment;
@@ -2,28 +2,10 @@
2
2
  // and 602kb against 20 and 54kb for this module — and this file is bundled for the browser.
3
3
  import { generateSlideFragment } from "@mulmocast/deck/lib/fragment.js";
4
4
  import { slideThemes } from "../../data/slideThemes.js";
5
- /**
6
- * A deck slide as markup a host can inject. Shared by the beat types deck draws: `slide`,
7
- * and `chart` / `mermaid` as a one-block `figure` slide.
8
- *
9
- * `generateSlideFragment` is the body-only counterpart of the `generateSlideHTML` the Node
10
- * render path uses, so the two draw the same slide from the same layouts — the fragment is
11
- * not a second renderer.
12
- *
13
- * The theme order matches `MulmoPresentationStyleMethods.getResolvedSlideTheme` on the Node
14
- * side: the beat's own theme, then the deck's, then the built-in corporate one. Repeating
15
- * the order here rather than calling that method keeps this file off `MulmoPresentationStyle`,
16
- * which the browser bundle does not need.
17
- *
18
- * The scope class is derived from `idPrefix` so a re-render of the same beat keeps it: the
19
- * css is written against that class, and deck derives chart / mermaid ids from it, so a
20
- * generated one would change every render. It is not returned separately — deck already puts
21
- * it on the root of `html`, verified in a browser: injecting `html` and attaching `css`, with
22
- * no wrapper class of the host's own, resolves `--d-bg` to the beat's theme.
23
- */
24
- export const deckSlideToHtml = (slide, beatTheme, options) => {
5
+ import { slideBranding } from "./decoration.js";
6
+ export const deckSlideToHtml = (slide, beatTheme, options, decoration = {}) => {
25
7
  const theme = beatTheme ?? options.slideTheme ?? slideThemes.corporate;
26
- const fragment = generateSlideFragment(theme, slide, { scopeClass: `${options.idPrefix}-slide` });
8
+ const fragment = generateSlideFragment(theme, slide, { scopeClass: `${options.idPrefix}-slide`, ...decoration });
27
9
  return {
28
10
  html: fragment.html,
29
11
  css: fragment.css,
@@ -32,4 +14,7 @@ export const deckSlideToHtml = (slide, beatTheme, options) => {
32
14
  ...(fragment.mermaidTheme ? { mermaidTheme: fragment.mermaidTheme } : {}),
33
15
  };
34
16
  };
35
- export const slideToHtml = (image, options) => deckSlideToHtml(image.slide, image.theme, options);
17
+ export const slideToHtml = (image, options) => {
18
+ const branding = slideBranding(image.branding, options);
19
+ return deckSlideToHtml(image.slide, image.theme, options, { ...(branding ? { branding } : {}), ...(image.reference ? { reference: image.reference } : {}) });
20
+ };
@@ -1,4 +1,5 @@
1
- import type { SlideTheme } from "@mulmocast/deck";
1
+ import type { SlideBranding, SlideTheme } from "@mulmocast/deck";
2
+ import type { BackgroundImage, MulmoMediaSource } from "../../types/index.js";
2
3
  /**
3
4
  * A beat rendered as markup for a browser host.
4
5
  *
@@ -30,6 +31,27 @@ export type BeatHtmlFragment = {
30
31
  * and a host that does not gets an empty box, silently.
31
32
  */
32
33
  css?: string;
34
+ /**
35
+ * Rules for the whole document, as the PNG render applies them: a textSlide / markdown beat's
36
+ * background image on `html` / `body`, or an animated html_tailwind beat's rules pausing CSS
37
+ * animations and transitions. Absent when the beat needs neither.
38
+ *
39
+ * Apply it only where the beat owns the document (an iframe, a print page). Injected into a
40
+ * page shared with anything else it restyles that page: it cannot be scoped to the markup,
41
+ * because a text beat's markup has no root of its own and the PNG paints the background on
42
+ * `body`, behind the body's margin as well.
43
+ */
44
+ documentCss?: string;
45
+ /**
46
+ * `<script>` elements for the end of the beat's own document: an `html_tailwind` beat's author
47
+ * `script`, swipe driver and animation runtime, which bring it to what the PNG shows — for an
48
+ * animation, the final frame. Absent when the beat runs none.
49
+ *
50
+ * Only for a host that gives the beat a document of its own and lets it run scripts (an iframe
51
+ * with `allow-scripts`); `html` alone is the beat at rest. Not sanitized: the author script is
52
+ * the beat's content, as on the PNG path.
53
+ */
54
+ documentScript?: string;
33
55
  /** External runtimes the host must load once for the page, not once per beat. */
34
56
  requires?: BeatRuntime[];
35
57
  /**
@@ -78,4 +100,24 @@ export type BeatHtmlOptions = {
78
100
  * theme to give.
79
101
  */
80
102
  slideTheme?: SlideTheme;
103
+ /**
104
+ * The deck's `imageParams.backgroundImage`. A beat's own `backgroundImage` wins over it and a
105
+ * beat-level `null` switches it off — the order `resolveBackgroundImage` uses on the Node side.
106
+ * textSlide and markdown beats get it as `documentCss`; chart and mermaid beats, which deck
107
+ * draws as a figure slide, as that slide's background layer.
108
+ */
109
+ backgroundImage?: BackgroundImage;
110
+ /**
111
+ * The deck's `slideParams.branding` (logo and background) for slide beats. A beat's own
112
+ * `branding` wins over it and a beat-level `null` switches it off, as on the Node side.
113
+ */
114
+ branding?: SlideBranding;
115
+ /**
116
+ * Turns a background or branding image into a URL the host's page can load. The default uses
117
+ * a url as written and a path as written, and skips a base64 source — the schema carries no
118
+ * media type to build a `data:` URI from. A host whose scripts sit next to local files
119
+ * resolves paths here. What it returns is percent-encoded where it would otherwise break out
120
+ * of a CSS string or an HTML attribute.
121
+ */
122
+ resolveMediaUrl?: (source: MulmoMediaSource) => string | undefined;
81
123
  };
@@ -7,8 +7,8 @@ import puppeteer from "puppeteer";
7
7
  import { guardRenderPage } from "./render_network_guard.js";
8
8
  import { strictNetworkLaunchArgs, withRenderContentSecurityPolicy } from "./render_network_policy.js";
9
9
  import { GraphAILogger } from "graphai";
10
+ import { VIDEO_LOAD_TIMEOUT_MS } from "./image_plugins/html_tailwind_script.js";
10
11
  const isCI = process.env.CI === "true";
11
- const VIDEO_LOAD_TIMEOUT_MS = 15000;
12
12
  const VIDEO_SEEK_TIMEOUT_MS = 3000;
13
13
  /** Wait for all <video> elements on the page to be ready for playback */
14
14
  const waitForVideosReady = async (page) => {
@@ -0,0 +1,19 @@
1
+ import type { BackgroundImage } from "../../types/index.js";
2
+ /**
3
+ * Background-image rules, without fetching or reading anything. Pure — no Node, no filesystem —
4
+ * because the PNG render and the browser fragment path paint the same background and must
5
+ * resolve and write it the same way.
6
+ */
7
+ /**
8
+ * Resolve background image from beat level and global level settings.
9
+ * Beat level takes precedence over global level.
10
+ * null explicitly disables background image.
11
+ */
12
+ export declare const resolveBackgroundImage: (beatBackgroundImage: BackgroundImage | undefined, globalBackgroundImage: BackgroundImage | undefined) => BackgroundImage | undefined;
13
+ /** How a resolved background is laid out: a plain url string is always `cover` at full opacity. */
14
+ export declare const backgroundImageLayout: (backgroundImage: NonNullable<BackgroundImage>) => {
15
+ size: string;
16
+ opacity: number;
17
+ };
18
+ /** Rules painting `imageUrl` behind the whole document (`html` / `body`), as the PNG render does. */
19
+ export declare const backgroundImageCss: (imageUrl: string, size: string, opacity: number) => string;
@@ -0,0 +1,75 @@
1
+ import { escapeCssString } from "../html_escape.js";
2
+ /**
3
+ * Background-image rules, without fetching or reading anything. Pure — no Node, no filesystem —
4
+ * because the PNG render and the browser fragment path paint the same background and must
5
+ * resolve and write it the same way.
6
+ */
7
+ /**
8
+ * Resolve background image from beat level and global level settings.
9
+ * Beat level takes precedence over global level.
10
+ * null explicitly disables background image.
11
+ */
12
+ export const resolveBackgroundImage = (beatBackgroundImage, globalBackgroundImage) => {
13
+ // null means explicitly disabled
14
+ if (beatBackgroundImage === null) {
15
+ return undefined;
16
+ }
17
+ // Beat level takes precedence
18
+ if (beatBackgroundImage !== undefined) {
19
+ return beatBackgroundImage;
20
+ }
21
+ // Fall back to global
22
+ if (globalBackgroundImage === null) {
23
+ return undefined;
24
+ }
25
+ return globalBackgroundImage;
26
+ };
27
+ /** How a resolved background is laid out: a plain url string is always `cover` at full opacity. */
28
+ export const backgroundImageLayout = (backgroundImage) => {
29
+ if (typeof backgroundImage === "string")
30
+ return { size: "cover", opacity: 1 };
31
+ return { size: backgroundImage.size ?? "cover", opacity: backgroundImage.opacity ?? 1 };
32
+ };
33
+ // Convert size option to CSS background-size value
34
+ const sizeToCSS = (size) => {
35
+ return size === "fill" ? "100% 100%" : size;
36
+ };
37
+ /** Rules painting `imageUrl` behind the whole document (`html` / `body`), as the PNG render does. */
38
+ export const backgroundImageCss = (imageUrl, size, opacity) => {
39
+ const cssSize = sizeToCSS(size);
40
+ // Use pseudo-element for opacity to not affect content
41
+ if (opacity < 1) {
42
+ return `
43
+ html, body {
44
+ height: 100%;
45
+ margin: 0;
46
+ }
47
+ body::before {
48
+ content: '';
49
+ position: fixed;
50
+ top: 0;
51
+ left: 0;
52
+ right: 0;
53
+ bottom: 0;
54
+ background-image: url('${escapeCssString(imageUrl)}');
55
+ background-size: ${cssSize};
56
+ background-position: center;
57
+ background-repeat: no-repeat;
58
+ opacity: ${opacity};
59
+ z-index: -1;
60
+ }
61
+ `;
62
+ }
63
+ return `
64
+ html, body {
65
+ height: 100%;
66
+ margin: 0;
67
+ }
68
+ body {
69
+ background-image: url('${escapeCssString(imageUrl)}');
70
+ background-size: ${cssSize};
71
+ background-position: center;
72
+ background-repeat: no-repeat;
73
+ }
74
+ `;
75
+ };
@@ -1,10 +1,4 @@
1
1
  import { BackgroundImage, MulmoStudioContext, ImageProcessorParams } from "../../types/index.js";
2
- /**
3
- * Resolve background image from beat level and global level settings.
4
- * Beat level takes precedence over global level.
5
- * null explicitly disables background image.
6
- */
7
- export declare const resolveBackgroundImage: (beatBackgroundImage: BackgroundImage | undefined, globalBackgroundImage: BackgroundImage | undefined) => BackgroundImage | undefined;
8
2
  /** The background image comes from the script, the style from the beat; the paths and sizes `process()` needs are not read here. */
9
3
  type CombinedStyleParams = Pick<ImageProcessorParams, "context" | "textSlideStyle">;
10
4
  /**
@@ -1,27 +1,8 @@
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 { escapeCssString, neutralizeStyleTerminator } from "../html_escape.js";
5
- /**
6
- * Resolve background image from beat level and global level settings.
7
- * Beat level takes precedence over global level.
8
- * null explicitly disables background image.
9
- */
10
- export const resolveBackgroundImage = (beatBackgroundImage, globalBackgroundImage) => {
11
- // null means explicitly disabled
12
- if (beatBackgroundImage === null) {
13
- return undefined;
14
- }
15
- // Beat level takes precedence
16
- if (beatBackgroundImage !== undefined) {
17
- return beatBackgroundImage;
18
- }
19
- // Fall back to global
20
- if (globalBackgroundImage === null) {
21
- return undefined;
22
- }
23
- return globalBackgroundImage;
24
- };
4
+ import { neutralizeStyleTerminator } from "../html_escape.js";
5
+ import { backgroundImageCss, backgroundImageLayout, resolveBackgroundImage } from "./bg_image_css.js";
25
6
  /**
26
7
  * Fetch URL and convert to data URL with timeout
27
8
  */
@@ -34,13 +15,6 @@ const fetchUrlAsDataUrl = async (url, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) => {
34
15
  const contentType = response.headers.get("content-type") || "image/png";
35
16
  return `data:${contentType};base64,${buffer.toString("base64")}`;
36
17
  };
37
- /**
38
- * Convert BackgroundImage to CSS string
39
- */
40
- // Convert size option to CSS background-size value
41
- const sizeToCSS = (size) => {
42
- return size === "fill" ? "100% 100%" : size;
43
- };
44
18
  /**
45
19
  * Resolve combined style from background image and custom style.
46
20
  * Common pattern used by markdown, textSlide, mermaid plugins.
@@ -57,45 +31,9 @@ export const backgroundImageToCSS = async (backgroundImage, context) => {
57
31
  if (!backgroundImage) {
58
32
  return "";
59
33
  }
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.
63
- const imageUrl = isSimpleUrl ? await fetchUrlAsDataUrl(backgroundImage) : await MulmoMediaSourceMethods.toDataUrl(backgroundImage.source, context);
64
- const size = sizeToCSS(isSimpleUrl ? "cover" : (backgroundImage.size ?? "cover"));
65
- const opacity = isSimpleUrl ? 1 : (backgroundImage.opacity ?? 1);
66
- // Use pseudo-element for opacity to not affect content
67
- if (opacity < 1) {
68
- return `
69
- html, body {
70
- height: 100%;
71
- margin: 0;
72
- }
73
- body::before {
74
- content: '';
75
- position: fixed;
76
- top: 0;
77
- left: 0;
78
- right: 0;
79
- bottom: 0;
80
- background-image: url('${escapeCssString(imageUrl)}');
81
- background-size: ${size};
82
- background-position: center;
83
- background-repeat: no-repeat;
84
- opacity: ${opacity};
85
- z-index: -1;
86
- }
87
- `;
88
- }
89
- return `
90
- html, body {
91
- height: 100%;
92
- margin: 0;
93
- }
94
- body {
95
- background-image: url('${escapeCssString(imageUrl)}');
96
- background-size: ${size};
97
- background-position: center;
98
- background-repeat: no-repeat;
99
- }
100
- `;
34
+ // Escaped where it is written (backgroundImageCss) rather than here: the base64 body cannot carry a quote, but
35
+ // the content-type in front of it is whatever the remote server's response header said.
36
+ const imageUrl = typeof backgroundImage === "string" ? await fetchUrlAsDataUrl(backgroundImage) : await MulmoMediaSourceMethods.toDataUrl(backgroundImage.source, context);
37
+ const { size, opacity } = backgroundImageLayout(backgroundImage);
38
+ return backgroundImageCss(imageUrl, size, opacity);
101
39
  };