domotion-svg 0.4.2 → 0.5.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 +1 -1
- package/dist/animation/animator.d.ts +45 -1
- package/dist/animation/animator.js +122 -73
- package/dist/animation/animator.test.js +100 -28
- package/dist/capture/script/index.js +9 -1
- package/dist/capture/script.generated.js +1 -1
- package/dist/cli/animate.d.ts +377 -0
- package/dist/cli/animate.js +709 -199
- package/dist/cli/animate.test.d.ts +7 -0
- package/dist/cli/animate.test.js +185 -0
- package/dist/cli/common.d.ts +25 -2
- package/dist/cli/common.js +57 -0
- package/dist/cli/index.js +49 -36
- package/dist/render/form-controls.js +2 -2
- package/dist/render/form-controls.test.d.ts +7 -0
- package/dist/render/form-controls.test.js +37 -0
- package/package.json +5 -3
- package/src/animation/animator.test.ts +105 -28
- package/src/animation/animator.ts +181 -86
- package/src/capture/script/index.ts +9 -1
- package/src/capture/script.generated.ts +1 -1
- package/src/cli/animate.test.ts +225 -0
- package/src/cli/animate.ts +669 -234
- package/src/cli/common.ts +71 -1
- package/src/cli/index.ts +49 -36
- package/src/render/form-controls.test.ts +42 -0
- package/src/render/form-controls.ts +2 -2
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ For a multi-frame animated SVG, write a small JSON config and run `domotion anim
|
|
|
59
59
|
domotion animate ./demo.json
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
The config describes each frame (input
|
|
62
|
+
The config describes each frame (input, duration, transition) plus a declarative surface for interaction demos: continuous-session frames that carry client-side state across steps (omit `input` / set `"continue": true`), DOM-mutation and interaction actions, richer readiness waits (`waitForText` / `waitForGone` / `waitForCount`), typing / tap / svg / blink overlays that can anchor to an element's box, an on-screen `cursor` (explicit or `"auto"`), `vars` + `${}` interpolation, and a small `evaluate` escape hatch. See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
|
|
63
63
|
|
|
64
64
|
### Scripting API
|
|
65
65
|
|
|
@@ -69,6 +69,17 @@ export interface TypingOverlay {
|
|
|
69
69
|
* sits on a clean background.
|
|
70
70
|
*/
|
|
71
71
|
bgHeight?: number;
|
|
72
|
+
/**
|
|
73
|
+
* DM-870: render a blinking insertion caret. The bar sweeps the type
|
|
74
|
+
* position while typing, then parks at the end of the text and blinks
|
|
75
|
+
* (opacity 1↔0) until the frame ends. `true` uses defaults (the typing
|
|
76
|
+
* `color`, 2px wide, ~530ms cadence); an object overrides them.
|
|
77
|
+
*/
|
|
78
|
+
caret?: boolean | {
|
|
79
|
+
color?: string;
|
|
80
|
+
width?: number;
|
|
81
|
+
blinkMs?: number;
|
|
82
|
+
};
|
|
72
83
|
}
|
|
73
84
|
export interface TapOverlay {
|
|
74
85
|
kind: "tap";
|
|
@@ -122,7 +133,29 @@ export interface SvgOverlay {
|
|
|
122
133
|
delay?: number;
|
|
123
134
|
};
|
|
124
135
|
}
|
|
125
|
-
|
|
136
|
+
/**
|
|
137
|
+
* DM-871: a standalone blinking bar/box, for carets/dots not tied to a typing
|
|
138
|
+
* overlay — a recording dot, an attention pulse on a focused field, a cursor.
|
|
139
|
+
* Renders a rect that toggles opacity on a `periodMs` cycle for the frame's
|
|
140
|
+
* hold (sugar over a rect + a repeating opacity animation).
|
|
141
|
+
*/
|
|
142
|
+
export interface BlinkOverlay {
|
|
143
|
+
kind: "blink";
|
|
144
|
+
/** Top-left corner in the captured frame's coordinate space. */
|
|
145
|
+
x: number;
|
|
146
|
+
y: number;
|
|
147
|
+
width: number;
|
|
148
|
+
height: number;
|
|
149
|
+
/** Full on/off cycle in ms (default 1000). */
|
|
150
|
+
periodMs?: number;
|
|
151
|
+
/** Fill color (default a light gray). */
|
|
152
|
+
color?: string;
|
|
153
|
+
/** Corner radius — set to half the width/height for a dot. */
|
|
154
|
+
radius?: number;
|
|
155
|
+
/** Ms after the frame becomes visible before blinking starts. Default 0. */
|
|
156
|
+
delay?: number;
|
|
157
|
+
}
|
|
158
|
+
export type AnimationOverlay = TypingOverlay | TapOverlay | SvgOverlay | BlinkOverlay;
|
|
126
159
|
/**
|
|
127
160
|
* Animate a CSS property on captured elements that match a selector, while
|
|
128
161
|
* the frame is held on screen. The selector is resolved against the source
|
|
@@ -154,6 +187,17 @@ export interface IntraFrameAnimation {
|
|
|
154
187
|
easing?: string;
|
|
155
188
|
/** Ms after the frame becomes visible before animation starts. Default 0. */
|
|
156
189
|
delay?: number;
|
|
190
|
+
/**
|
|
191
|
+
* DM-869: repeat count. A positive integer or `"infinite"`. When set, the
|
|
192
|
+
* animation loops on its own `duration` clock (CSS `animation-iteration-count`)
|
|
193
|
+
* rather than playing once — turning a property animation into a blink / pulse
|
|
194
|
+
* / breathe. The loop is only visible while the frame is on screen (the frame
|
|
195
|
+
* group's visibility gating). `"infinite"` is the robust choice for a looping
|
|
196
|
+
* scene; a finite count aligns to the frame's first appearance.
|
|
197
|
+
*/
|
|
198
|
+
repeat?: number | "infinite";
|
|
199
|
+
/** DM-869: when true, the loop ping-pongs `from`→`to`→`from` (CSS `animation-direction: alternate`). */
|
|
200
|
+
alternate?: boolean;
|
|
157
201
|
}
|
|
158
202
|
export interface AnimationConfig {
|
|
159
203
|
width: number;
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Takes captured SVG frame content and composes them into a single
|
|
5
5
|
* animated SVG with CSS keyframe transitions.
|
|
6
6
|
*/
|
|
7
|
-
import { mergeFrames } from "../tree-ops/frame-merge.js";
|
|
8
7
|
import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
|
|
9
8
|
export function generateAnimatedSvg(config) {
|
|
10
9
|
const { width, height, frames } = config;
|
|
@@ -25,19 +24,20 @@ export function generateAnimatedSvg(config) {
|
|
|
25
24
|
t += f.duration + td;
|
|
26
25
|
}
|
|
27
26
|
}
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
// Every sequence composites: each frame is emitted as a complete, internally
|
|
28
|
+
// z-ordered `<g class="f f-N">` sub-SVG and switched/faded by opacity.
|
|
29
|
+
//
|
|
30
|
+
// There used to be an element-merge fast path (`mergeFrames`) for cut-only
|
|
31
|
+
// sequences that flattened all frames into one de-duplicated tree to save
|
|
32
|
+
// bytes. DM-854 took crossfade off it (it dropped per-frame z-order and
|
|
33
|
+
// step-end-switched instead of fading); DM-865 then showed it also mis-renders
|
|
34
|
+
// *near-identical* frames — the same DOM evolved across frames, as produced by
|
|
35
|
+
// continuous-session capture — because differing text in a shared element slot
|
|
36
|
+
// can't be gated per frame (a bare text node carries no class, and a `<tspan>`
|
|
37
|
+
// with `visibility:hidden` still advances layout, shifting the surviving
|
|
38
|
+
// glyph). Compositing has neither problem. The dedup size win can be recovered
|
|
39
|
+
// later as `<defs>`/symbol-level glyph sharing across intact frame groups,
|
|
40
|
+
// which preserves each frame's layout (tracked with DM-854/DM-865).
|
|
41
41
|
const frameGroups = [];
|
|
42
42
|
const keyframes = [];
|
|
43
43
|
let timeOffset = 0;
|
|
@@ -74,15 +74,15 @@ export function generateAnimatedSvg(config) {
|
|
|
74
74
|
keyframes.push(`
|
|
75
75
|
@keyframes fp-${i} {
|
|
76
76
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateX(${entersViaPush ? width : 0}px); }
|
|
77
|
-
${startPct}
|
|
78
|
-
${holdEndPct}
|
|
79
|
-
${transEndPct}
|
|
77
|
+
${startPct} { transform: translateX(0); }
|
|
78
|
+
${holdEndPct} { transform: translateX(0); }
|
|
79
|
+
${transEndPct} { transform: translateX(-${width}px); }
|
|
80
80
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateX(-${width}px); }
|
|
81
81
|
}
|
|
82
82
|
@keyframes fv-${i} {
|
|
83
83
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
|
|
84
|
-
${enterStartPct}
|
|
85
|
-
${transEndPct}
|
|
84
|
+
${enterStartPct} { opacity: 1; }
|
|
85
|
+
${transEndPct} { opacity: 1; }
|
|
86
86
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
|
|
87
87
|
}${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
|
|
88
88
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
|
|
@@ -103,15 +103,15 @@ export function generateAnimatedSvg(config) {
|
|
|
103
103
|
keyframes.push(`
|
|
104
104
|
@keyframes fp-${i} {
|
|
105
105
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateY(${entersViaScroll ? height : 0}px); }
|
|
106
|
-
${startPct}
|
|
107
|
-
${holdEndPct}
|
|
108
|
-
${transEndPct}
|
|
106
|
+
${startPct} { transform: translateY(0); }
|
|
107
|
+
${holdEndPct} { transform: translateY(0); }
|
|
108
|
+
${transEndPct} { transform: translateY(-${height}px); }
|
|
109
109
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateY(-${height}px); }
|
|
110
110
|
}
|
|
111
111
|
@keyframes fv-${i} {
|
|
112
112
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
|
|
113
|
-
${enterStartPct}
|
|
114
|
-
${transEndPct}
|
|
113
|
+
${enterStartPct} { opacity: 1; }
|
|
114
|
+
${transEndPct} { opacity: 1; }
|
|
115
115
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
|
|
116
116
|
}${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
|
|
117
117
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
|
|
@@ -191,6 +191,11 @@ export function generateAnimatedSvg(config) {
|
|
|
191
191
|
frameGroups.push(svgMarkup);
|
|
192
192
|
keyframes.push(css);
|
|
193
193
|
}
|
|
194
|
+
else if (overlay.kind === "blink") {
|
|
195
|
+
const { svgMarkup, css } = renderBlinkOverlay(overlay, i, timeOffset, timeOffset + frame.duration, totalDuration, totalSec);
|
|
196
|
+
frameGroups.push(svgMarkup);
|
|
197
|
+
keyframes.push(css);
|
|
198
|
+
}
|
|
194
199
|
}
|
|
195
200
|
}
|
|
196
201
|
timeOffset += frame.duration + transDur;
|
|
@@ -337,6 +342,8 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
|
|
|
337
342
|
// width-growing clip during the slice of the type timeline when that line's
|
|
338
343
|
// characters are typed (line N starts after line N-1 finishes), so the caret
|
|
339
344
|
// advances down the field exactly as it would in the browser.
|
|
345
|
+
// DM-870: per-line type timing, collected for the optional caret below.
|
|
346
|
+
const lineTimings = [];
|
|
340
347
|
let cumChars = 0;
|
|
341
348
|
lines.forEach((line, li) => {
|
|
342
349
|
const lineY = overlay.y + li * lineHeight;
|
|
@@ -345,8 +352,11 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
|
|
|
345
352
|
// is where the caret would sit just after the typed character anyway.
|
|
346
353
|
const lineWidth = line.length * charWidth + charWidth;
|
|
347
354
|
const clipId = `${id}-clip${li}`;
|
|
348
|
-
const
|
|
349
|
-
const
|
|
355
|
+
const lineStartMs = typeStartMs + (cumChars / visibleChars) * effTypeDur;
|
|
356
|
+
const lineEndMs = typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur;
|
|
357
|
+
const lineStartPct = pct(lineStartMs, totalDuration);
|
|
358
|
+
const lineEndPct = pct(lineEndMs, totalDuration);
|
|
359
|
+
lineTimings.push({ li, startMs: lineStartMs, endMs: lineEndMs, len: line.length });
|
|
350
360
|
cumChars += line.length;
|
|
351
361
|
parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
|
|
352
362
|
parts.push(` <text class="${id}-text" x="${overlay.x}" y="${lineY}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(line)}</text>`);
|
|
@@ -359,6 +369,48 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
|
|
|
359
369
|
cssRules.push(`
|
|
360
370
|
@keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
|
|
361
371
|
.${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
|
|
372
|
+
// DM-870: blinking insertion caret. Sweeps the type position while typing
|
|
373
|
+
// (one linear translate segment per wrapped line, jumping to the next line's
|
|
374
|
+
// start), then parks at the end of the last line and blinks (step-end opacity
|
|
375
|
+
// toggle) until the overlay disappears. Two animations on one rect: a linear
|
|
376
|
+
// position track + a step-end opacity blink.
|
|
377
|
+
if (overlay.caret != null && overlay.caret !== false && lineTimings.length > 0) {
|
|
378
|
+
const caretOpts = typeof overlay.caret === "object" ? overlay.caret : {};
|
|
379
|
+
const caretColor = caretOpts.color ?? color;
|
|
380
|
+
const caretW = caretOpts.width ?? 2;
|
|
381
|
+
const blinkMs = caretOpts.blinkMs ?? 530;
|
|
382
|
+
const last = lineTimings[lineTimings.length - 1];
|
|
383
|
+
const endX = last.len * charWidth;
|
|
384
|
+
const endY = last.li * lineHeight;
|
|
385
|
+
// Position track: hold at line 0 start until typing begins, then sweep each
|
|
386
|
+
// line, then hold at the text end through the blink + disappear.
|
|
387
|
+
const posStops = [`0%, ${typeStartPct} { transform: translate(0px, 0px); }`];
|
|
388
|
+
for (const lt of lineTimings) {
|
|
389
|
+
posStops.push(`${pct(lt.startMs, totalDuration)} { transform: translate(0px, ${lt.li * lineHeight}px); }`);
|
|
390
|
+
posStops.push(`${pct(lt.endMs, totalDuration)} { transform: translate(${lt.len * charWidth}px, ${lt.li * lineHeight}px); }`);
|
|
391
|
+
}
|
|
392
|
+
posStops.push(`${holdEndPct}, 100% { transform: translate(${endX}px, ${endY}px); }`);
|
|
393
|
+
// Blink: invisible until typing starts, solid through typing, then toggle
|
|
394
|
+
// on/off every half-period until the overlay disappears.
|
|
395
|
+
const blinkStops = [
|
|
396
|
+
`0%, ${typeStartPct} { opacity: 0; }`,
|
|
397
|
+
`${pct(typeStartMs + 30, totalDuration)} { opacity: 1; }`,
|
|
398
|
+
`${pct(textEndMs, totalDuration)} { opacity: 1; }`,
|
|
399
|
+
];
|
|
400
|
+
let t = textEndMs + blinkMs / 2;
|
|
401
|
+
let on = false;
|
|
402
|
+
while (t < holdEndMs) {
|
|
403
|
+
blinkStops.push(`${pct(t, totalDuration)} { opacity: ${on ? 1 : 0}; }`);
|
|
404
|
+
t += blinkMs / 2;
|
|
405
|
+
on = !on;
|
|
406
|
+
}
|
|
407
|
+
blinkStops.push(`${disappearPct}, 100% { opacity: 0; }`);
|
|
408
|
+
parts.push(` <rect class="${id}-caret" x="${overlay.x}" y="${overlay.y - fontSize + 2}" width="${caretW}" height="${fontSize}" fill="${caretColor}" />`);
|
|
409
|
+
cssRules.push(`
|
|
410
|
+
@keyframes ${id}-caret-pos { ${posStops.join(" ")} }
|
|
411
|
+
@keyframes ${id}-caret-blink { ${blinkStops.join(" ")} }
|
|
412
|
+
.${id}-caret { animation: ${id}-caret-pos ${totalSec.toFixed(2)}s linear infinite, ${id}-caret-blink ${totalSec.toFixed(2)}s step-end infinite; }`);
|
|
413
|
+
}
|
|
362
414
|
return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
|
|
363
415
|
}
|
|
364
416
|
function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec) {
|
|
@@ -380,6 +432,29 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
|
|
|
380
432
|
.${id}-dot { animation: ${id}-dot ${totalSec.toFixed(2)}s infinite; }`;
|
|
381
433
|
return { svgMarkup, css };
|
|
382
434
|
}
|
|
435
|
+
function renderBlinkOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
|
|
436
|
+
const id = `blink${frameIdx}`;
|
|
437
|
+
const period = overlay.periodMs ?? 1000;
|
|
438
|
+
const color = overlay.color ?? "#e6edf3";
|
|
439
|
+
const startMs = frameStart + (overlay.delay ?? 0);
|
|
440
|
+
const radiusAttr = overlay.radius != null ? ` rx="${overlay.radius}" ry="${overlay.radius}"` : "";
|
|
441
|
+
// Toggle opacity on/off every half-period across the frame's hold, then off.
|
|
442
|
+
// step-end keeps each state until the next stop (a hard blink, not a fade).
|
|
443
|
+
const stops = [`0%, ${pct(startMs, totalDuration)} { opacity: 0; }`];
|
|
444
|
+
let t = startMs;
|
|
445
|
+
let on = true;
|
|
446
|
+
while (t < frameEnd) {
|
|
447
|
+
stops.push(`${pct(t, totalDuration)} { opacity: ${on ? 1 : 0}; }`);
|
|
448
|
+
t += period / 2;
|
|
449
|
+
on = !on;
|
|
450
|
+
}
|
|
451
|
+
stops.push(`${pct(frameEnd, totalDuration)}, 100% { opacity: 0; }`);
|
|
452
|
+
const svgMarkup = ` <rect class="${id}" x="${overlay.x}" y="${overlay.y}" width="${overlay.width}" height="${overlay.height}"${radiusAttr} fill="${color}" />`;
|
|
453
|
+
const css = `
|
|
454
|
+
@keyframes ${id} { ${stops.join(" ")} }
|
|
455
|
+
.${id} { animation: ${id} ${totalSec.toFixed(2)}s step-end infinite; }`;
|
|
456
|
+
return { svgMarkup, css };
|
|
457
|
+
}
|
|
383
458
|
function pct(ms, total) {
|
|
384
459
|
return `${((ms / total) * 100).toFixed(2)}%`;
|
|
385
460
|
}
|
|
@@ -483,50 +558,6 @@ function offsetForDirection(dir, w, h, _outFrom) {
|
|
|
483
558
|
return `translate(-${w}px, 0)`;
|
|
484
559
|
return `translate(${w}px, 0)`; // right
|
|
485
560
|
}
|
|
486
|
-
/**
|
|
487
|
-
* Compose the animated SVG using the frame-merge pipeline. Every element in
|
|
488
|
-
* every frame is reduced to one render with a visibility timeline. Stable
|
|
489
|
-
* elements (prompt, background, typed characters that stay on screen) emit
|
|
490
|
-
* once with opacity: 1 throughout; changing elements get step-end keyframes
|
|
491
|
-
* that flip their opacity at the appropriate frame boundaries.
|
|
492
|
-
*/
|
|
493
|
-
function composeMergedSvg(config, frameTiming, totalSec) {
|
|
494
|
-
const { width, height, frames } = config;
|
|
495
|
-
const framesSvg = frames.map((f) => f.svgContent);
|
|
496
|
-
const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
|
|
497
|
-
const sharedDefsMarkup = config.sharedDefs ?? "";
|
|
498
|
-
const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
|
|
499
|
-
// DM-603: viewBox-cull keyframes from each frame's pre-pass (see unmerged path).
|
|
500
|
-
const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
|
|
501
|
-
// Cursor overlay (DM-277). Same emission as the unmerged path — the
|
|
502
|
-
// overlay sits above the merged frame group, clipped to the viewport.
|
|
503
|
-
const totalDuration = totalSec * 1000;
|
|
504
|
-
let overlayMarkup = "";
|
|
505
|
-
if (config.cursorOverlay != null && config.cursorOverlay.events.length > 0) {
|
|
506
|
-
const frameStarts = [];
|
|
507
|
-
let acc = 0;
|
|
508
|
-
for (const f of frames) {
|
|
509
|
-
frameStarts.push(acc);
|
|
510
|
-
acc += f.duration + transitionDuration(f);
|
|
511
|
-
}
|
|
512
|
-
const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
|
|
513
|
-
overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
|
|
514
|
-
}
|
|
515
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
516
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
|
517
|
-
<defs>
|
|
518
|
-
<clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
|
|
519
|
-
</defs>
|
|
520
|
-
<style>
|
|
521
|
-
${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss + "\n" : ""} :root { --scene-dur: ${totalSec.toFixed(2)}s; }
|
|
522
|
-
${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
|
|
523
|
-
</style>
|
|
524
|
-
<g clip-path="url(#viewport-clip)">
|
|
525
|
-
<rect width="${width}" height="${height}" fill="#0d1117" />
|
|
526
|
-
${merged}${overlayMarkup}
|
|
527
|
-
</g>
|
|
528
|
-
</svg>`;
|
|
529
|
-
}
|
|
530
561
|
/**
|
|
531
562
|
* Compile each frame's intra-frame animations into CSS. Each animation gets
|
|
532
563
|
* a uniquely-named keyframe block whose timing is mapped onto the global
|
|
@@ -560,15 +591,33 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
|
|
|
560
591
|
return `${a.property}: ${val};`;
|
|
561
592
|
};
|
|
562
593
|
const animName = `f${i}-${a.animId}-${ai}`;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
594
|
+
if (a.repeat != null) {
|
|
595
|
+
// DM-869: repeating animation (blink / pulse / breathe). The keyframe is
|
|
596
|
+
// a single from→to cycle on the animation's own `duration` clock, looped
|
|
597
|
+
// via animation-iteration-count + (optional) direction:alternate. The
|
|
598
|
+
// loop is only visible while the frame is on screen (the frame group's
|
|
599
|
+
// visibility gating); `animation-delay` aligns the first cycle to the
|
|
600
|
+
// frame's appearance. `fill-mode: both` holds `from` before the delay.
|
|
601
|
+
const iterations = a.repeat === "infinite" ? "infinite" : String(a.repeat);
|
|
602
|
+
const direction = a.alternate === true ? " alternate" : "";
|
|
603
|
+
out.push(` @keyframes ${animName} {
|
|
604
|
+
0% { ${propValue(a.from)} }
|
|
605
|
+
100% { ${propValue(a.to)} }
|
|
606
|
+
}
|
|
607
|
+
.anim-${a.animId} { animation: ${animName} ${a.duration}ms ${iterations}${direction}; animation-timing-function: ${easing}; animation-delay: ${startMs.toFixed(0)}ms; animation-fill-mode: both; }`);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
// One-shot: hold `from` until startPct, animate from→to during
|
|
611
|
+
// [startPct, endPct], hold `to` afterwards, mapped onto the global scene
|
|
612
|
+
// clock so it replays in sync each scene loop.
|
|
613
|
+
out.push(` @keyframes ${animName} {
|
|
566
614
|
0% { ${propValue(a.from)} }
|
|
567
615
|
${startPct.toFixed(3)}% { ${propValue(a.from)} }
|
|
568
616
|
${endPct.toFixed(3)}% { ${propValue(a.to)} }
|
|
569
617
|
100% { ${propValue(a.to)} }
|
|
570
618
|
}
|
|
571
619
|
.anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
|
|
620
|
+
}
|
|
572
621
|
}
|
|
573
622
|
}
|
|
574
623
|
return out.length === 0 ? "" : "\n" + out.join("\n");
|
|
@@ -20,35 +20,45 @@ describe("animator", () => {
|
|
|
20
20
|
expect(topDefs).not.toBeNull();
|
|
21
21
|
expect(topDefs[0]).toContain(`id="g0"`);
|
|
22
22
|
});
|
|
23
|
-
it("crossfade
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
23
|
+
it("crossfade transition composites full frames (per-frame opacity fade, not an element merge)", () => {
|
|
24
|
+
// A crossfade transition dissolves between two complete, independently
|
|
25
|
+
// z-ordered sub-SVGs. It must NOT flatten them into one deduped element
|
|
26
|
+
// tree — doing so loses per-frame stacking (a later frame's background can
|
|
27
|
+
// occlude its own foreground) and step-end-switches at the midpoint
|
|
28
|
+
// instead of fading. So a crossfade emits per-frame fv- groups with an
|
|
29
|
+
// interpolated opacity fade, and each frame's content survives intact —
|
|
30
|
+
// NOT the merge pipeline's tN timeline classes.
|
|
28
31
|
const svg = generateAnimatedSvg({
|
|
29
32
|
width: 100,
|
|
30
33
|
height: 100,
|
|
31
34
|
frames: [
|
|
32
35
|
{ svgContent: `<rect fill="red" width="50" height="50"/>`, duration: 1000, transition: { type: "crossfade", duration: 200 } },
|
|
33
|
-
{ svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
|
|
36
|
+
{ svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000, transition: { type: "crossfade", duration: 200 } },
|
|
34
37
|
],
|
|
35
38
|
});
|
|
36
|
-
expect(svg).
|
|
37
|
-
expect(svg).toMatch(/@keyframes t\d+/);
|
|
39
|
+
expect(svg).toMatch(/@keyframes fv-/);
|
|
40
|
+
expect(svg).not.toMatch(/@keyframes t\d+/);
|
|
41
|
+
expect(svg).toContain(`<rect fill="red" width="50" height="50"/>`);
|
|
42
|
+
expect(svg).toContain(`<rect fill="blue" width="50" height="50"/>`);
|
|
38
43
|
expect(svg).toContain("--scene-dur");
|
|
39
44
|
});
|
|
40
|
-
it("
|
|
41
|
-
//
|
|
42
|
-
//
|
|
45
|
+
it("cut: composites each frame as a complete sub-SVG (DM-865 — no element merge)", () => {
|
|
46
|
+
// DM-865: the element-merge fast path is gone — cut composites each frame
|
|
47
|
+
// like crossfade. So identical content across frames is emitted once PER
|
|
48
|
+
// frame (not deduped into one), gated by per-frame fv-N opacity. The merge
|
|
49
|
+
// mis-rendered near-identical (continuous-session) frames; compositing
|
|
50
|
+
// keeps each frame's layout intact.
|
|
43
51
|
const svg = generateAnimatedSvg({
|
|
44
52
|
width: 100,
|
|
45
53
|
height: 100,
|
|
46
54
|
frames: [
|
|
47
|
-
{ svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500, transition: { type: "
|
|
48
|
-
{ svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500 },
|
|
55
|
+
{ svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500, transition: { type: "cut", duration: 0 } },
|
|
56
|
+
{ svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500, transition: { type: "cut", duration: 0 } },
|
|
49
57
|
],
|
|
50
58
|
});
|
|
51
|
-
expect((svg.match(/<rect width="50" height="50" fill="green"\/>/g) ?? []).length).toBe(
|
|
59
|
+
expect((svg.match(/<rect width="50" height="50" fill="green"\/>/g) ?? []).length).toBe(2);
|
|
60
|
+
expect(svg).toMatch(/@keyframes fv-/);
|
|
61
|
+
expect(svg).not.toMatch(/@keyframes t\d+\s*{/);
|
|
52
62
|
});
|
|
53
63
|
it("DM-609: scroll transition is now a vertical push (translateY), not opacity-only", () => {
|
|
54
64
|
// The `scroll` transition used to be opacity-only (a misnomer). Per
|
|
@@ -105,7 +115,7 @@ describe("animator", () => {
|
|
|
105
115
|
const bDur = b.match(/--scene-dur:\s*([0-9.]+)s/)?.[1];
|
|
106
116
|
expect(aDur).toBe(bDur);
|
|
107
117
|
});
|
|
108
|
-
it("cut transition:
|
|
118
|
+
it("cut transition: composites with per-frame fv-N groups (DM-865 — no element merge)", () => {
|
|
109
119
|
const svg = generateAnimatedSvg({
|
|
110
120
|
width: 100, height: 100,
|
|
111
121
|
frames: [
|
|
@@ -114,8 +124,9 @@ describe("animator", () => {
|
|
|
114
124
|
{ svgContent: `<rect fill="green" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
115
125
|
],
|
|
116
126
|
});
|
|
117
|
-
expect(svg).
|
|
118
|
-
expect(svg).toMatch(/@keyframes t\d
|
|
127
|
+
expect(svg).toMatch(/@keyframes fv-/);
|
|
128
|
+
expect(svg).not.toMatch(/@keyframes t\d+\s*{/);
|
|
129
|
+
expect((svg.match(/class="f f-\d+"/g) ?? []).length).toBe(3);
|
|
119
130
|
// 3 frames × 1000ms hold + 3 × 0ms cut transitions = 3000ms total.
|
|
120
131
|
expect(svg).toMatch(/--scene-dur:\s*3\.00s/);
|
|
121
132
|
});
|
|
@@ -152,6 +163,69 @@ describe("animator", () => {
|
|
|
152
163
|
// Timing function passes through.
|
|
153
164
|
expect(svg).toContain("ease-out");
|
|
154
165
|
});
|
|
166
|
+
it("DM-869: repeating animation loops on its own clock with iteration-count + alternate", () => {
|
|
167
|
+
const svg = generateAnimatedSvg({
|
|
168
|
+
width: 100,
|
|
169
|
+
height: 100,
|
|
170
|
+
frames: [
|
|
171
|
+
{
|
|
172
|
+
svgContent: `<rect class="anim-caret"/>`,
|
|
173
|
+
duration: 2000,
|
|
174
|
+
animations: [{ animId: "caret", property: "opacity", from: "1", to: "0", duration: 530, repeat: "infinite", alternate: true }],
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
// Loops on its own 530ms clock, infinite + alternating — NOT the global scene clock.
|
|
179
|
+
expect(svg).toMatch(/\.anim-caret\s*{[^}]*animation:\s*f0-caret-0\s+530ms\s+infinite\s+alternate/);
|
|
180
|
+
// Simple two-stop from→to cycle (no 4-stop global-clock hold).
|
|
181
|
+
expect(svg).toMatch(/@keyframes f0-caret-0\s*{\s*0%\s*{\s*opacity:\s*1;\s*}\s*100%\s*{\s*opacity:\s*0;\s*}\s*}/);
|
|
182
|
+
});
|
|
183
|
+
it("DM-869: finite repeat emits the count and (without alternate) no direction", () => {
|
|
184
|
+
const svg = generateAnimatedSvg({
|
|
185
|
+
width: 100,
|
|
186
|
+
height: 100,
|
|
187
|
+
frames: [
|
|
188
|
+
{
|
|
189
|
+
svgContent: `<rect class="anim-p"/>`,
|
|
190
|
+
duration: 2000,
|
|
191
|
+
animations: [{ animId: "p", property: "opacity", from: "1", to: "0.3", duration: 400, repeat: 3 }],
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
expect(svg).toMatch(/\.anim-p\s*{[^}]*animation:\s*f0-p-0\s+400ms\s+3\s*;/);
|
|
196
|
+
expect(svg).not.toContain("alternate");
|
|
197
|
+
});
|
|
198
|
+
it("DM-870: typing-overlay caret emits a position-tracked, step-end blinking bar", () => {
|
|
199
|
+
const svg = generateAnimatedSvg({
|
|
200
|
+
width: 200,
|
|
201
|
+
height: 80,
|
|
202
|
+
frames: [{ svgContent: `<rect/>`, duration: 3000, overlays: [{ kind: "typing", text: "hi there", x: 10, y: 40, caret: true }] }],
|
|
203
|
+
});
|
|
204
|
+
expect(svg).toContain(`class="t0-caret"`);
|
|
205
|
+
expect(svg).toMatch(/@keyframes t0-caret-pos/);
|
|
206
|
+
expect(svg).toMatch(/@keyframes t0-caret-blink/);
|
|
207
|
+
// Position track is linear; the blink toggles with step-end (hard on/off).
|
|
208
|
+
expect(svg).toMatch(/\.t0-caret\s*{[^}]*linear[^}]*step-end/);
|
|
209
|
+
});
|
|
210
|
+
it("DM-870: typing overlay without the caret option emits no caret", () => {
|
|
211
|
+
const svg = generateAnimatedSvg({
|
|
212
|
+
width: 200,
|
|
213
|
+
height: 80,
|
|
214
|
+
frames: [{ svgContent: `<rect/>`, duration: 3000, overlays: [{ kind: "typing", text: "hi", x: 10, y: 40 }] }],
|
|
215
|
+
});
|
|
216
|
+
expect(svg).not.toContain("-caret");
|
|
217
|
+
});
|
|
218
|
+
it("DM-871: blink overlay emits a step-end toggling rect", () => {
|
|
219
|
+
const svg = generateAnimatedSvg({
|
|
220
|
+
width: 100,
|
|
221
|
+
height: 100,
|
|
222
|
+
frames: [{ svgContent: `<rect/>`, duration: 2000, overlays: [{ kind: "blink", x: 10, y: 10, width: 12, height: 12, periodMs: 800, color: "#ef4444", radius: 6 }] }],
|
|
223
|
+
});
|
|
224
|
+
expect(svg).toMatch(/<rect class="blink0"[^>]*fill="#ef4444"/);
|
|
225
|
+
expect(svg).toContain('rx="6"');
|
|
226
|
+
expect(svg).toMatch(/@keyframes blink0/);
|
|
227
|
+
expect(svg).toMatch(/\.blink0\s*{[^}]*step-end/);
|
|
228
|
+
});
|
|
155
229
|
it("intra-frame animation: translateY desugars to transform: translateY()", () => {
|
|
156
230
|
const svg = generateAnimatedSvg({
|
|
157
231
|
width: 100, height: 100,
|
|
@@ -228,23 +302,21 @@ describe("animator", () => {
|
|
|
228
302
|
// The "cut" frame uses ONLY fv-1 (no fd-1 — it's folded in).
|
|
229
303
|
expect(svg).not.toMatch(/@keyframes fd-1\s*{/);
|
|
230
304
|
});
|
|
231
|
-
it("DM-599/DM-641:
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
305
|
+
it("DM-599/DM-641: cut fv-N keyframes emit visibility alongside opacity", () => {
|
|
306
|
+
// Cut composites (DM-865); its fv-N keyframes flip BOTH opacity and
|
|
307
|
+
// visibility at the frame boundary (step-end) so the browser can skip
|
|
308
|
+
// painting a frame that's off screen.
|
|
235
309
|
const svg = generateAnimatedSvg({
|
|
236
310
|
width: 100, height: 100,
|
|
237
311
|
frames: [
|
|
238
312
|
{ svgContent: `<rect fill="red" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
239
|
-
{ svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
|
|
313
|
+
{ svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
240
314
|
],
|
|
241
315
|
});
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
expect(
|
|
246
|
-
expect(tN[0]).toMatch(/opacity:\s*1;\s*visibility:\s*visible/);
|
|
247
|
-
expect(tN[0]).toMatch(/opacity:\s*0;\s*visibility:\s*hidden/);
|
|
316
|
+
const fv = svg.match(/@keyframes fv-\d+\s*{[\s\S]*?\n\s*}/);
|
|
317
|
+
expect(fv).not.toBeNull();
|
|
318
|
+
expect(fv[0]).toMatch(/opacity:\s*1;\s*visibility:\s*visible/);
|
|
319
|
+
expect(fv[0]).toMatch(/opacity:\s*0;\s*visibility:\s*hidden/);
|
|
248
320
|
});
|
|
249
321
|
it("DM-641: never emits `display: none` keyframes (would park Chromium's animation engine)", () => {
|
|
250
322
|
// Regression. The repro from the ticket: a multi-frame animation with
|
|
@@ -1512,7 +1512,15 @@ export const captureScript = (args) => {
|
|
|
1512
1512
|
|| (parseFloat(rootCs.borderBottomWidth) || 0) > 0
|
|
1513
1513
|
|| (parseFloat(rootCs.borderLeftWidth) || 0) > 0;
|
|
1514
1514
|
const rootBg = rootCs.backgroundColor;
|
|
1515
|
-
|
|
1515
|
+
// DM-855: also capture the root when it carries a gradient/image background,
|
|
1516
|
+
// not just a solid color. `backgroundColor` is `transparent` for a
|
|
1517
|
+
// gradient-only <body>, so checking it alone made us skip capturing the root
|
|
1518
|
+
// element and drop its background entirely. Treating a non-`none`
|
|
1519
|
+
// `background-image` as "has background" captures the root as a normal
|
|
1520
|
+
// element, routing its gradient through the existing element-gradient path.
|
|
1521
|
+
const rootBgImage = rootCs.backgroundImage;
|
|
1522
|
+
const rootHasBgImage = rootBgImage != null && rootBgImage !== 'none' && rootBgImage !== '';
|
|
1523
|
+
const rootHasBg = (rootBg != null && rootBg !== 'rgba(0, 0, 0, 0)' && rootBg !== 'transparent') || rootHasBgImage;
|
|
1516
1524
|
// DM-365: invalid HTML like <p>foo<div>bar</div>baz</p> auto-closes the <p>
|
|
1517
1525
|
// when the <div> opens, leaving "baz" as a direct text-node child of <body>.
|
|
1518
1526
|
// Chrome paints it; we'd miss it if we only walked root.children (Element
|