domotion-svg 0.14.0 → 0.15.0

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 (60) hide show
  1. package/README.md +2 -1
  2. package/dist/animation/animator.js +25 -4
  3. package/dist/animation/embed-namespace.d.ts +52 -0
  4. package/dist/animation/embed-namespace.js +89 -0
  5. package/dist/animation/overlay-schema.d.ts +2 -0
  6. package/dist/animation/overlay-schema.js +13 -2
  7. package/dist/cli/animate.d.ts +42 -1
  8. package/dist/cli/animate.js +206 -4
  9. package/dist/cli/index.js +19 -0
  10. package/dist/cli/template.d.ts +15 -0
  11. package/dist/cli/template.js +136 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +7 -0
  14. package/dist/render/clip-path.d.ts +9 -0
  15. package/dist/render/clip-path.js +173 -0
  16. package/dist/render/element-tree-to-svg.d.ts +3 -99
  17. package/dist/render/element-tree-to-svg.js +236 -2476
  18. package/dist/render/font-resolution.d.ts +483 -0
  19. package/dist/render/font-resolution.js +3046 -0
  20. package/dist/render/gradient-defs.d.ts +37 -0
  21. package/dist/render/gradient-defs.js +571 -0
  22. package/dist/render/image-pattern.d.ts +24 -0
  23. package/dist/render/image-pattern.js +256 -0
  24. package/dist/render/mask.d.ts +69 -0
  25. package/dist/render/mask.js +941 -0
  26. package/dist/render/stacking.d.ts +99 -0
  27. package/dist/render/stacking.js +411 -0
  28. package/dist/render/text-to-path.d.ts +9 -354
  29. package/dist/render/text-to-path.js +21 -3394
  30. package/dist/render/unicode-classification.d.ts +51 -0
  31. package/dist/render/unicode-classification.js +376 -0
  32. package/dist/templates/builtin/background-loop.d.ts +171 -0
  33. package/dist/templates/builtin/background-loop.js +500 -0
  34. package/dist/templates/builtin/chart.d.ts +140 -0
  35. package/dist/templates/builtin/chart.js +422 -0
  36. package/dist/templates/builtin/chat.d.ts +67 -0
  37. package/dist/templates/builtin/chat.js +197 -0
  38. package/dist/templates/builtin/device-mockup.d.ts +37 -0
  39. package/dist/templates/builtin/device-mockup.js +56 -0
  40. package/dist/templates/builtin/kinetic-text.d.ts +109 -0
  41. package/dist/templates/builtin/kinetic-text.js +333 -0
  42. package/dist/templates/builtin/lower-third.d.ts +45 -0
  43. package/dist/templates/builtin/lower-third.js +126 -0
  44. package/dist/templates/builtin/subscribe.d.ts +46 -0
  45. package/dist/templates/builtin/subscribe.js +170 -0
  46. package/dist/templates/index.d.ts +19 -0
  47. package/dist/templates/index.js +19 -0
  48. package/dist/templates/json-schema.d.ts +29 -0
  49. package/dist/templates/json-schema.js +70 -0
  50. package/dist/templates/registry.d.ts +24 -0
  51. package/dist/templates/registry.js +62 -0
  52. package/dist/templates/render.d.ts +24 -0
  53. package/dist/templates/render.js +86 -0
  54. package/dist/templates/types.d.ts +111 -0
  55. package/dist/templates/types.js +30 -0
  56. package/dist/terminal/pty.d.ts +7 -15
  57. package/dist/terminal/pty.js +56 -0
  58. package/llms.txt +107 -0
  59. package/package.json +4 -3
  60. package/schemas/animate-config.schema.json +24 -4
package/README.md CHANGED
@@ -126,7 +126,8 @@ npm run demos:examples # run the bundled example demo scripts
126
126
 
127
127
  - `FEATURES.md` — per-feature support checklist with links to test fixtures.
128
128
  - `docs/` — requirements docs covering rendering fidelity, supported CSS features, and known caveats.
129
- - `CLAUDE.md` — guidance for AI assistants working in this repo.
129
+ - [`llms.txt`](llms.txt)a concise, self-contained guide for **AI agents using Domotion as a tool** (Claude, Cursor, etc.): the CLIs, the config schema, the template library, the API, and the gotchas. Point your agent at it.
130
+ - `CLAUDE.md` — guidance for AI assistants working *on this repo's* source (different audience from `llms.txt`).
130
131
 
131
132
  ## License
132
133
 
@@ -829,25 +829,46 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
829
829
  return `transform: translateX(${val});`;
830
830
  if (a.property === "translateY")
831
831
  return `transform: translateY(${val});`;
832
+ if (a.property === "scale")
833
+ return `transform: scale(${val});`;
832
834
  if (a.property === "clipPath")
833
835
  return `clip-path: ${val};`;
834
836
  return `${a.property}: ${val};`;
835
837
  };
838
+ // DM-1297: SVG transforms are origin-(0,0); a `transformOrigin` makes a
839
+ // scale/rotate/translate resolve about the element's OWN box (e.g. a
840
+ // center-origin scale-pop) instead of the SVG origin. `transform-box:
841
+ // fill-box` switches the reference box to the element's bounding box.
842
+ const originDecl = a.transformOrigin != null && a.transformOrigin !== ""
843
+ ? ` transform-box: fill-box; transform-origin: ${a.transformOrigin};`
844
+ : "";
836
845
  const animName = `f${i}-${a.animId}-${ai}`;
