domotion-svg 0.4.1 → 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 +56 -1
- package/dist/animation/animator.js +217 -94
- package/dist/animation/animator.test.js +156 -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 +167 -28
- package/src/animation/animator.ts +280 -112
- 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
|
|
|
@@ -56,8 +56,30 @@ export interface TypingOverlay {
|
|
|
56
56
|
speed?: number;
|
|
57
57
|
/** Background color to mask placeholder text */
|
|
58
58
|
bgColor?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Field width in px. When set, the typed text WRAPS to this width like a
|
|
61
|
+
* browser textarea — breaking on spaces (char-breaking over-long words),
|
|
62
|
+
* advancing one line-height per wrapped line — instead of running off the
|
|
63
|
+
* right edge on a single line (DM-840). Omit for unbounded single-line text.
|
|
64
|
+
*/
|
|
59
65
|
bgWidth?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Field height in px (used to size the placeholder mask). The mask grows
|
|
68
|
+
* beyond this if the wrapped text needs more lines, so the typed text always
|
|
69
|
+
* sits on a clean background.
|
|
70
|
+
*/
|
|
60
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
|
+
};
|
|
61
83
|
}
|
|
62
84
|
export interface TapOverlay {
|
|
63
85
|
kind: "tap";
|
|
@@ -111,7 +133,29 @@ export interface SvgOverlay {
|
|
|
111
133
|
delay?: number;
|
|
112
134
|
};
|
|
113
135
|
}
|
|
114
|
-
|
|
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;
|
|
115
159
|
/**
|
|
116
160
|
* Animate a CSS property on captured elements that match a selector, while
|
|
117
161
|
* the frame is held on screen. The selector is resolved against the source
|
|
@@ -143,6 +187,17 @@ export interface IntraFrameAnimation {
|
|
|
143
187
|
easing?: string;
|
|
144
188
|
/** Ms after the frame becomes visible before animation starts. Default 0. */
|
|
145
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;
|
|
146
201
|
}
|
|
147
202
|
export interface AnimationConfig {
|
|
148
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;
|
|
@@ -245,46 +250,167 @@ function transitionDuration(f) {
|
|
|
245
250
|
return 0;
|
|
246
251
|
return f.transition.duration;
|
|
247
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Wrap `text` into lines no wider than `maxChars` monospace cells, the way a
|
|
255
|
+
* browser textarea does: break on spaces, char-break a word longer than the
|
|
256
|
+
* field, and honor explicit newlines. `maxChars === Infinity` → no wrap (one
|
|
257
|
+
* line per explicit-newline paragraph), preserving the pre-DM-840 behavior for
|
|
258
|
+
* overlays with no `bgWidth`. DM-840.
|
|
259
|
+
*/
|
|
260
|
+
function wrapTypingText(text, maxChars) {
|
|
261
|
+
const lines = [];
|
|
262
|
+
for (const paragraph of text.split("\n")) {
|
|
263
|
+
if (maxChars === Infinity) {
|
|
264
|
+
lines.push(paragraph);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (paragraph === "") {
|
|
268
|
+
lines.push("");
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
let cur = "";
|
|
272
|
+
for (let word of paragraph.split(" ")) {
|
|
273
|
+
// A single word wider than the line char-breaks across lines.
|
|
274
|
+
while (word.length > maxChars) {
|
|
275
|
+
if (cur !== "") {
|
|
276
|
+
lines.push(cur);
|
|
277
|
+
cur = "";
|
|
278
|
+
}
|
|
279
|
+
lines.push(word.slice(0, maxChars));
|
|
280
|
+
word = word.slice(maxChars);
|
|
281
|
+
}
|
|
282
|
+
if (cur === "")
|
|
283
|
+
cur = word;
|
|
284
|
+
else if ((cur + " " + word).length <= maxChars)
|
|
285
|
+
cur += " " + word;
|
|
286
|
+
else {
|
|
287
|
+
lines.push(cur);
|
|
288
|
+
cur = word;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
lines.push(cur);
|
|
292
|
+
}
|
|
293
|
+
return lines;
|
|
294
|
+
}
|
|
248
295
|
function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
|
|
249
296
|
const delay = overlay.delay ?? 300;
|
|
250
297
|
const speed = overlay.speed ?? 60;
|
|
251
298
|
const fontSize = overlay.fontSize ?? 14;
|
|
252
|
-
const charWidth = fontSize * 0.6;
|
|
299
|
+
const charWidth = fontSize * 0.6; // monospace cell (overlay font is monospace)
|
|
300
|
+
const lineHeight = Math.round(fontSize * 1.35);
|
|
253
301
|
const color = overlay.color ?? "#e6edf3";
|
|
254
302
|
const typeStartMs = frameStart + delay;
|
|
303
|
+
const id = `t${frameIdx}`;
|
|
304
|
+
// DM-840: wrap to bgWidth so typed text behaves like a browser field
|
|
305
|
+
// (textarea) — wrapping to the next line instead of running off the right
|
|
306
|
+
// edge. Text starts at overlay.x and the bg rect starts at overlay.x-2 with
|
|
307
|
+
// width bgWidth, so the usable text width is bgWidth-4. With no bgWidth we
|
|
308
|
+
// keep the original single-line behavior (maxChars = Infinity).
|
|
309
|
+
const maxLineWidth = overlay.bgWidth != null ? overlay.bgWidth - 4 : Infinity;
|
|
310
|
+
const maxChars = maxLineWidth === Infinity ? Infinity : Math.max(1, Math.floor(maxLineWidth / charWidth));
|
|
311
|
+
const lines = wrapTypingText(overlay.text, maxChars);
|
|
312
|
+
const visibleChars = Math.max(1, lines.reduce((n, l) => n + l.length, 0));
|
|
313
|
+
const longestLineChars = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
255
314
|
const parts = [];
|
|
256
315
|
const cssRules = [];
|
|
257
|
-
|
|
258
|
-
//
|
|
316
|
+
// ── Timeline — all stops clamped to the frame so the overlay can't leak
|
|
317
|
+
// across the cut into the next frame. `naturalEnd` is when typing finishes
|
|
318
|
+
// at the requested speed; if that runs past the frame we compress the reveal
|
|
319
|
+
// to fit. The fully-typed text then HOLDS until just before the frame ends
|
|
320
|
+
// (the old hard 3 s cap cut long text off mid-type), then fades out.
|
|
321
|
+
const disappearGap = 150;
|
|
322
|
+
const naturalEndMs = typeStartMs + visibleChars * speed;
|
|
323
|
+
const textEndMs = Math.min(naturalEndMs, Math.max(typeStartMs + 1, frameEnd - disappearGap));
|
|
324
|
+
const effTypeDur = Math.max(1, textEndMs - typeStartMs);
|
|
325
|
+
const holdEndMs = Math.max(textEndMs, frameEnd - disappearGap);
|
|
326
|
+
const disappearMs = Math.min(frameEnd, holdEndMs + 100);
|
|
327
|
+
const textHeight = fontSize + 4;
|
|
328
|
+
const holdEndPct = pct(holdEndMs, totalDuration);
|
|
329
|
+
const disappearPct = pct(disappearMs, totalDuration);
|
|
330
|
+
// Background mask — grown to cover every wrapped line so the typed text
|
|
331
|
+
// always lands on a clean field instead of the captured placeholder.
|
|
259
332
|
if (overlay.bgColor != null) {
|
|
260
|
-
const bgW = overlay.bgWidth ??
|
|
261
|
-
const bgH = overlay.bgHeight ?? fontSize + 6;
|
|
333
|
+
const bgW = overlay.bgWidth ?? longestLineChars * charWidth + 8;
|
|
334
|
+
const bgH = Math.max(overlay.bgHeight ?? fontSize + 6, lines.length * lineHeight + 6);
|
|
262
335
|
const bgStartPct = pct(typeStartMs, totalDuration);
|
|
263
|
-
const bgEndPct = pct(frameStart + overlay.text.length * speed + delay + 500, totalDuration);
|
|
264
336
|
parts.push(` <rect class="${id}-bg" x="${overlay.x - 2}" y="${overlay.y - fontSize + 2}" width="${bgW}" height="${bgH}" fill="${overlay.bgColor}" rx="2" />`);
|
|
265
337
|
cssRules.push(`
|
|
266
|
-
@keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${
|
|
338
|
+
@keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
|
|
267
339
|
.${id}-bg { animation: ${id}-bg ${totalSec.toFixed(2)}s infinite; }`);
|
|
268
340
|
}
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
341
|
+
// Typewriter reveal: one <text> per wrapped line, each unveiled by a
|
|
342
|
+
// width-growing clip during the slice of the type timeline when that line's
|
|
343
|
+
// characters are typed (line N starts after line N-1 finishes), so the caret
|
|
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 = [];
|
|
347
|
+
let cumChars = 0;
|
|
348
|
+
lines.forEach((line, li) => {
|
|
349
|
+
const lineY = overlay.y + li * lineHeight;
|
|
350
|
+
// +1 cell of slack so the last glyph never clips: the real monospace
|
|
351
|
+
// advance is slightly wider than the 0.6em estimate, and the trailing cell
|
|
352
|
+
// is where the caret would sit just after the typed character anyway.
|
|
353
|
+
const lineWidth = line.length * charWidth + charWidth;
|
|
354
|
+
const clipId = `${id}-clip${li}`;
|
|
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 });
|
|
360
|
+
cumChars += line.length;
|
|
361
|
+
parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
|
|
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>`);
|
|
363
|
+
cssRules.push(`
|
|
364
|
+
@keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
|
|
365
|
+
.${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s infinite; }`);
|
|
366
|
+
});
|
|
367
|
+
// Whole-overlay visibility — shared by every line's <text>.
|
|
280
368
|
const typeStartPct = pct(typeStartMs, totalDuration);
|
|
281
|
-
const typeEndPct = pct(textEndMs, totalDuration);
|
|
282
|
-
const holdEndPct = pct(holdEndMs, totalDuration);
|
|
283
369
|
cssRules.push(`
|
|
284
|
-
@keyframes ${id}-
|
|
285
|
-
.${id}-reveal { animation: ${id}-reveal ${totalSec.toFixed(2)}s infinite; }
|
|
286
|
-
@keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${pct(holdEndMs + 100, totalDuration)}, 100% { opacity: 0; } }
|
|
370
|
+
@keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
|
|
287
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
|
+
}
|
|
288
414
|
return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
|
|
289
415
|
}
|
|
290
416
|
function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec) {
|
|
@@ -306,6 +432,29 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
|
|
|
306
432
|
.${id}-dot { animation: ${id}-dot ${totalSec.toFixed(2)}s infinite; }`;
|
|
307
433
|
return { svgMarkup, css };
|
|
308
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
|
+
}
|
|
309
458
|
function pct(ms, total) {
|
|
310
459
|
return `${((ms / total) * 100).toFixed(2)}%`;
|
|
311
460
|
}
|
|
@@ -409,50 +558,6 @@ function offsetForDirection(dir, w, h, _outFrom) {
|
|
|
409
558
|
return `translate(-${w}px, 0)`;
|
|
410
559
|
return `translate(${w}px, 0)`; // right
|
|
411
560
|
}
|
|
412
|
-
/**
|
|
413
|
-
* Compose the animated SVG using the frame-merge pipeline. Every element in
|
|
414
|
-
* every frame is reduced to one render with a visibility timeline. Stable
|
|
415
|
-
* elements (prompt, background, typed characters that stay on screen) emit
|
|
416
|
-
* once with opacity: 1 throughout; changing elements get step-end keyframes
|
|
417
|
-
* that flip their opacity at the appropriate frame boundaries.
|
|
418
|
-
*/
|
|
419
|
-
function composeMergedSvg(config, frameTiming, totalSec) {
|
|
420
|
-
const { width, height, frames } = config;
|
|
421
|
-
const framesSvg = frames.map((f) => f.svgContent);
|
|
422
|
-
const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
|
|
423
|
-
const sharedDefsMarkup = config.sharedDefs ?? "";
|
|
424
|
-
const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
|
|
425
|
-
// DM-603: viewBox-cull keyframes from each frame's pre-pass (see unmerged path).
|
|
426
|
-
const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
|
|
427
|
-
// Cursor overlay (DM-277). Same emission as the unmerged path — the
|
|
428
|
-
// overlay sits above the merged frame group, clipped to the viewport.
|
|
429
|
-
const totalDuration = totalSec * 1000;
|
|
430
|
-
let overlayMarkup = "";
|
|
431
|
-
if (config.cursorOverlay != null && config.cursorOverlay.events.length > 0) {
|
|
432
|
-
const frameStarts = [];
|
|
433
|
-
let acc = 0;
|
|
434
|
-
for (const f of frames) {
|
|
435
|
-
frameStarts.push(acc);
|
|
436
|
-
acc += f.duration + transitionDuration(f);
|
|
437
|
-
}
|
|
438
|
-
const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
|
|
439
|
-
overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
|
|
440
|
-
}
|
|
441
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
442
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
|
443
|
-
<defs>
|
|
444
|
-
<clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
|
|
445
|
-
</defs>
|
|
446
|
-
<style>
|
|
447
|
-
${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss + "\n" : ""} :root { --scene-dur: ${totalSec.toFixed(2)}s; }
|
|
448
|
-
${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
|
|
449
|
-
</style>
|
|
450
|
-
<g clip-path="url(#viewport-clip)">
|
|
451
|
-
<rect width="${width}" height="${height}" fill="#0d1117" />
|
|
452
|
-
${merged}${overlayMarkup}
|
|
453
|
-
</g>
|
|
454
|
-
</svg>`;
|
|
455
|
-
}
|
|
456
561
|
/**
|
|
457
562
|
* Compile each frame's intra-frame animations into CSS. Each animation gets
|
|
458
563
|
* a uniquely-named keyframe block whose timing is mapped onto the global
|
|
@@ -486,15 +591,33 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
|
|
|
486
591
|
return `${a.property}: ${val};`;
|
|
487
592
|
};
|
|
488
593
|
const animName = `f${i}-${a.animId}-${ai}`;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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} {
|
|
492
614
|
0% { ${propValue(a.from)} }
|
|
493
615
|
${startPct.toFixed(3)}% { ${propValue(a.from)} }
|
|
494
616
|
${endPct.toFixed(3)}% { ${propValue(a.to)} }
|
|
495
617
|
100% { ${propValue(a.to)} }
|
|
496
618
|
}
|
|
497
619
|
.anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
|
|
620
|
+
}
|
|
498
621
|
}
|
|
499
622
|
}
|
|
500
623
|
return out.length === 0 ? "" : "\n" + out.join("\n");
|