domotion-svg 0.4.2 → 0.6.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 +34 -4
- package/dist/animation/animator.d.ts +71 -3
- package/dist/animation/animator.js +247 -76
- package/dist/animation/index.d.ts +1 -0
- package/dist/animation/index.js +3 -2
- package/dist/animation/magic-move.d.ts +61 -0
- package/dist/animation/magic-move.js +243 -0
- package/dist/capture/index.js +117 -3
- package/dist/capture/script/index.js +14 -1
- package/dist/capture/script/pseudo-rules.d.ts +1 -1
- package/dist/capture/script/pseudo-rules.js +10 -3
- package/dist/capture/script/walker/borders-backgrounds.d.ts +61 -1
- package/dist/capture/script/walker/borders-backgrounds.js +22 -9
- package/dist/capture/script/walker/masks-clips.js +35 -12
- package/dist/capture/script/walker/text-segments.d.ts +2 -2
- package/dist/capture/script/walker/text-segments.js +14 -3
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +24 -0
- package/dist/cli/animate.d.ts +378 -0
- package/dist/cli/animate.js +740 -201
- package/dist/cli/capture.js +95 -5
- package/dist/cli/common.d.ts +35 -2
- package/dist/cli/common.js +92 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +76 -38
- package/dist/cli/review.d.ts +18 -0
- package/dist/cli/review.js +213 -0
- package/dist/cli/svg-to-video-core.d.ts +133 -0
- package/dist/cli/svg-to-video-core.js +533 -0
- package/dist/cli/svg-to-video.d.ts +14 -0
- package/dist/cli/svg-to-video.js +161 -0
- package/dist/render/borders.d.ts +50 -0
- package/dist/render/borders.js +55 -0
- package/dist/render/element-tree-to-svg.d.ts +60 -2
- package/dist/render/element-tree-to-svg.js +273 -71
- package/dist/render/embedded-font-builder.js +38 -0
- package/dist/render/form-controls.js +2 -2
- package/dist/render/glyph-helper.d.ts +73 -0
- package/dist/render/{coretext.js → glyph-helper.js} +68 -21
- package/dist/render/helper-acquire.d.ts +67 -0
- package/dist/render/helper-acquire.js +189 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.js +6 -1
- package/dist/render/text-to-path.d.ts +145 -0
- package/dist/render/text-to-path.js +863 -92
- package/dist/render/text.js +153 -13
- package/dist/review/client.bundle.generated.d.ts +1 -0
- package/dist/review/client.bundle.generated.js +3 -0
- package/dist/review/client.d.ts +10 -0
- package/dist/review/client.js +197 -0
- package/dist/review/compare-pngs.d.ts +151 -0
- package/dist/review/compare-pngs.js +475 -0
- package/dist/review/region-overlay.d.ts +50 -0
- package/dist/review/region-overlay.js +361 -0
- package/dist/review/server.d.ts +45 -0
- package/dist/review/server.js +140 -0
- package/dist/scroll/composer.d.ts +7 -1
- package/dist/scroll/composer.js +14 -10
- package/package.json +14 -10
- package/dist/animation/animator.test.d.ts +0 -5
- package/dist/animation/animator.test.js +0 -346
- package/dist/animation/cursor-overlay.test.d.ts +0 -1
- package/dist/animation/cursor-overlay.test.js +0 -88
- package/dist/cross-origin-font-face.test.d.ts +0 -1
- package/dist/cross-origin-font-face.test.js +0 -107
- package/dist/dark-mode-capture.test.d.ts +0 -1
- package/dist/dark-mode-capture.test.js +0 -158
- package/dist/dark-mode-form-controls.test.d.ts +0 -1
- package/dist/dark-mode-form-controls.test.js +0 -218
- package/dist/embed-remote-images.test.d.ts +0 -1
- package/dist/embed-remote-images.test.js +0 -424
- package/dist/kerfjs-imports.test.d.ts +0 -1
- package/dist/kerfjs-imports.test.js +0 -36
- package/dist/mask.test.d.ts +0 -1
- package/dist/mask.test.js +0 -211
- package/dist/post-processing/optimize.test.d.ts +0 -1
- package/dist/post-processing/optimize.test.js +0 -40
- package/dist/preserve-aspect-ratio.test.d.ts +0 -1
- package/dist/preserve-aspect-ratio.test.js +0 -38
- package/dist/render/borders.test.d.ts +0 -1
- package/dist/render/borders.test.js +0 -148
- package/dist/render/conic-raster.test.d.ts +0 -1
- package/dist/render/conic-raster.test.js +0 -187
- package/dist/render/coretext.d.ts +0 -60
- package/dist/render/coretext.test.d.ts +0 -1
- package/dist/render/coretext.test.js +0 -94
- package/dist/render/gradients.test.d.ts +0 -1
- package/dist/render/gradients.test.js +0 -184
- package/dist/render/text-to-path.test.d.ts +0 -1
- package/dist/render/text-to-path.test.js +0 -951
- package/dist/render/text.test.d.ts +0 -1
- package/dist/render/text.test.js +0 -234
- package/dist/scroll/composer.test.d.ts +0 -1
- package/dist/scroll/composer.test.js +0 -452
- package/dist/scroll/executor.test.d.ts +0 -1
- package/dist/scroll/executor.test.js +0 -236
- package/dist/scroll/hoist-fixed.test.d.ts +0 -1
- package/dist/scroll/hoist-fixed.test.js +0 -103
- package/dist/scroll/hoist-sticky.test.d.ts +0 -1
- package/dist/scroll/hoist-sticky.test.js +0 -154
- package/dist/scroll/pattern.test.d.ts +0 -1
- package/dist/scroll/pattern.test.js +0 -438
- package/dist/stacking-context.test.d.ts +0 -1
- package/dist/stacking-context.test.js +0 -927
- package/dist/tree-ops/frame-merge.test.d.ts +0 -6
- package/dist/tree-ops/frame-merge.test.js +0 -189
- package/dist/tree-ops/resize-embedded-images.test.d.ts +0 -9
- package/dist/tree-ops/resize-embedded-images.test.js +0 -255
- package/dist/tree-ops/tree-diff.test.d.ts +0 -1
- package/dist/tree-ops/tree-diff.test.js +0 -267
- package/dist/tree-ops/viewbox-culling.test.d.ts +0 -1
- package/dist/tree-ops/viewbox-culling.test.js +0 -240
- package/dist/utils/region-feedback.test.d.ts +0 -1
- package/dist/utils/region-feedback.test.js +0 -221
- package/dist/webfont-unicode-range.test.d.ts +0 -1
- package/dist/webfont-unicode-range.test.js +0 -174
- package/src/animation/animator.test.ts +0 -368
- package/src/animation/animator.ts +0 -832
- package/src/animation/cursor-overlay.test.ts +0 -95
- package/src/animation/cursor-overlay.ts +0 -295
- package/src/animation/index.ts +0 -29
- package/src/capture/embed.ts +0 -305
- package/src/capture/emoji.ts +0 -226
- package/src/capture/index.ts +0 -1309
- package/src/capture/script/color-norm.ts +0 -80
- package/src/capture/script/emoji-detect.ts +0 -97
- package/src/capture/script/font-metrics.ts +0 -130
- package/src/capture/script/index.ts +0 -1506
- package/src/capture/script/placeholder-shown.ts +0 -54
- package/src/capture/script/pseudo-rules.ts +0 -223
- package/src/capture/script/utils.ts +0 -18
- package/src/capture/script/walker/borders-backgrounds.ts +0 -277
- package/src/capture/script/walker/counter-style-resolver.ts +0 -184
- package/src/capture/script/walker/form-controls.ts +0 -242
- package/src/capture/script/walker/input-value.ts +0 -221
- package/src/capture/script/walker/lists-counters.ts +0 -110
- package/src/capture/script/walker/masks-clips.ts +0 -181
- package/src/capture/script/walker/pseudo-content.ts +0 -784
- package/src/capture/script/walker/pseudo-inject.ts +0 -203
- package/src/capture/script/walker/replaced-elements.ts +0 -102
- package/src/capture/script/walker/text-segments.ts +0 -404
- package/src/capture/script/walker/transforms.ts +0 -160
- package/src/capture/script/warnings.ts +0 -39
- package/src/capture/script.generated.ts +0 -7
- package/src/capture/types.ts +0 -891
- package/src/capture/warnings.ts +0 -39
- package/src/cli/animate.ts +0 -521
- package/src/cli/capture.ts +0 -200
- package/src/cli/common.ts +0 -126
- package/src/cli/index.ts +0 -170
- package/src/cross-origin-font-face.test.ts +0 -119
- package/src/dark-mode-capture.test.ts +0 -178
- package/src/dark-mode-form-controls.test.ts +0 -229
- package/src/embed-remote-images.test.ts +0 -460
- package/src/globals.d.ts +0 -2
- package/src/index.ts +0 -82
- package/src/kerf-jsx-augmentation.d.ts +0 -36
- package/src/kerfjs-imports.test.tsx +0 -45
- package/src/mask.test.ts +0 -279
- package/src/post-processing/gzip.ts +0 -14
- package/src/post-processing/index.ts +0 -5
- package/src/post-processing/optimize.test.ts +0 -48
- package/src/post-processing/optimize.ts +0 -34
- package/src/preserve-aspect-ratio.test.ts +0 -49
- package/src/render/borders.test.ts +0 -160
- package/src/render/borders.ts +0 -783
- package/src/render/box-shadow.ts +0 -65
- package/src/render/colors.ts +0 -163
- package/src/render/conic-raster.test.ts +0 -213
- package/src/render/conic-raster.ts +0 -306
- package/src/render/coretext.test.ts +0 -131
- package/src/render/coretext.ts +0 -256
- package/src/render/css-tokens.ts +0 -44
- package/src/render/element-tree-to-svg.ts +0 -5660
- package/src/render/embedded-font-builder.ts +0 -221
- package/src/render/form-controls.ts +0 -1195
- package/src/render/format.ts +0 -24
- package/src/render/gradients.test.ts +0 -221
- package/src/render/gradients.ts +0 -1047
- package/src/render/index.ts +0 -22
- package/src/render/opentype.js.d.ts +0 -7
- package/src/render/text-to-path.test.ts +0 -1050
- package/src/render/text-to-path.ts +0 -2902
- package/src/render/text.test.ts +0 -262
- package/src/render/text.ts +0 -1017
- package/src/render/transforms.ts +0 -41
- package/src/scroll/composer.test.ts +0 -505
- package/src/scroll/composer.ts +0 -375
- package/src/scroll/executor.test.ts +0 -270
- package/src/scroll/executor.ts +0 -592
- package/src/scroll/hoist-fixed.test.ts +0 -117
- package/src/scroll/hoist-fixed.ts +0 -95
- package/src/scroll/hoist-sticky.test.ts +0 -173
- package/src/scroll/hoist-sticky.ts +0 -193
- package/src/scroll/index.ts +0 -36
- package/src/scroll/pattern.test.ts +0 -531
- package/src/scroll/pattern.ts +0 -610
- package/src/stacking-context.test.ts +0 -968
- package/src/tree-ops/frame-merge.test.ts +0 -208
- package/src/tree-ops/frame-merge.ts +0 -470
- package/src/tree-ops/index.ts +0 -11
- package/src/tree-ops/resize-embedded-images.test.ts +0 -292
- package/src/tree-ops/resize-embedded-images.ts +0 -177
- package/src/tree-ops/tree-diff.test.ts +0 -295
- package/src/tree-ops/tree-diff.ts +0 -236
- package/src/tree-ops/viewbox-culling.test.ts +0 -292
- package/src/tree-ops/viewbox-culling.ts +0 -327
- package/src/utils/escapeHtml.ts +0 -16
- package/src/utils/region-feedback.test.ts +0 -261
- package/src/utils/region-feedback.ts +0 -216
- package/src/webfont-unicode-range.test.ts +0 -207
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="examples/output/domotion-word-demo.svg" alt="Domotion — an animated wordmark cycling through twenty neon-retro typographic variants of the word domotion" width="600">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
5
|
DOM-to-animated-SVG renderer. Captures HTML/CSS rendered in headless Chromium and converts the captured tree into a self-contained SVG with optional CSS animations — pixel-faithful to what Chromium painted, scales crisply at any size, and embeds without external assets.
|
|
4
6
|
|
|
@@ -39,6 +41,9 @@ yourself to keep the first job's runtime down.
|
|
|
39
41
|
The fastest way in is the `domotion` CLI — no TypeScript, no Playwright bring-up. Point it at a URL or HTML file:
|
|
40
42
|
|
|
41
43
|
```bash
|
|
44
|
+
# Zero-install: run the published CLI straight from npm.
|
|
45
|
+
npx domotion-svg capture https://example.com -o example.svg
|
|
46
|
+
|
|
42
47
|
# Capture a URL as SVG.
|
|
43
48
|
domotion capture https://example.com -o example.svg
|
|
44
49
|
|
|
@@ -59,21 +64,46 @@ For a multi-frame animated SVG, write a small JSON config and run `domotion anim
|
|
|
59
64
|
domotion animate ./demo.json
|
|
60
65
|
```
|
|
61
66
|
|
|
62
|
-
The config describes each frame (input
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
### Export to video
|
|
70
|
+
|
|
71
|
+
The package also ships a standalone `svg-to-video` CLI that renders an animated SVG (a `domotion animate` output, or any CSS-/SMIL-animated SVG) to a video file. It steps the animation timeline frame by frame in Chromium for frame-accurate timing, then pipes the frames to **ffmpeg** (a required external dependency — install via `brew` / `apt` / `winget`).
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# h264/mp4 at 30fps, contained to 1280px wide.
|
|
75
|
+
svg-to-video demo.svg -o demo.mp4 --width 1280
|
|
76
|
+
|
|
77
|
+
# 60fps VP9/webm with looping background music.
|
|
78
|
+
svg-to-video demo.svg -o demo.webm --format vp9 --fps 60 --music bed.mp3
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Supports target size (`--width`/`--height`, aspect-preserving), `--fps`, `--format` / `--container`, supersampling (`--scale`), background music / foreground audio / captions, and a disk-space pre-flight. See `svg-to-video --help`.
|
|
82
|
+
|
|
83
|
+
### Reviewing a regression
|
|
84
|
+
|
|
85
|
+
If a capture comes out looking different from how Chromium painted the source page, the package ships an `svg-review` CLI to help you file a focused bug report. Capture once with `--debug` to get a reproduction bundle (HAR + the Chromium screenshot of the source + the SVG we produced), then open the bundle in the local review UI:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
domotion capture https://example.com --debug -o example.svg
|
|
89
|
+
svg-review --expected example.debug/expected.png --actual example.debug/actual.svg
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The browser opens a single review card showing the expected / actual / diff PNGs. Arrow keys cycle through the three at full size; drag on any image to mark a problem region and caption it. The side panel builds a GitHub-issue-ready Markdown block as you go — copy it, then file the issue at <https://github.com/brianwestphal/domotion/issues/new> and attach `expected.png` + `actual.svg` so a maintainer can reproduce.
|
|
63
93
|
|
|
64
94
|
### Scripting API
|
|
65
95
|
|
|
66
96
|
When you outgrow the CLI — custom interaction loops, programmatic frame composition, custom overlays — the same primitives are available as a library:
|
|
67
97
|
|
|
68
98
|
```ts
|
|
69
|
-
import { captureElementTree, elementTreeToSvg, launchChromium
|
|
99
|
+
import { captureElementTree, elementTreeToSvg, launchChromium } from "domotion-svg";
|
|
70
100
|
|
|
71
101
|
const browser = await launchChromium();
|
|
72
102
|
const page = await browser.newPage();
|
|
73
103
|
await page.setContent(`<div style="padding:20px;color:white;background:#0d1117">Hello</div>`);
|
|
74
104
|
|
|
75
105
|
const tree = await captureElementTree(page, "body", { x: 0, y: 0, width: 800, height: 200 });
|
|
76
|
-
const svg =
|
|
106
|
+
const svg = elementTreeToSvg(tree, 800, 200);
|
|
77
107
|
|
|
78
108
|
console.log(svg);
|
|
79
109
|
await browser.close();
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* animated SVG with CSS keyframe transitions.
|
|
6
6
|
*/
|
|
7
7
|
import { type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
|
|
8
|
+
import type { MagicMove } from "./magic-move.js";
|
|
8
9
|
export interface AnimationFrame {
|
|
9
10
|
/** SVG content for this frame (from dom-to-svg) */
|
|
10
11
|
svgContent: string;
|
|
@@ -28,11 +29,26 @@ export interface AnimationFrame {
|
|
|
28
29
|
* `crossfade` (default) overlaps fade-out and fade-in. `push-left` slides
|
|
29
30
|
* the outgoing frame off and the incoming frame in from the right.
|
|
30
31
|
* `scroll` keeps both visible during the transition. `cut` is instant —
|
|
31
|
-
* no fade, no slide. For `cut`, `duration` is ignored.
|
|
32
|
+
* no fade, no slide. For `cut`, `duration` is ignored. `magic-move` blends
|
|
33
|
+
* shared elements between the two frames — matched elements slide from
|
|
34
|
+
* their old position to their new one while added/removed elements
|
|
35
|
+
* cross-fade (DM-898; see `docs/53-magic-move-transition.md`). It requires
|
|
36
|
+
* the per-frame `magicMove` bridge layer (built caller-side from the
|
|
37
|
+
* element trees); when that's absent it degrades to `crossfade`.
|
|
32
38
|
*/
|
|
33
|
-
type: "crossfade" | "push-left" | "scroll" | "cut";
|
|
39
|
+
type: "crossfade" | "push-left" | "scroll" | "cut" | "magic-move";
|
|
34
40
|
duration: number;
|
|
35
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Magic-move bridge layer for this frame's transition to the next, built by
|
|
44
|
+
* the caller via `buildMagicMove(prevTree, nextTree, …)`. Present only when
|
|
45
|
+
* `transition.type === "magic-move"` and both frames' element trees were
|
|
46
|
+
* available; the animator shows it during the transition window (moved
|
|
47
|
+
* elements slide, added fade in, removed fade out) between the hard-cut prev
|
|
48
|
+
* and next frame blobs. When `transition.type` is `magic-move` but this is
|
|
49
|
+
* null, the animator falls back to `crossfade`.
|
|
50
|
+
*/
|
|
51
|
+
magicMove?: MagicMove | null;
|
|
36
52
|
/** Overlays: typing, tap ripple */
|
|
37
53
|
overlays?: AnimationOverlay[];
|
|
38
54
|
/**
|
|
@@ -69,6 +85,17 @@ export interface TypingOverlay {
|
|
|
69
85
|
* sits on a clean background.
|
|
70
86
|
*/
|
|
71
87
|
bgHeight?: number;
|
|
88
|
+
/**
|
|
89
|
+
* DM-870: render a blinking insertion caret. The bar sweeps the type
|
|
90
|
+
* position while typing, then parks at the end of the text and blinks
|
|
91
|
+
* (opacity 1↔0) until the frame ends. `true` uses defaults (the typing
|
|
92
|
+
* `color`, 2px wide, ~530ms cadence); an object overrides them.
|
|
93
|
+
*/
|
|
94
|
+
caret?: boolean | {
|
|
95
|
+
color?: string;
|
|
96
|
+
width?: number;
|
|
97
|
+
blinkMs?: number;
|
|
98
|
+
};
|
|
72
99
|
}
|
|
73
100
|
export interface TapOverlay {
|
|
74
101
|
kind: "tap";
|
|
@@ -122,7 +149,29 @@ export interface SvgOverlay {
|
|
|
122
149
|
delay?: number;
|
|
123
150
|
};
|
|
124
151
|
}
|
|
125
|
-
|
|
152
|
+
/**
|
|
153
|
+
* DM-871: a standalone blinking bar/box, for carets/dots not tied to a typing
|
|
154
|
+
* overlay — a recording dot, an attention pulse on a focused field, a cursor.
|
|
155
|
+
* Renders a rect that toggles opacity on a `periodMs` cycle for the frame's
|
|
156
|
+
* hold (sugar over a rect + a repeating opacity animation).
|
|
157
|
+
*/
|
|
158
|
+
export interface BlinkOverlay {
|
|
159
|
+
kind: "blink";
|
|
160
|
+
/** Top-left corner in the captured frame's coordinate space. */
|
|
161
|
+
x: number;
|
|
162
|
+
y: number;
|
|
163
|
+
width: number;
|
|
164
|
+
height: number;
|
|
165
|
+
/** Full on/off cycle in ms (default 1000). */
|
|
166
|
+
periodMs?: number;
|
|
167
|
+
/** Fill color (default a light gray). */
|
|
168
|
+
color?: string;
|
|
169
|
+
/** Corner radius — set to half the width/height for a dot. */
|
|
170
|
+
radius?: number;
|
|
171
|
+
/** Ms after the frame becomes visible before blinking starts. Default 0. */
|
|
172
|
+
delay?: number;
|
|
173
|
+
}
|
|
174
|
+
export type AnimationOverlay = TypingOverlay | TapOverlay | SvgOverlay | BlinkOverlay;
|
|
126
175
|
/**
|
|
127
176
|
* Animate a CSS property on captured elements that match a selector, while
|
|
128
177
|
* the frame is held on screen. The selector is resolved against the source
|
|
@@ -154,6 +203,17 @@ export interface IntraFrameAnimation {
|
|
|
154
203
|
easing?: string;
|
|
155
204
|
/** Ms after the frame becomes visible before animation starts. Default 0. */
|
|
156
205
|
delay?: number;
|
|
206
|
+
/**
|
|
207
|
+
* DM-869: repeat count. A positive integer or `"infinite"`. When set, the
|
|
208
|
+
* animation loops on its own `duration` clock (CSS `animation-iteration-count`)
|
|
209
|
+
* rather than playing once — turning a property animation into a blink / pulse
|
|
210
|
+
* / breathe. The loop is only visible while the frame is on screen (the frame
|
|
211
|
+
* group's visibility gating). `"infinite"` is the robust choice for a looping
|
|
212
|
+
* scene; a finite count aligns to the frame's first appearance.
|
|
213
|
+
*/
|
|
214
|
+
repeat?: number | "infinite";
|
|
215
|
+
/** DM-869: when true, the loop ping-pongs `from`→`to`→`from` (CSS `animation-direction: alternate`). */
|
|
216
|
+
alternate?: boolean;
|
|
157
217
|
}
|
|
158
218
|
export interface AnimationConfig {
|
|
159
219
|
width: number;
|
|
@@ -184,5 +244,13 @@ export interface AnimationConfig {
|
|
|
184
244
|
* uses `selector`; otherwise pass undefined / null.
|
|
185
245
|
*/
|
|
186
246
|
resolveSelector?: SelectorResolver;
|
|
247
|
+
/**
|
|
248
|
+
* Canvas background color painted behind every frame (a full-viewport
|
|
249
|
+
* `<rect>`). Mirrors the single-frame path's `transparentRootBgRect`
|
|
250
|
+
* (DM-554): pass the captured page's root background so animated output
|
|
251
|
+
* matches `capture` output. Omitted / `"transparent"` / `"rgba(0, 0, 0, 0)"`
|
|
252
|
+
* → no rect, i.e. a transparent SVG that composites over a host background.
|
|
253
|
+
*/
|
|
254
|
+
background?: string;
|
|
187
255
|
}
|
|
188
256
|
export declare function generateAnimatedSvg(config: AnimationConfig): string;
|
|
@@ -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;
|
|
@@ -51,6 +51,11 @@ export function generateAnimatedSvg(config) {
|
|
|
51
51
|
const prevFrame = i > 0 ? frames[i - 1] : null;
|
|
52
52
|
const entersViaPush = prevFrame?.transition?.type === "push-left";
|
|
53
53
|
const entersViaScroll = prevFrame?.transition?.type === "scroll";
|
|
54
|
+
// DM-898: a frame entered from a magic-move transition appears at its own
|
|
55
|
+
// start (= the predecessor's transition end), NOT overlap-faded — the
|
|
56
|
+
// magic-move bridge layer already covered the window, so a crossfade
|
|
57
|
+
// overlap here would double-show the next frame on top of the bridge.
|
|
58
|
+
const entersViaMagicMove = prevFrame?.transition?.type === "magic-move" && prevFrame?.magicMove != null;
|
|
54
59
|
// Both push-left and scroll overlap their transition with the next
|
|
55
60
|
// frame's entry — the next frame is already sliding in while the current
|
|
56
61
|
// one slides out, so its show window starts at `timeOffset - prevTransDur`
|
|
@@ -74,15 +79,15 @@ export function generateAnimatedSvg(config) {
|
|
|
74
79
|
keyframes.push(`
|
|
75
80
|
@keyframes fp-${i} {
|
|
76
81
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateX(${entersViaPush ? width : 0}px); }
|
|
77
|
-
${startPct}
|
|
78
|
-
${holdEndPct}
|
|
79
|
-
${transEndPct}
|
|
82
|
+
${startPct} { transform: translateX(0); }
|
|
83
|
+
${holdEndPct} { transform: translateX(0); }
|
|
84
|
+
${transEndPct} { transform: translateX(-${width}px); }
|
|
80
85
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateX(-${width}px); }
|
|
81
86
|
}
|
|
82
87
|
@keyframes fv-${i} {
|
|
83
88
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
|
|
84
|
-
${enterStartPct}
|
|
85
|
-
${transEndPct}
|
|
89
|
+
${enterStartPct} { opacity: 1; }
|
|
90
|
+
${transEndPct} { opacity: 1; }
|
|
86
91
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
|
|
87
92
|
}${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
|
|
88
93
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
|
|
@@ -103,20 +108,131 @@ export function generateAnimatedSvg(config) {
|
|
|
103
108
|
keyframes.push(`
|
|
104
109
|
@keyframes fp-${i} {
|
|
105
110
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateY(${entersViaScroll ? height : 0}px); }
|
|
106
|
-
${startPct}
|
|
107
|
-
${holdEndPct}
|
|
108
|
-
${transEndPct}
|
|
111
|
+
${startPct} { transform: translateY(0); }
|
|
112
|
+
${holdEndPct} { transform: translateY(0); }
|
|
113
|
+
${transEndPct} { transform: translateY(-${height}px); }
|
|
109
114
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateY(-${height}px); }
|
|
110
115
|
}
|
|
111
116
|
@keyframes fv-${i} {
|
|
112
117
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
|
|
113
|
-
${enterStartPct}
|
|
114
|
-
${transEndPct}
|
|
118
|
+
${enterStartPct} { opacity: 1; }
|
|
119
|
+
${transEndPct} { opacity: 1; }
|
|
115
120
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
|
|
116
121
|
}${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
|
|
117
122
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
|
|
118
123
|
.fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
|
|
119
124
|
}
|
|
125
|
+
else if (transType === "magic-move" && frame.magicMove != null) {
|
|
126
|
+
// DM-898: magic-move. Frame i holds [start..holdEnd] then HARD-CUTS out;
|
|
127
|
+
// a bridge composite covers the transition window [holdEnd..transEnd],
|
|
128
|
+
// inside which matched elements slide prev→next, added elements fade in,
|
|
129
|
+
// and removed elements fade out. The next frame cuts in at transEnd
|
|
130
|
+
// (= its own start). The bridge's start state matches the prev frame's
|
|
131
|
+
// final paint and its end state the next frame's initial paint, so both
|
|
132
|
+
// hard cuts are seamless. (When `frame.magicMove` is null the type falls
|
|
133
|
+
// through to the crossfade branch below — the documented fallback.)
|
|
134
|
+
const mm = frame.magicMove;
|
|
135
|
+
const sNum = parseFloat(startPct);
|
|
136
|
+
const hNum = parseFloat(holdEndPct);
|
|
137
|
+
const tNum = parseFloat(transEndPct);
|
|
138
|
+
const beforeS = Math.max(0, sNum - 0.001).toFixed(3);
|
|
139
|
+
const afterH = Math.min(100, hNum + 0.001).toFixed(3);
|
|
140
|
+
const beforeH = Math.max(0, hNum - 0.001).toFixed(3);
|
|
141
|
+
const afterT = Math.min(100, tNum + 0.001).toFixed(3);
|
|
142
|
+
// Frame i blob: visible only during its hold, hard-cut out at hold end.
|
|
143
|
+
frameGroups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
|
|
144
|
+
keyframes.push(`
|
|
145
|
+
@keyframes fv-${i} {
|
|
146
|
+
0% { opacity: 0; visibility: hidden; }
|
|
147
|
+
${beforeS}% { opacity: 0; visibility: hidden; }
|
|
148
|
+
${sNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
149
|
+
${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
150
|
+
${afterH}% { opacity: 0; visibility: hidden; }
|
|
151
|
+
100% { opacity: 0; visibility: hidden; }
|
|
152
|
+
}
|
|
153
|
+
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
|
|
154
|
+
// Bridge composite: visible during the transition window only.
|
|
155
|
+
frameGroups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
|
|
156
|
+
keyframes.push(`
|
|
157
|
+
@keyframes mmv-${i} {
|
|
158
|
+
0% { opacity: 0; visibility: hidden; }
|
|
159
|
+
${beforeH}% { opacity: 0; visibility: hidden; }
|
|
160
|
+
${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
161
|
+
${tNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
162
|
+
${afterT}% { opacity: 0; visibility: hidden; }
|
|
163
|
+
100% { opacity: 0; visibility: hidden; }
|
|
164
|
+
}
|
|
165
|
+
.mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
|
|
166
|
+
// Per-element slide / fade keyframes within the window (linear interp).
|
|
167
|
+
// The composite is only visible [holdEnd..transEnd], so the held values
|
|
168
|
+
// outside that window are never painted — they just pin the endpoints.
|
|
169
|
+
//
|
|
170
|
+
// A dual-render cross-fade copy (DM-903) is BOTH a slide and a fade, so
|
|
171
|
+
// its element needs two animations. They MUST go in one `animation:`
|
|
172
|
+
// declaration (comma-joined) — two separate `.cls { animation: … }` rules
|
|
173
|
+
// would have the later one silently override the former, dropping the
|
|
174
|
+
// slide. Accumulate per-class animation entries and emit one rule each.
|
|
175
|
+
const animEntries = new Map();
|
|
176
|
+
const addAnim = (cls, name) => {
|
|
177
|
+
const list = animEntries.get(cls) ?? [];
|
|
178
|
+
list.push(`${name} ${totalSec.toFixed(2)}s infinite`);
|
|
179
|
+
animEntries.set(cls, list);
|
|
180
|
+
};
|
|
181
|
+
for (const s of mm.slides) {
|
|
182
|
+
// Interpolate the element's transform `from → to` across the window.
|
|
183
|
+
// The next-appearance copy maps its prev rect → `none` (final next
|
|
184
|
+
// rect); a cross-fade prev copy maps `none` → its next rect, so both
|
|
185
|
+
// copies trace the same path (DM-899 geometry; DM-903 paired copies).
|
|
186
|
+
keyframes.push(`
|
|
187
|
+
@keyframes mms-${s.cls} {
|
|
188
|
+
0%, ${hNum.toFixed(3)}% { transform: ${s.from}; }
|
|
189
|
+
${tNum.toFixed(3)}%, 100% { transform: ${s.to}; }
|
|
190
|
+
}`);
|
|
191
|
+
addAnim(s.cls, `mms-${s.cls}`);
|
|
192
|
+
}
|
|
193
|
+
for (const cls of mm.fadeIn) {
|
|
194
|
+
keyframes.push(`
|
|
195
|
+
@keyframes mmf-${cls} {
|
|
196
|
+
0%, ${hNum.toFixed(3)}% { opacity: 0; }
|
|
197
|
+
${tNum.toFixed(3)}%, 100% { opacity: 1; }
|
|
198
|
+
}`);
|
|
199
|
+
addAnim(cls, `mmf-${cls}`);
|
|
200
|
+
}
|
|
201
|
+
for (const cls of mm.fadeOut) {
|
|
202
|
+
keyframes.push(`
|
|
203
|
+
@keyframes mmf-${cls} {
|
|
204
|
+
0%, ${hNum.toFixed(3)}% { opacity: 1; }
|
|
205
|
+
${tNum.toFixed(3)}%, 100% { opacity: 0; }
|
|
206
|
+
}`);
|
|
207
|
+
addAnim(cls, `mmf-${cls}`);
|
|
208
|
+
}
|
|
209
|
+
for (const [cls, entries] of animEntries) {
|
|
210
|
+
keyframes.push(` .${cls} { animation: ${entries.join(", ")}; }`);
|
|
211
|
+
}
|
|
212
|
+
// DM-901: honor `prefers-reduced-motion: reduce` — pin everything to the
|
|
213
|
+
// NEXT state instead of animating, so the transition degrades to a
|
|
214
|
+
// cut-like reveal for motion-sensitive viewers. Slides drop to their
|
|
215
|
+
// final transform (`none` for the next copy; the prev cross-fade copy is
|
|
216
|
+
// also hidden via its fade-out below). Added / next-appearance fades snap
|
|
217
|
+
// to opacity 1; removed / prev-appearance fades snap to opacity 0. Static
|
|
218
|
+
// CSS, so output stays deterministic; rasterizers default to
|
|
219
|
+
// `no-preference` and play the full move. (DM-903: the fade rules now
|
|
220
|
+
// also matter — without pinning fade-out to 0 the prev-appearance copy
|
|
221
|
+
// would stay visible at full opacity.)
|
|
222
|
+
const reduceRules = [];
|
|
223
|
+
if (mm.slides.length > 0)
|
|
224
|
+
reduceRules.push(`${mm.slides.map((s) => `.${s.cls}`).join(", ")} { animation: none; transform: none; }`);
|
|
225
|
+
if (mm.fadeIn.length > 0)
|
|
226
|
+
reduceRules.push(`${mm.fadeIn.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 1; }`);
|
|
227
|
+
if (mm.fadeOut.length > 0)
|
|
228
|
+
reduceRules.push(`${mm.fadeOut.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 0; }`);
|
|
229
|
+
if (reduceRules.length > 0) {
|
|
230
|
+
keyframes.push(`
|
|
231
|
+
@media (prefers-reduced-motion: reduce) {
|
|
232
|
+
${reduceRules.join("\n ")}
|
|
233
|
+
}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
120
236
|
else {
|
|
121
237
|
// Crossfade or cut: opacity in/out.
|
|
122
238
|
//
|
|
@@ -157,7 +273,7 @@ export function generateAnimatedSvg(config) {
|
|
|
157
273
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
|
|
158
274
|
}
|
|
159
275
|
else {
|
|
160
|
-
const fadeInStartPct = i > 0
|
|
276
|
+
const fadeInStartPct = (i > 0 && !entersViaMagicMove)
|
|
161
277
|
? pct(Math.max(0, timeOffset - prevTransDur), totalDuration)
|
|
162
278
|
: startPct;
|
|
163
279
|
const prevEnd = i > 0
|
|
@@ -191,6 +307,11 @@ export function generateAnimatedSvg(config) {
|
|
|
191
307
|
frameGroups.push(svgMarkup);
|
|
192
308
|
keyframes.push(css);
|
|
193
309
|
}
|
|
310
|
+
else if (overlay.kind === "blink") {
|
|
311
|
+
const { svgMarkup, css } = renderBlinkOverlay(overlay, i, timeOffset, timeOffset + frame.duration, totalDuration, totalSec);
|
|
312
|
+
frameGroups.push(svgMarkup);
|
|
313
|
+
keyframes.push(css);
|
|
314
|
+
}
|
|
194
315
|
}
|
|
195
316
|
}
|
|
196
317
|
timeOffset += frame.duration + transDur;
|
|
@@ -216,6 +337,13 @@ export function generateAnimatedSvg(config) {
|
|
|
216
337
|
const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
|
|
217
338
|
overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
|
|
218
339
|
}
|
|
340
|
+
// Canvas background rect — only when a non-transparent background is given.
|
|
341
|
+
// Default (none / transparent) emits nothing so the SVG composites over the
|
|
342
|
+
// host page, matching the single-frame `transparentRootBgRect` path (DM-554).
|
|
343
|
+
const bg = config.background;
|
|
344
|
+
const canvasBgRect = (bg != null && bg !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)")
|
|
345
|
+
? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
|
|
346
|
+
: "";
|
|
219
347
|
const out = `<?xml version="1.0" encoding="UTF-8"?>
|
|
220
348
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
|
221
349
|
<defs>
|
|
@@ -227,8 +355,7 @@ ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss +
|
|
|
227
355
|
${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
|
|
228
356
|
</style>
|
|
229
357
|
<g clip-path="url(#viewport-clip)">
|
|
230
|
-
|
|
231
|
-
${frameGroups.join("\n")}${overlayMarkup}
|
|
358
|
+
${canvasBgRect}${frameGroups.join("\n")}${overlayMarkup}
|
|
232
359
|
</g>
|
|
233
360
|
</svg>`;
|
|
234
361
|
return out;
|
|
@@ -337,6 +464,8 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
|
|
|
337
464
|
// width-growing clip during the slice of the type timeline when that line's
|
|
338
465
|
// characters are typed (line N starts after line N-1 finishes), so the caret
|
|
339
466
|
// advances down the field exactly as it would in the browser.
|
|
467
|
+
// DM-870: per-line type timing, collected for the optional caret below.
|
|
468
|
+
const lineTimings = [];
|
|
340
469
|
let cumChars = 0;
|
|
341
470
|
lines.forEach((line, li) => {
|
|
342
471
|
const lineY = overlay.y + li * lineHeight;
|
|
@@ -345,8 +474,11 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
|
|
|
345
474
|
// is where the caret would sit just after the typed character anyway.
|
|
346
475
|
const lineWidth = line.length * charWidth + charWidth;
|
|
347
476
|
const clipId = `${id}-clip${li}`;
|
|
348
|
-
const
|
|
349
|
-
const
|
|
477
|
+
const lineStartMs = typeStartMs + (cumChars / visibleChars) * effTypeDur;
|
|
478
|
+
const lineEndMs = typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur;
|
|
479
|
+
const lineStartPct = pct(lineStartMs, totalDuration);
|
|
480
|
+
const lineEndPct = pct(lineEndMs, totalDuration);
|
|
481
|
+
lineTimings.push({ li, startMs: lineStartMs, endMs: lineEndMs, len: line.length });
|
|
350
482
|
cumChars += line.length;
|
|
351
483
|
parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
|
|
352
484
|
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 +491,48 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
|
|
|
359
491
|
cssRules.push(`
|
|
360
492
|
@keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
|
|
361
493
|
.${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
|
|
494
|
+
// DM-870: blinking insertion caret. Sweeps the type position while typing
|
|
495
|
+
// (one linear translate segment per wrapped line, jumping to the next line's
|
|
496
|
+
// start), then parks at the end of the last line and blinks (step-end opacity
|
|
497
|
+
// toggle) until the overlay disappears. Two animations on one rect: a linear
|
|
498
|
+
// position track + a step-end opacity blink.
|
|
499
|
+
if (overlay.caret != null && overlay.caret !== false && lineTimings.length > 0) {
|
|
500
|
+
const caretOpts = typeof overlay.caret === "object" ? overlay.caret : {};
|
|
501
|
+
const caretColor = caretOpts.color ?? color;
|
|
502
|
+
const caretW = caretOpts.width ?? 2;
|
|
503
|
+
const blinkMs = caretOpts.blinkMs ?? 530;
|
|
504
|
+
const last = lineTimings[lineTimings.length - 1];
|
|
505
|
+
const endX = last.len * charWidth;
|
|
506
|
+
const endY = last.li * lineHeight;
|
|
507
|
+
// Position track: hold at line 0 start until typing begins, then sweep each
|
|
508
|
+
// line, then hold at the text end through the blink + disappear.
|
|
509
|
+
const posStops = [`0%, ${typeStartPct} { transform: translate(0px, 0px); }`];
|
|
510
|
+
for (const lt of lineTimings) {
|
|
511
|
+
posStops.push(`${pct(lt.startMs, totalDuration)} { transform: translate(0px, ${lt.li * lineHeight}px); }`);
|
|
512
|
+
posStops.push(`${pct(lt.endMs, totalDuration)} { transform: translate(${lt.len * charWidth}px, ${lt.li * lineHeight}px); }`);
|
|
513
|
+
}
|
|
514
|
+
posStops.push(`${holdEndPct}, 100% { transform: translate(${endX}px, ${endY}px); }`);
|
|
515
|
+
// Blink: invisible until typing starts, solid through typing, then toggle
|
|
516
|
+
// on/off every half-period until the overlay disappears.
|
|
517
|
+
const blinkStops = [
|
|
518
|
+
`0%, ${typeStartPct} { opacity: 0; }`,
|
|
519
|
+
`${pct(typeStartMs + 30, totalDuration)} { opacity: 1; }`,
|
|
520
|
+
`${pct(textEndMs, totalDuration)} { opacity: 1; }`,
|
|
521
|
+
];
|
|
522
|
+
let t = textEndMs + blinkMs / 2;
|
|
523
|
+
let on = false;
|
|
524
|
+
while (t < holdEndMs) {
|
|
525
|
+
blinkStops.push(`${pct(t, totalDuration)} { opacity: ${on ? 1 : 0}; }`);
|
|
526
|
+
t += blinkMs / 2;
|
|
527
|
+
on = !on;
|
|
528
|
+
}
|
|
529
|
+
blinkStops.push(`${disappearPct}, 100% { opacity: 0; }`);
|
|
530
|
+
parts.push(` <rect class="${id}-caret" x="${overlay.x}" y="${overlay.y - fontSize + 2}" width="${caretW}" height="${fontSize}" fill="${caretColor}" />`);
|
|
531
|
+
cssRules.push(`
|
|
532
|
+
@keyframes ${id}-caret-pos { ${posStops.join(" ")} }
|
|
533
|
+
@keyframes ${id}-caret-blink { ${blinkStops.join(" ")} }
|
|
534
|
+
.${id}-caret { animation: ${id}-caret-pos ${totalSec.toFixed(2)}s linear infinite, ${id}-caret-blink ${totalSec.toFixed(2)}s step-end infinite; }`);
|
|
535
|
+
}
|
|
362
536
|
return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
|
|
363
537
|
}
|
|
364
538
|
function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec) {
|
|
@@ -380,6 +554,29 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
|
|
|
380
554
|
.${id}-dot { animation: ${id}-dot ${totalSec.toFixed(2)}s infinite; }`;
|
|
381
555
|
return { svgMarkup, css };
|
|
382
556
|
}
|
|
557
|
+
function renderBlinkOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
|
|
558
|
+
const id = `blink${frameIdx}`;
|
|
559
|
+
const period = overlay.periodMs ?? 1000;
|
|
560
|
+
const color = overlay.color ?? "#e6edf3";
|
|
561
|
+
const startMs = frameStart + (overlay.delay ?? 0);
|
|
562
|
+
const radiusAttr = overlay.radius != null ? ` rx="${overlay.radius}" ry="${overlay.radius}"` : "";
|
|
563
|
+
// Toggle opacity on/off every half-period across the frame's hold, then off.
|
|
564
|
+
// step-end keeps each state until the next stop (a hard blink, not a fade).
|
|
565
|
+
const stops = [`0%, ${pct(startMs, totalDuration)} { opacity: 0; }`];
|
|
566
|
+
let t = startMs;
|
|
567
|
+
let on = true;
|
|
568
|
+
while (t < frameEnd) {
|
|
569
|
+
stops.push(`${pct(t, totalDuration)} { opacity: ${on ? 1 : 0}; }`);
|
|
570
|
+
t += period / 2;
|
|
571
|
+
on = !on;
|
|
572
|
+
}
|
|
573
|
+
stops.push(`${pct(frameEnd, totalDuration)}, 100% { opacity: 0; }`);
|
|
574
|
+
const svgMarkup = ` <rect class="${id}" x="${overlay.x}" y="${overlay.y}" width="${overlay.width}" height="${overlay.height}"${radiusAttr} fill="${color}" />`;
|
|
575
|
+
const css = `
|
|
576
|
+
@keyframes ${id} { ${stops.join(" ")} }
|
|
577
|
+
.${id} { animation: ${id} ${totalSec.toFixed(2)}s step-end infinite; }`;
|
|
578
|
+
return { svgMarkup, css };
|
|
579
|
+
}
|
|
383
580
|
function pct(ms, total) {
|
|
384
581
|
return `${((ms / total) * 100).toFixed(2)}%`;
|
|
385
582
|
}
|
|
@@ -483,50 +680,6 @@ function offsetForDirection(dir, w, h, _outFrom) {
|
|
|
483
680
|
return `translate(-${w}px, 0)`;
|
|
484
681
|
return `translate(${w}px, 0)`; // right
|
|
485
682
|
}
|
|
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
683
|
/**
|
|
531
684
|
* Compile each frame's intra-frame animations into CSS. Each animation gets
|
|
532
685
|
* a uniquely-named keyframe block whose timing is mapped onto the global
|
|
@@ -560,15 +713,33 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
|
|
|
560
713
|
return `${a.property}: ${val};`;
|
|
561
714
|
};
|
|
562
715
|
const animName = `f${i}-${a.animId}-${ai}`;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
716
|
+
if (a.repeat != null) {
|
|
717
|
+
// DM-869: repeating animation (blink / pulse / breathe). The keyframe is
|
|
718
|
+
// a single from→to cycle on the animation's own `duration` clock, looped
|
|
719
|
+
// via animation-iteration-count + (optional) direction:alternate. The
|
|
720
|
+
// loop is only visible while the frame is on screen (the frame group's
|
|
721
|
+
// visibility gating); `animation-delay` aligns the first cycle to the
|
|
722
|
+
// frame's appearance. `fill-mode: both` holds `from` before the delay.
|
|
723
|
+
const iterations = a.repeat === "infinite" ? "infinite" : String(a.repeat);
|
|
724
|
+
const direction = a.alternate === true ? " alternate" : "";
|
|
725
|
+
out.push(` @keyframes ${animName} {
|
|
726
|
+
0% { ${propValue(a.from)} }
|
|
727
|
+
100% { ${propValue(a.to)} }
|
|
728
|
+
}
|
|
729
|
+
.anim-${a.animId} { animation: ${animName} ${a.duration}ms ${iterations}${direction}; animation-timing-function: ${easing}; animation-delay: ${startMs.toFixed(0)}ms; animation-fill-mode: both; }`);
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
// One-shot: hold `from` until startPct, animate from→to during
|
|
733
|
+
// [startPct, endPct], hold `to` afterwards, mapped onto the global scene
|
|
734
|
+
// clock so it replays in sync each scene loop.
|
|
735
|
+
out.push(` @keyframes ${animName} {
|
|
566
736
|
0% { ${propValue(a.from)} }
|
|
567
737
|
${startPct.toFixed(3)}% { ${propValue(a.from)} }
|
|
568
738
|
${endPct.toFixed(3)}% { ${propValue(a.to)} }
|
|
569
739
|
100% { ${propValue(a.to)} }
|
|
570
740
|
}
|
|
571
741
|
.anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
|
|
742
|
+
}
|
|
572
743
|
}
|
|
573
744
|
}
|
|
574
745
|
return out.length === 0 ? "" : "\n" + out.join("\n");
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
export { buildMagicMove, type MagicMove, type MagicMoveSlide, } from "./magic-move.js";
|
|
1
2
|
export { generateAnimatedSvg, type AnimationConfig, type AnimationFrame, type AnimationOverlay, type TypingOverlay, type TapOverlay, type SvgOverlay, type IntraFrameAnimation, } from "./animator.js";
|
|
2
3
|
export { cursorOverlayMarkup, resolveCursorScript, type CursorOverlay, type CursorEvent, type CursorMoveEvent, type CursorClickEvent, type CursorShowEvent, type CursorHideEvent, type CursorStyle, type SelectorResolver, } from "./cursor-overlay.js";
|
package/dist/animation/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Public surface of the animation pipeline. The composer
|
|
2
2
|
// (`generateAnimatedSvg`) consumes per-frame element trees + transition /
|
|
3
3
|
// overlay config and emits one self-contained SVG with `@keyframes` cross-
|
|
4
|
-
// fade / push-left / scroll / cut transitions and optional
|
|
5
|
-
// SVG / cursor overlays.
|
|
4
|
+
// fade / push-left / scroll / cut / magic-move transitions and optional
|
|
5
|
+
// typing / tap / SVG / cursor overlays.
|
|
6
|
+
export { buildMagicMove, } from "./magic-move.js";
|
|
6
7
|
export { generateAnimatedSvg, } from "./animator.js";
|
|
7
8
|
export { cursorOverlayMarkup, resolveCursorScript, } from "./cursor-overlay.js";
|