837
846
  if (a.repeat != null) {
838
847
  // DM-869: repeating animation (blink / pulse / breathe). The keyframe is
839
848
  // a single from→to cycle on the animation's own `duration` clock, looped
840
849
  // via animation-iteration-count + (optional) direction:alternate. The
841
850
  // loop is only visible while the frame is on screen (the frame group's
842
- // visibility gating); `animation-delay` aligns the first cycle to the
843
- // frame's appearance. `fill-mode: both` holds `from` before the delay.
851
+ // visibility gating).
852
+ //
853
+ // `animation-delay` positions the first cycle: a POSITIVE delay (after the
854
+ // frame appears) holds `from` until it elapses then plays; a NEGATIVE delay
855
+ // (a phase offset) starts the loop already mid-cycle so it never freezes —
856
+ // the right choice for a seamless ambient loop (DM-1289).
857
+ //
858
+ // DM-1289: emit timing-function / delay / fill-mode INSIDE the `animation`
859
+ // shorthand, not as trailing longhands. The optimizer (csso) merges shared
860
+ // longhands into a separate, earlier grouped rule; a later `animation`
861
+ // shorthand then resets `animation-fill-mode` back to `none`, so during a
862
+ // positive delay the element showed its base value (not `from`) and SNAPPED
863
+ // when the cycle began. Folding everything into the one shorthand leaves
864
+ // nothing for the optimizer to hoist out of order.
844
865
  const iterations = a.repeat === "infinite" ? "infinite" : String(a.repeat);
845
866
  const direction = a.alternate === true ? " alternate" : "";
846
867
  out.push(` @keyframes ${animName} {
847
868
  0% { ${propValue(a.from)} }
848
869
  100% { ${propValue(a.to)} }
849
870
  }
850
- .anim-${a.animId} { animation: ${animName} ${a.duration}ms ${iterations}${direction}; animation-timing-function: ${easing}; animation-delay: ${startMs.toFixed(0)}ms; animation-fill-mode: both; }`);
871
+ .anim-${a.animId} { animation: ${animName} ${a.duration}ms ${easing} ${startMs.toFixed(0)}ms ${iterations}${direction} both;${originDecl} }`);
851
872
  }
852
873
  else {
853
874
  // One-shot: hold `from` until startPct, animate from→to during
@@ -859,7 +880,7 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
859
880
  ${endPct.toFixed(3)}% { ${propValue(a.to)} }
860
881
  100% { ${propValue(a.to)} }
861
882
  }
862
- .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
883
+ .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing};${originDecl} }`);
863
884
  }
864
885
  }
