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.
- package/README.md +2 -1
- package/dist/animation/animator.js +25 -4
- package/dist/animation/embed-namespace.d.ts +52 -0
- package/dist/animation/embed-namespace.js +89 -0
- package/dist/animation/overlay-schema.d.ts +2 -0
- package/dist/animation/overlay-schema.js +13 -2
- package/dist/cli/animate.d.ts +42 -1
- package/dist/cli/animate.js +206 -4
- package/dist/cli/index.js +19 -0
- package/dist/cli/template.d.ts +15 -0
- package/dist/cli/template.js +136 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/render/clip-path.d.ts +9 -0
- package/dist/render/clip-path.js +173 -0
- package/dist/render/element-tree-to-svg.d.ts +3 -99
- package/dist/render/element-tree-to-svg.js +236 -2476
- package/dist/render/font-resolution.d.ts +483 -0
- package/dist/render/font-resolution.js +3046 -0
- package/dist/render/gradient-defs.d.ts +37 -0
- package/dist/render/gradient-defs.js +571 -0
- package/dist/render/image-pattern.d.ts +24 -0
- package/dist/render/image-pattern.js +256 -0
- package/dist/render/mask.d.ts +69 -0
- package/dist/render/mask.js +941 -0
- package/dist/render/stacking.d.ts +99 -0
- package/dist/render/stacking.js +411 -0
- package/dist/render/text-to-path.d.ts +9 -354
- package/dist/render/text-to-path.js +21 -3394
- package/dist/render/unicode-classification.d.ts +51 -0
- package/dist/render/unicode-classification.js +376 -0
- package/dist/templates/builtin/background-loop.d.ts +171 -0
- package/dist/templates/builtin/background-loop.js +500 -0
- package/dist/templates/builtin/chart.d.ts +140 -0
- package/dist/templates/builtin/chart.js +422 -0
- package/dist/templates/builtin/chat.d.ts +67 -0
- package/dist/templates/builtin/chat.js +197 -0
- package/dist/templates/builtin/device-mockup.d.ts +37 -0
- package/dist/templates/builtin/device-mockup.js +56 -0
- package/dist/templates/builtin/kinetic-text.d.ts +109 -0
- package/dist/templates/builtin/kinetic-text.js +333 -0
- package/dist/templates/builtin/lower-third.d.ts +45 -0
- package/dist/templates/builtin/lower-third.js +126 -0
- package/dist/templates/builtin/subscribe.d.ts +46 -0
- package/dist/templates/builtin/subscribe.js +170 -0
- package/dist/templates/index.d.ts +19 -0
- package/dist/templates/index.js +19 -0
- package/dist/templates/json-schema.d.ts +29 -0
- package/dist/templates/json-schema.js +70 -0
- package/dist/templates/registry.d.ts +24 -0
- package/dist/templates/registry.js +62 -0
- package/dist/templates/render.d.ts +24 -0
- package/dist/templates/render.js +86 -0
- package/dist/templates/types.d.ts +111 -0
- package/dist/templates/types.js +30 -0
- package/dist/terminal/pty.d.ts +7 -15
- package/dist/terminal/pty.js +56 -0
- package/llms.txt +107 -0
- package/package.json +4 -3
- 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
|
-
- `
|
|
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)
|
|
843
|
-
//
|
|
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 ${
|
|
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
|
/**
|
package/dist/cli/animate.d.ts
CHANGED
|
@@ -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
|
package/dist/cli/animate.js
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
}
|