865
886
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * DM-1287 (doc 73): namespace a self-contained animated SVG so it can be nested
3
+ * INSIDE another animated SVG without its document-global names colliding.
4
+ *
5
+ * A template frame embeds a complete `generateAnimatedSvg` document as one
6
+ * frame's content. SVG/CSS names are document-global — they do NOT scope to a
7
+ * nested `<svg>` subtree — so the inner document's generated names clash with the
8
+ * outer animation's identical names (and with sibling template frames):
9
+ *
10
+ * - **element ids** — `id="viewport-clip"`, `id="f0-bg0"`, glyph defs, gradients,
11
+ * clips, filters (referenced via `url(#…)` / `href="#…"`).
12
+ * - **embedded-font families** — `font-family:"dmf0"` in `@font-face` + the
13
+ * `<text font-family="dmf0">` attrs. A duplicate family name makes a later
14
+ * `@font-face` win, so text in OTHER frames reshapes to the wrong glyphs.
15
+ * - **frame / animation classes** — `class="f f-0"`, `class="anim-…"` and their
16
+ * `.f` / `.f-0` / `.anim-…` style selectors.
17
+ * - **`@keyframes` names** — `fv-0`, the intra-frame `f0-…` names — and the
18
+ * `animation:` references to them. A duplicate `@keyframes` wins globally, so
19
+ * the outer frame-visibility timing gets the inner timeline.
20
+ * - **the `--scene-dur` custom property** on `:root`.
21
+ *
22
+ * The fix: rewrite every such name with a per-frame token. The vocabulary is
23
+ * fully controlled (domotion's own renderer/animator emits it), so the rewrite is
24
+ * precise. The token must be a valid CSS-ident prefix (start with a letter).
25
+ *
26
+ * This is the same class of problem the `cast` frame sidesteps by sharing the
27
+ * outer embedded-font builder (`manageFonts: false`); a template runs a fully
28
+ * independent `composeAnimateConfig`, so it can't share that state and is
29
+ * namespaced after the fact instead.
30
+ */
31
+ /** Options for {@link namespaceEmbeddedAnimatedSvg}. */
32
+ export interface NamespaceEmbedOptions {
33
+ /**
34
+ * Prefix embedded-font family names (`dmfN`) too. Default `true` — correct when
35
+ * the nested document carries its own `@font-face` rules (a template frame).
36
+ *
37
+ * Pass `false` when the nested document's fonts are DEFERRED to the host
38
+ * pipeline's shared embedded-font builder (a `cast` frame composed with
39
+ * `manageFonts: false`): its `font-family="dmfN"` attrs reference an
40
+ * `@font-face` emitted ONCE at the outer top level, where the names are already
41
+ * globally unique — prefixing them here would dangle the reference. The class /
42
+ * keyframe / id / `--scene-dur` collisions still need fixing, so the rest of the
43
+ * pass runs unchanged.
44
+ */
45
+ namespaceFonts?: boolean;
46
+ }
47
+ /**
48
+ * Prefix every document-global name in a complete animated-SVG document with
49
+ * `token` (e.g. `"e1_"`). Returns the rewritten SVG string; the input is assumed
50
+ * to be a single `<svg>…</svg>` document as produced by `generateAnimatedSvg`.
51
+ */
52
+ export declare function namespaceEmbeddedAnimatedSvg(svg: string, token: string, opts?: NamespaceEmbedOptions): string;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * DM-1287 (doc 73): namespace a self-contained animated SVG so it can be nested
3
+ * INSIDE another animated SVG without its document-global names colliding.
4
+ *
5
+ * A template frame embeds a complete `generateAnimatedSvg` document as one
6
+ * frame's content. SVG/CSS names are document-global — they do NOT scope to a
7
+ * nested `<svg>` subtree — so the inner document's generated names clash with the
8
+ * outer animation's identical names (and with sibling template frames):
9
+ *
10
+ * - **element ids** — `id="viewport-clip"`, `id="f0-bg0"`, glyph defs, gradients,
11
+ * clips, filters (referenced via `url(#…)` / `href="#…"`).
12
+ * - **embedded-font families** — `font-family:"dmf0"` in `@font-face` + the
13
+ * `<text font-family="dmf0">` attrs. A duplicate family name makes a later
14
+ * `@font-face` win, so text in OTHER frames reshapes to the wrong glyphs.
15
+ * - **frame / animation classes** — `class="f f-0"`, `class="anim-…"` and their
16
+ * `.f` / `.f-0` / `.anim-…` style selectors.
17
+ * - **`@keyframes` names** — `fv-0`, the intra-frame `f0-…` names — and the
18
+ * `animation:` references to them. A duplicate `@keyframes` wins globally, so
19
+ * the outer frame-visibility timing gets the inner timeline.
20
+ * - **the `--scene-dur` custom property** on `:root`.
21
+ *
22
+ * The fix: rewrite every such name with a per-frame token. The vocabulary is
23
+ * fully controlled (domotion's own renderer/animator emits it), so the rewrite is
24
+ * precise. The token must be a valid CSS-ident prefix (start with a letter).
25
+ *
26
+ * This is the same class of problem the `cast` frame sidesteps by sharing the
27
+ * outer embedded-font builder (`manageFonts: false`); a template runs a fully
28
+ * independent `composeAnimateConfig`, so it can't share that state and is
29
+ * namespaced after the fact instead.
30
+ */
31
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32
+ const byLengthDesc = (a, b) => b.length - a.length;
33
+ /**
34
+ * Prefix every document-global name in a complete animated-SVG document with
35
+ * `token` (e.g. `"e1_"`). Returns the rewritten SVG string; the input is assumed
36
+ * to be a single `<svg>…</svg>` document as produced by `generateAnimatedSvg`.
37
+ */
38
+ export function namespaceEmbeddedAnimatedSvg(svg, token, opts = {}) {
39
+ let out = svg;
40
+ // 1. Element ids + every reference (url(#id) / (xlink:)href="#id"). Longest
41
+ // name first so an id that is a textual prefix of another can't partial-match.
42
+ const ids = new Set();
43
+ for (const m of out.matchAll(/\sid="([^"]+)"/g))
44
+ ids.add(m[1]);
45
+ for (const id of [...ids].sort(byLengthDesc)) {
46
+ const e = escapeRe(id);
47
+ out = out
48
+ .replace(new RegExp(`(\\sid=")${e}(")`, "g"), `$1${token}${id}$2`)
49
+ .replace(new RegExp(`url\\(#${e}\\)`, "g"), `url(#${token}${id})`)
50
+ .replace(new RegExp(`((?:xlink:)?href=")#${e}(")`, "g"), `$1#${token}${id}$2`);
51
+ }
52
+ // 2. Embedded-font families (`dmfN`). Only the `@font-face` declaration and the
53
+ // `font-family` attr/decl contexts — never a bare global replace, since the
54
+ // base64 `src` payload could contain the same byte sequence. Skipped when the
55
+ // fonts are deferred to a shared outer builder (see NamespaceEmbedOptions).
56
+ if (opts.namespaceFonts !== false) {
57
+ out = out
58
+ .replace(/(font-family:\s*")(dmf\d+)(")/g, `$1${token}$2$3`)
59
+ .replace(/(font-family=")(dmf\d+)(")/g, `$1${token}$2$3`);
60
+ }
61
+ // 3. `@keyframes` names + their `animation:` references. A keyframe name is a
62
+ // domotion-generated token (`fv-0`, `f0-f0a0-0`, …) that never appears as an
63
+ // id, class, or inside the (hyphen-free) base64 font data, so a word-boundary
64
+ // replace across the whole doc is safe. Longest-first for prefix safety.
65
+ const keyframes = new Set();
66
+ for (const m of out.matchAll(/@keyframes\s+([A-Za-z0-9_-]+)/g))
67
+ keyframes.add(m[1]);
68
+ for (const name of [...keyframes].sort(byLengthDesc)) {
69
+ out = out.replace(new RegExp(`\\b${escapeRe(name)}\\b`, "g"), `${token}${name}`);
70
+ }
71
+ // 4. Classes. Collect the names from `class="…"` attrs (every token is renderer-
72
+ // generated), then (a) prefix each token in every class attr, and (b) prefix
73
+ // each known name where it appears as a `.name` selector. The selector pass is
74
+ // boundary-guarded (`(?![\w-])`) and limited to the collected names so it can
75
+ // never touch a decimal like `0.22` / `22.500%` in the CSS.
76
+ const classes = new Set();
77
+ for (const m of out.matchAll(/\sclass="([^"]+)"/g)) {
78
+ for (const t of m[1].split(/\s+/))
79
+ if (t !== "")
80
+ classes.add(t);
81
+ }
82
+ out = out.replace(/(\sclass=")([^"]+)(")/g, (_full, a, cls, c) => a + cls.split(/\s+/).map((t) => (t !== "" ? token + t : t)).join(" ") + c);
83
+ for (const name of [...classes].sort(byLengthDesc)) {
84
+ out = out.replace(new RegExp(`\\.${escapeRe(name)}(?![\\w-])`, "g"), `.${token}${name}`);
85
+ }
86
+ // 5. The `--scene-dur` custom property (declaration on `:root` + any `var()`).
87
+ out = out.replace(/--scene-dur\b/g, `--scene-dur-${token}`);
88
+ return out;
89
+ }
@@ -249,11 +249,13 @@ export declare const intraFrameAnimationSchema: z.ZodObject<{
249
249
  opacity: "opacity";
250
250
  translateX: "translateX";
251
251
  translateY: "translateY";
252
+ scale: "scale";
252
253
  }>;
253
254
  from: z.ZodString;
254
255
  to: z.ZodString;
255
256
  duration: z.ZodNumber;
256
257
  easing: z.ZodOptional<z.ZodString>;
258
+ transformOrigin: z.ZodOptional<z.ZodString>;
257
259
  delay: z.ZodOptional<z.ZodNumber>;
258
260
  repeat: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"infinite">]>>;
259
261
  alternate: z.ZodOptional<z.ZodBoolean>;
@@ -189,8 +189,9 @@ export const intraFrameAnimationSchema = z.object({
189
189
  * captured element is wrapped in a `<g class="anim-<id>">`, the keyframes
190
190
  * apply `clip-path` to that wrapper.
191
191
  */
192
- property: z.enum(["width", "height", "opacity", "transform", "translateX", "translateY", "clipPath"]),
193
- /** Start value (CSS string, e.g. `"0%"`, `"240px"`, `"0.3"`). */
192
+ property: z.enum(["width", "height", "opacity", "transform", "translateX", "translateY", "scale", "clipPath"]),
193
+ /** Start value (CSS string, e.g. `"0%"`, `"240px"`, `"0.3"`). For `scale`,
194
+ * a unitless factor (`"0.6"` -> `"1"`). */
194
195
  from: z.string(),
195
196
  /** End value (same syntax as `from`). */
196
197
  to: z.string(),
@@ -198,6 +199,16 @@ export const intraFrameAnimationSchema = z.object({
198
199
  duration: z.number(),
199
200
  /** CSS easing string. Default `linear`. */
200
201
  easing: z.string().optional(),
202
+ /**
203
+ * DM-1297: transform-origin for a `transform` / `scale` / `translate*`
204
+ * animation (e.g. `"center"`, `"50% 50%"`, `"left top"`). SVG transforms are
205
+ * origin-(0,0) by default, so a `scale`/`rotate` would shrink/orbit toward the
206
+ * SVG origin instead of the element's own box. Setting this emits
207
+ * `transform-box: fill-box; transform-origin: <value>` on the animated group so
208
+ * the transform resolves about the element's OWN bounding box — e.g. a
209
+ * center-origin scale-pop. Ignored for non-transform properties.
210
+ */
211
+ transformOrigin: z.string().optional(),
201
212
  /** Ms after the frame becomes visible before animation starts. Default 0. */
202
213
  delay: z.number().optional(),
203
214
  /**
@@ -239,8 +239,15 @@ export declare const animateConfigSchema: z.ZodObject<{
239
239
  maxFrameMs: z.ZodOptional<z.ZodNumber>;
240
240
  tailMs: z.ZodOptional<z.ZodNumber>;
241
241
  }, z.core.$strip>>;
242
+ template: z.ZodOptional<z.ZodString>;
243
+ params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
244
+ fit: z.ZodOptional<z.ZodEnum<{
245
+ center: "center";
246
+ cover: "cover";
247
+ contain: "contain";
248
+ }>>;
242
249
  continue: z.ZodOptional<z.ZodBoolean>;
243
- duration: z.ZodNumber;
250
+ duration: z.ZodDefault<z.ZodNumber>;
244
251
  transition: z.ZodOptional<z.ZodObject<{
245
252
  type: z.ZodEnum<{
246
253
  scroll: "scroll";
@@ -540,7 +547,9 @@ export declare const animateConfigSchema: z.ZodObject<{
540
547
  opacity: "opacity";
541
548
  translateX: "translateX";
542
549
  translateY: "translateY";
550
+ scale: "scale";
543
551
  }>;
552
+ transformOrigin: z.ZodOptional<z.ZodString>;
544
553
  alternate: z.ZodOptional<z.ZodBoolean>;
545
554
  selector: z.ZodString;
546
555
  repeat: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"infinite">]>>;
@@ -606,6 +615,38 @@ export interface ComposeAnimateOptions {
606
615
  * a frame's relative `input` / svg-overlay `src` paths (default `process.cwd()`);
607
616
  * `log` defaults to a no-op.
608
617
  */
618
+ /**
619
+ * DM-1287 (doc 73): render every `template` frame's named template to a finished
620
+ * SVG string, ready to nest as that frame's `svgContent`. Returns a map keyed by
621
+ * frame index (only template frames appear).
622
+ *
623
+ * Runs BEFORE the caller's outer font lifecycle so the nested per-template
624
+ * `composeAnimateFrames` (a template is a front-end onto the same engine) can
625
+ * clear + manage the module-global font builders without clobbering the outer
626
+ * run's frames — each template's output carries its own `@font-face`.
627
+ *
628
+ * Sizing: the template inherits the config's `width`/`height` when its params
629
+ * schema declares those fields and the caller left them unset, so it fills the
630
+ * frame by default. A template whose output differs from the canvas (e.g.
631
+ * `device-mockup`, which grows by its bezel) is centered; an oversized output is
632
+ * centered and clipped by the frame viewport. The template's own internal
633
+ * timeline plays within the frame's `duration` (size `duration` to ≈ the
634
+ * template's play time, same rule as a `cast` frame).
635
+ *
636
+ * Loaded via dynamic `import()` to avoid a static import cycle (the template
637
+ * subsystem already imports `composeAnimateConfig` from this module).
638
+ */
639
+ /**
640
+ * DM-1293: place a nested frame's `content` (a `srcW × srcH` SVG body) inside a
641
+ * `dstW × dstH` canvas per the `fit` policy, wrapping it in a `<g transform>` when
642
+ * a translate/scale is needed (and returning it untouched when neither is — the
643
+ * exact-fit common case). All modes keep the content centered:
644
+ * - `center` — 1:1, no scale (oversized content is clipped by the frame viewport).
645
+ * - `contain` — scale to fit, preserving aspect (letterboxed).
646
+ * - `cover` — scale to fill, preserving aspect (the overflow is clipped).
647
+ * Exported for unit testing the geometry without a browser.
648
+ */
649
+ export declare function placeEmbeddedFrame(content: string, srcW: number, srcH: number, dstW: number, dstH: number, fit?: "center" | "contain" | "cover"): string;
609
650
  export declare function composeAnimateFrames(browser: Browser, cfg: AnimateConfig, configDirOrOpts?: string | ComposeAnimateOptions, logArg?: (msg: string) => void): Promise<AnimationConfig>;
610
651
  /**
611
652
  * Capture and compose every frame in `cfg` into one animated SVG string
@@ -27,6 +27,7 @@ import { composeScrollSvg, executeScrollPattern, parseScrollPattern } from "../s
27
27
  import { cullElementsOutsideViewBox } from "../tree-ops/index.js";
28
28
  import { optimizeSvg } from "../post-processing/index.js";
29
29
  import { frameAdvanceMs } from "../animation/frame-timeline.js";
30
+ import { namespaceEmbeddedAnimatedSvg } from "../animation/embed-namespace.js";
30
31
  import { castToAnimatedSvg } from "../terminal/index.js";
31
32
  import { applyReadyWaits, isSvgzPath, loadInputIntoPage, makeLogger, resolveOutputPath, timed, writeOutput, } from "./common.js";
32
33
  // ── Config schema (DM-843) ──────────────────────────────────────────────────
@@ -206,8 +207,29 @@ const frameSchema = z.object({
206
207
  // recorded length (the tool logs it). Mutually exclusive with `input`.
207
208
  cast: z.string().optional(),
208
209
  term: termOptionsSchema.optional(),
210
+ // DM-1287 (doc 73): a `template` frame embeds a named Domotion template's
211
+ // output (e.g. a `lower-third` banner or a `kinetic-text` title) as this
212
+ // frame's content — most templates emit an animated SVG, which nests like a
213
+ // `cast` frame. `params` is validated against the named template's own schema
214
+ // at compose time (path-specific errors). The template inherits the config's
215
+ // `width`/`height` when its schema has those params and they're unset, so it
216
+ // fills the frame by default; a smaller/larger output is centered (+ clipped).
217
+ // Mutually exclusive with `input` / `cast` / `continue`.
218
+ template: z.string().optional(),
219
+ params: z.record(z.string(), z.unknown()).optional(),
220
+ // DM-1293: how a `template` frame's output is placed when its size differs from
221
+ // the canvas. `center` (default) places it 1:1 (oversized → clipped); `contain`
222
+ // scales it down to fit, preserving aspect (letterboxed); `cover` scales it up
223
+ // to fill, preserving aspect (cropped). Only meaningful on a template frame.
224
+ fit: z.enum(["center", "contain", "cover"]).optional(),
209
225
  continue: z.boolean().optional(),
210
- duration: z.number(),
226
+ // DM-1294: `duration` is required on every frame EXCEPT a `template` frame,
227
+ // which may omit it to inherit the template's own play time (its generator
228
+ // reports `durationMs`). Defaults to `0` (a sentinel for "unset" — a 0 ms frame
229
+ // is never valid), so the type stays `number` for the timeline math; the
230
+ // "required-and-positive unless template" rule is enforced in the config-level
231
+ // superRefine below (it needs the sibling `template` field).
232
+ duration: z.number().default(0),
211
233
  transition: transitionSchema.optional(),
212
234
  selector: z.string().optional(),
213
235
  wait: z.number().optional(),
@@ -289,8 +311,8 @@ export const animateConfigSchema = z
289
311
  .superRefine((cfg, ctx) => {
290
312
  // DM-846 §1 cross-frame rules for the continuous-session model.
291
313
  cfg.frames.forEach((f, i) => {
292
- if (i === 0 && f.input == null && f.cast == null) {
293
- ctx.addIssue({ code: "custom", path: ["frames", 0, "input"], message: "frame 0 must load an `input` or a `cast`" });
314
+ if (i === 0 && f.input == null && f.cast == null && f.template == null) {
315
+ ctx.addIssue({ code: "custom", path: ["frames", 0, "input"], message: "frame 0 must load an `input`, a `cast`, or a `template`" });
294
316
  }
295
317
  if (i === 0 && f.continue === true) {
296
318
  ctx.addIssue({ code: "custom", path: ["frames", 0, "continue"], message: "frame 0 cannot continue — it has no predecessor" });
@@ -306,6 +328,31 @@ export const animateConfigSchema = z
306
328
  if (f.cast != null && f.continue === true) {
307
329
  ctx.addIssue({ code: "custom", path: ["frames", i, "cast"], message: "a `cast` frame cannot also `continue` a live page" });
308
330
  }
331
+ // DM-1287: a `template` frame is its own content source — it can't also
332
+ // load an `input`, embed a `cast`, or continue a live page. `params`
333
+ // without a `template` has nothing to validate against.
334
+ if (f.template != null && f.input != null) {
335
+ ctx.addIssue({ code: "custom", path: ["frames", i, "template"], message: "a frame cannot set both `template` and `input`" });
336
+ }
337
+ if (f.template != null && f.cast != null) {
338
+ ctx.addIssue({ code: "custom", path: ["frames", i, "template"], message: "a frame cannot set both `template` and `cast`" });
339
+ }
340
+ if (f.template != null && f.continue === true) {
341
+ ctx.addIssue({ code: "custom", path: ["frames", i, "template"], message: "a `template` frame cannot also `continue` a live page" });
342
+ }
343
+ if (f.params != null && f.template == null) {
344
+ ctx.addIssue({ code: "custom", path: ["frames", i, "params"], message: "`params` requires a `template`" });
345
+ }
346
+ // DM-1293: `fit` only governs how a template frame's output is placed.
347
+ if (f.fit != null && f.template == null) {
348
+ ctx.addIssue({ code: "custom", path: ["frames", i, "fit"], message: "`fit` requires a `template`" });
349
+ }
350
+ // DM-1294: `duration` is required (and positive) except on a `template`
351
+ // frame, which derives it from the template's play time when omitted (the
352
+ // `0` default is the "unset" sentinel).
353
+ if (f.duration <= 0 && f.template == null) {
354
+ ctx.addIssue({ code: "custom", path: ["frames", i, "duration"], message: "`duration` is required and must be > 0 (only a `template` frame may omit it — it inherits the template's play time)" });
355
+ }
309
356
  });
310
357
  });
311
358
  export async function runAnimate(args, help) {
@@ -392,10 +439,138 @@ function normalizeComposeArgs(configDirOrOpts, log) {
392
439
  * a frame's relative `input` / svg-overlay `src` paths (default `process.cwd()`);
393
440
  * `log` defaults to a no-op.
394
441
  */
442
+ /**
443
+ * DM-1287 (doc 73): render every `template` frame's named template to a finished
444
+ * SVG string, ready to nest as that frame's `svgContent`. Returns a map keyed by
445
+ * frame index (only template frames appear).
446
+ *
447
+ * Runs BEFORE the caller's outer font lifecycle so the nested per-template
448
+ * `composeAnimateFrames` (a template is a front-end onto the same engine) can
449
+ * clear + manage the module-global font builders without clobbering the outer
450
+ * run's frames — each template's output carries its own `@font-face`.
451
+ *
452
+ * Sizing: the template inherits the config's `width`/`height` when its params
453
+ * schema declares those fields and the caller left them unset, so it fills the
454
+ * frame by default. A template whose output differs from the canvas (e.g.
455
+ * `device-mockup`, which grows by its bezel) is centered; an oversized output is
456
+ * centered and clipped by the frame viewport. The template's own internal
457
+ * timeline plays within the frame's `duration` (size `duration` to ≈ the
458
+ * template's play time, same rule as a `cast` frame).
459
+ *
460
+ * Loaded via dynamic `import()` to avoid a static import cycle (the template
461
+ * subsystem already imports `composeAnimateConfig` from this module).
462
+ */
463
+ /**
464
+ * DM-1293: place a nested frame's `content` (a `srcW × srcH` SVG body) inside a
465
+ * `dstW × dstH` canvas per the `fit` policy, wrapping it in a `<g transform>` when
466
+ * a translate/scale is needed (and returning it untouched when neither is — the
467
+ * exact-fit common case). All modes keep the content centered:
468
+ * - `center` — 1:1, no scale (oversized content is clipped by the frame viewport).
469
+ * - `contain` — scale to fit, preserving aspect (letterboxed).
470
+ * - `cover` — scale to fill, preserving aspect (the overflow is clipped).
471
+ * Exported for unit testing the geometry without a browser.
472
+ */
473
+ export function placeEmbeddedFrame(content, srcW, srcH, dstW, dstH, fit = "center") {
474
+ const r = (n) => Math.round(n * 1000) / 1000;
475
+ let scale = 1;
476
+ if (fit === "contain")
477
+ scale = Math.min(dstW / srcW, dstH / srcH);
478
+ else if (fit === "cover")
479
+ scale = Math.max(dstW / srcW, dstH / srcH);
480
+ const ox = r((dstW - srcW * scale) / 2);
481
+ const oy = r((dstH - srcH * scale) / 2);
482
+ const parts = [];
483
+ if (ox !== 0 || oy !== 0)
484
+ parts.push(`translate(${ox},${oy})`);
485
+ // `transform` applies right-to-left, so `translate(…) scale(…)` scales first
486
+ // (about the content's own origin) then offsets — i.e. the scaled box is centered.
487
+ if (scale !== 1)
488
+ parts.push(`scale(${r(scale)})`);
489
+ return parts.length > 0 ? `<g transform="${parts.join(" ")}">${content}</g>` : content;
490
+ }
491
+ async function renderTemplateFrames(cfg, browser, log) {
492
+ const out = new Map();
493
+ const idxs = cfg.frames.flatMap((f, i) => (f.template != null ? [i] : []));
494
+ if (idxs.length === 0)
495
+ return out;
496
+ const { loadTemplate } = await import("../templates/registry.js");
497
+ const { renderTemplateToSvg } = await import("../templates/render.js");
498
+ for (const i of idxs) {
499
+ const fc = cfg.frames[i];
500
+ const name = fc.template;
501
+ log(`Rendering template "${name}" for frame ${i + 1}/${cfg.frames.length}…`);
502
+ let template;
503
+ try {
504
+ template = await loadTemplate(name);
505
+ }
506
+ catch (e) {
507
+ throw new Error(`animate: frames[${i}].template: ${e.message}`);
508
+ }
509
+ // Inherit the canvas size into the template's `width`/`height` params when
510
+ // its schema declares them and the caller didn't set them, so the template
511
+ // fills the frame. Introspect the zod object shape; templates without those
512
+ // params (or non-object schemas) just get no injection.
513
+ const shape = template.paramsSchema.shape;
514
+ const base = {};
515
+ if (shape != null && Object.prototype.hasOwnProperty.call(shape, "width"))
516
+ base.width = cfg.width;
517
+ if (shape != null && Object.prototype.hasOwnProperty.call(shape, "height"))
518
+ base.height = cfg.height;
519
+ const rawParams = { ...base, ...(fc.params ?? {}) };
520
+ let result;
521
+ try {
522
+ result = await renderTemplateToSvg(template, rawParams, { browser, log: (m) => log(` ${m}`) });
523
+ }
524
+ catch (e) {
525
+ // Param-validation errors already carry their own `template "x": …` path.
526
+ throw new Error(`animate: frames[${i}]: ${e.message}`);
527
+ }
528
+ const fit = fc.fit ?? "center";
529
+ if (fit === "center" && (result.width > cfg.width || result.height > cfg.height)) {
530
+ log(` note: template output ${result.width}×${result.height} exceeds the ${cfg.width}×${cfg.height} canvas — it will be centered and clipped (set "fit":"contain" to scale it down)`);
531
+ }
532
+ // DM-1294: resolve the frame's duration. When the author omitted it, inherit
533
+ // the template's own play time (`durationMs`); a static template (no intrinsic
534
+ // duration) MUST carry an explicit `duration`. When the author set one that's
535
+ // shorter than the template plays, warn — the template will be cut off (same
536
+ // rule as a `cast` frame).
537
+ if (fc.duration <= 0) {
538
+ if (result.durationMs == null) {
539
+ throw new Error(`animate: frames[${i}].duration: template "${name}" has no intrinsic play time (it's a static template) — set an explicit "duration"`);
540
+ }
541
+ fc.duration = result.durationMs;
542
+ log(` frame duration defaulted to the template's play time: ${result.durationMs}ms`);
543
+ }
544
+ else if (result.durationMs != null && fc.duration < result.durationMs) {
545
+ log(` note: frame duration ${fc.duration}ms < template play time ${result.durationMs}ms — the template will be cut off; size duration to ≈ ${result.durationMs}ms`);
546
+ }
547
+ // Namespace the template's document-global names (ids, font families, frame
548
+ // classes, @keyframes, --scene-dur) with a per-frame token so they can't
549
+ // collide with the outer animation or sibling template frames once nested
550
+ // into one document (a template is a full `generateAnimatedSvg` SVG, and
551
+ // SVG/CSS names are document-global, not scoped to a nested `<svg>`).
552
+ let content = namespaceEmbeddedAnimatedSvg(result.svg, `tf${i}_`);
553
+ // Strip the XML prolog so the `<svg>` nests cleanly in the animator's frame
554
+ // group (same as a `cast` frame). Center within the canvas when smaller.
555
+ content = content.replace(/^<\?xml[^>]*\?>\s*/, "");
556
+ content = placeEmbeddedFrame(content, result.width, result.height, cfg.width, cfg.height, fit);
557
+ out.set(i, content);
558
+ }
559
+ return out;
560
+ }
395
561
  export async function composeAnimateFrames(browser, cfg, configDirOrOpts, logArg) {
396
562
  const { configDir, log, onFrame } = normalizeComposeArgs(configDirOrOpts, logArg);
397
563
  // DM-852: resolve `${vars}` across every string field before anything runs.
398
564
  cfg = interpolateConfigVars(cfg);
565
+ // DM-1287 (doc 73): render `template` frames UP FRONT, before the outer run's
566
+ // font lifecycle (clearWebfonts / clearEmbeddedFonts) starts below. A template
567
+ // is itself a front-end onto `composeAnimateConfig`, so rendering one runs a
568
+ // NESTED `composeAnimateFrames` that clears + manages the module-global font
569
+ // builders. Doing it here — before the outer clears — keeps each template's
570
+ // output fully self-contained (its own `@font-face`) and stops the nested run
571
+ // from clobbering the outer frames' embedded fonts. Each rendered template SVG
572
+ // is a finished string by the time the outer loop reaches its frame.
573
+ const templateRenders = await renderTemplateFrames(cfg, browser, log);
399
574
  const ctx = await browser.newContext({
400
575
  viewport: { width: cfg.width, height: cfg.height },
401
576
  isMobile: cfg.mobile === true,
@@ -482,7 +657,19 @@ export async function composeAnimateFrames(browser, cfg, configDirOrOpts, logArg
482
657
  if (fc.duration < totalDurationMs) {
483
658
  log(` note: frame duration ${fc.duration}ms < cast play time ${totalDurationMs}ms — the terminal will be cut off; size duration to ≈ ${totalDurationMs}ms`);
484
659
  }
485
- const termSvg = castSvg;
660
+ // DM-1292: the cast SVG is a full `generateAnimatedSvg` document, so its
661
+ // document-global names (ids, `.f-N` frame classes + `@keyframes fv-N` in
662
+ // `mode: "full"`, the incremental `ln…` / `tcur…` keyframes, `--scene-dur`)
663
+ // collide with the outer animation's identical names, or with a sibling
664
+ // cast frame's, once concatenated — a duplicate `@keyframes`/rule wins
665
+ // globally and hijacks the wrong frame's timeline (visible when the
666
+ // timeline is SEEKED, like the DM-1145 id-collision bug). Namespace them
667
+ // with a per-frame token, exactly like a `template` frame. Fonts are the
668
+ // ONE exception: `manageFonts: false` defers them to this pipeline's shared
669
+ // embedded-font builder (one `@font-face` block, already-unique `dmfN`
670
+ // names) collected after the loop, so we must NOT prefix the cast's
671
+ // `font-family` references or they'd dangle.
672
+ const termSvg = namespaceEmbeddedAnimatedSvg(castSvg, `cf${i}_`, { namespaceFonts: false });
486
673
  // The animator wraps `svgContent` in `<g class="f f-N">`, which holds a
487
674
  // nested `<svg>` fine — strip just the XML prolog (same as scroll).
488
675
  frames.push({
@@ -496,6 +683,20 @@ export async function composeAnimateFrames(browser, cfg, configDirOrOpts, logArg
496
683
  frameTrees.push(null);
497
684
  continue;
498
685
  }
686
+ // DM-1287 (doc 73): a `template` frame embeds a named template's output,
687
+ // pre-rendered above into a finished (self-contained, possibly animated)
688
+ // SVG string. Nest it exactly like a `cast` frame.
689
+ if (fc.template != null) {
690
+ log(`Frame ${i + 1}/${cfg.frames.length}: embedding template "${fc.template}"…`);
691
+ frames.push({
692
+ svgContent: templateRenders.get(i),
693
+ duration: fc.duration,
694
+ transition: fc.transition,
695
+ });
696
+ prevFrameTree = null;
697
+ frameTrees.push(null);
698
+ continue;
699
+ }
499
700
  // DM-846 §1: a continued frame (explicit `continue: true`, or a non-first
500
701
  // frame that omits `input`) captures the previous frame's live page after
501
702
  // running its own actions, instead of reloading. The page persists across
@@ -575,6 +776,7 @@ export async function composeAnimateFrames(browser, cfg, configDirOrOpts, logArg
575
776
  delay: a.delay,
576
777
  repeat: a.repeat,
577
778
  alternate: a.alternate,
779
+ transformOrigin: a.transformOrigin,
578
780
  });
579
781
  }
580
782
  }