freemotion 0.1.0 → 0.1.1
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 +26 -4
- package/dist/node/args.js +8 -4
- package/dist/node/ffmpeg.d.ts +7 -2
- package/dist/node/ffmpeg.js +5 -3
- package/dist/node/render.d.ts +2 -0
- package/dist/node/render.js +61 -11
- package/dist/shared/render-api.d.ts +14 -0
- package/dist/studio/render-page.js +22 -1
- package/dist/studio/svg-export.d.ts +70 -0
- package/dist/studio/svg-export.js +276 -0
- package/dist/studio/svg-recorder.d.ts +40 -0
- package/dist/studio/svg-recorder.js +312 -0
- package/package.json +2 -1
- package/templates/default/node_modules/.vitest-cache/CACHEDIR.TAG +0 -3
- package/templates/default/node_modules/.vitest-cache/_metadata.json +0 -3
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ Under the hood:
|
|
|
126
126
|
| | |
|
|
127
127
|
|---|---|
|
|
128
128
|
| **`freemotion studio`** | Starts a Vite dev server whose home page is the studio, with your compositions loaded and hot reload on. |
|
|
129
|
-
| **`freemotion render`** | Bundles the project with Vite, opens it in several headless Chromium tabs and, for each frame, draws it, waits for fonts, images and [`delayRender()`](#async-data-delayrender) holds, captures a PNG and pipes it to ffmpeg, in order. |
|
|
129
|
+
| **`freemotion render`** | Bundles the project with Vite, opens it in several headless Chromium tabs and, for each frame, draws it, waits for fonts, images and [`delayRender()`](#async-data-delayrender) holds, captures a PNG and pipes it to ffmpeg, in order. For an [animated SVG](#animated-svg), one tab records the DOM of every frame instead. |
|
|
130
130
|
| **`<Player>`** | Draws a composition inside your own React app, scaled to fit its container. |
|
|
131
131
|
|
|
132
132
|
## 🎬 Studio
|
|
@@ -156,6 +156,7 @@ npx freemotion render Intro # out/Intro.mp4
|
|
|
156
156
|
npx freemotion render Intro -o promo.webm # VP9 WebM
|
|
157
157
|
npx freemotion render Intro -o promo.gif # animated GIF
|
|
158
158
|
npx freemotion render Intro -o promo.gif --scale 0.5 --every-nth-frame 2 # half size, 15 fps
|
|
159
|
+
npx freemotion render Intro -o banner.svg # animated SVG, no ffmpeg needed
|
|
159
160
|
npx freemotion render Intro --frame 60 # out/Intro.png, a still
|
|
160
161
|
npx freemotion render Intro --frames 0-89 # only the first 3 seconds
|
|
161
162
|
npx freemotion render Intro --scale 2 # twice the width and height
|
|
@@ -169,18 +170,20 @@ npx freemotion render --all # every composition, to out/
|
|
|
169
170
|
| `.webm` | VP9, CRF 32 | Needs an ffmpeg built with `libvpx`. |
|
|
170
171
|
| `.gif` | Palette per video | For lighter files, shrink it with `--scale 0.5` and drop frames with `--every-nth-frame 2`. |
|
|
171
172
|
| `.png` | Lossless | A single frame (`--frame`). ffmpeg is not needed. |
|
|
173
|
+
| `.svg` | CSS keyframes | A vector file that plays by itself, in browsers and GitHub READMEs. ffmpeg is not needed. See [Animated SVG](#animated-svg). |
|
|
172
174
|
|
|
173
175
|
<details>
|
|
174
176
|
<summary><b>All render options</b></summary>
|
|
175
177
|
|
|
176
178
|
```text
|
|
177
|
-
-o, --output <file> Output file, for a single composition (.mp4, .webm, .gif or .
|
|
179
|
+
-o, --output <file> Output file, for a single composition (.mp4, .webm, .gif, .png or .svg)
|
|
178
180
|
--out-dir <dir> Folder for <id>.<format> files (default: out)
|
|
179
181
|
--all Render every composition
|
|
180
|
-
--format <format> mp4, webm, gif or
|
|
182
|
+
--format <format> mp4, webm, gif, png or svg (default: from --output, else mp4; png with --frame)
|
|
181
183
|
--frame <n> Render a still image of frame n
|
|
182
184
|
--frames <a-b> Render only frames a to b (inclusive)
|
|
183
|
-
--every-nth-frame <n> Keep one frame out of every n, at 1/n of the frame rate (lighter GIFs)
|
|
185
|
+
--every-nth-frame <n> Keep one frame out of every n, at 1/n of the frame rate (lighter GIFs and SVGs)
|
|
186
|
+
--no-loop GIF and SVG: stop on the last frame instead of starting over
|
|
184
187
|
--props <json|file> Props merged over defaultProps, as JSON or a path to a .json file
|
|
185
188
|
--scale <n> Pixel density: 2 renders at twice the width and height (default: 1)
|
|
186
189
|
--crf <n> Quality for mp4/webm, lower is better (default: 18 mp4, 32 webm)
|
|
@@ -195,6 +198,25 @@ npx freemotion render --all # every composition, to out/
|
|
|
195
198
|
|
|
196
199
|
</details>
|
|
197
200
|
|
|
201
|
+
### Animated SVG
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
npx freemotion render Banner -o banner.svg # loops, like a GIF
|
|
205
|
+
npx freemotion render Banner -o banner.svg --no-loop # plays once and stays on the last frame
|
|
206
|
+
npx freemotion render Banner -o banner.svg --frame 30 # a still, as vectors
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The same composition, the same frames, as one `.svg` file: text stays text, edges stay sharp at any size, and the file is usually far smaller than a GIF. Put it in a README with `<img src="banner.svg">`, in a web page inline or as `<img>`, or anywhere else a browser engine draws SVG.
|
|
210
|
+
|
|
211
|
+
How it's made: the renderer draws every frame as usual, but instead of a screenshot it records the DOM of the stage. Whatever changed from one frame to the next (an inline style, an SVG attribute, an element that mounted or unmounted, a text that counts) becomes CSS `@keyframes` that step at the frame rate; the rest is written once. The markup goes inside a `<foreignObject>`, so it is the HTML your components produced, along with the stylesheets, the images and the font faces it uses, embedded as `data:` URLs so the file stands alone.
|
|
212
|
+
|
|
213
|
+
What to know:
|
|
214
|
+
|
|
215
|
+
- **It needs a browser engine.** Chrome, Edge, Firefox and Safari from 2024 on play it, inline or as `<img>`. Design tools such as Inkscape, Illustrator or Figma don't render `<foreignObject>`, and `<img>` can't run scripts, which is why everything is CSS.
|
|
216
|
+
- **Size grows with change.** A value that moves every frame costs one keyframe per frame. Elements whose attributes CSS can't animate (a `<path d>` that morphs, an `<img src>` that swaps) are written once per distinct value. `--every-nth-frame 2` halves all of it. Fonts are embedded only for the faces and the characters the video uses, but a font family can still weigh a few hundred KB.
|
|
217
|
+
- **Not everything survives.** `<canvas>` is captured as one image per frame (heavy), `<video>` and WebGL don't play, shadow DOM is not recorded, and stylesheet rules must target elements inside the composition (`body` and `html` don't exist in the file). Where the composition paints nothing, the SVG is transparent, where a video would be white.
|
|
218
|
+
- **Timing is frame-exact.** Each keyframe holds for one frame of the original frame rate, like the video. The `--scale`, `--crf` and `--concurrency` options don't apply.
|
|
219
|
+
|
|
198
220
|
### Tips for identical renders everywhere
|
|
199
221
|
|
|
200
222
|
- **Bundle your fonts** with a package such as [`@fontsource`](https://fontsource.org) instead of relying on fonts installed on the machine. The renderer waits for every font face to load.
|
package/dist/node/args.js
CHANGED
|
@@ -12,7 +12,7 @@ Usage
|
|
|
12
12
|
Commands
|
|
13
13
|
init [directory] Create a new project (default directory: my-video)
|
|
14
14
|
studio Preview your compositions with hot reload
|
|
15
|
-
render <id...> Render compositions to MP4, WebM, GIF or
|
|
15
|
+
render <id...> Render compositions to MP4, WebM, GIF, PNG or animated SVG
|
|
16
16
|
|
|
17
17
|
Options
|
|
18
18
|
-h, --help Show help for a command
|
|
@@ -39,13 +39,14 @@ Options
|
|
|
39
39
|
freemotion render --all [options]
|
|
40
40
|
|
|
41
41
|
Options
|
|
42
|
-
-o, --output <file> Output file, for a single composition (.mp4, .webm, .gif or .
|
|
42
|
+
-o, --output <file> Output file, for a single composition (.mp4, .webm, .gif, .png or .svg)
|
|
43
43
|
--out-dir <dir> Folder for <id>.<format> files (default: out)
|
|
44
44
|
--all Render every composition
|
|
45
|
-
--format <format> mp4, webm, gif or
|
|
45
|
+
--format <format> mp4, webm, gif, png or svg (default: from --output, else mp4; png with --frame)
|
|
46
46
|
--frame <n> Render a still image of frame n
|
|
47
47
|
--frames <a-b> Render only frames a to b (inclusive)
|
|
48
|
-
--every-nth-frame <n> Keep one frame out of every n, at 1/n of the frame rate (lighter GIFs)
|
|
48
|
+
--every-nth-frame <n> Keep one frame out of every n, at 1/n of the frame rate (lighter GIFs and SVGs)
|
|
49
|
+
--no-loop GIF and SVG: stop on the last frame instead of starting over
|
|
49
50
|
--props <json|file> Props merged over defaultProps, as JSON or a path to a .json file
|
|
50
51
|
--scale <n> Pixel density: 2 renders at twice the width and height (default: 1)
|
|
51
52
|
--crf <n> Quality for mp4/webm, lower is better (default: 18 mp4, 32 webm)
|
|
@@ -164,6 +165,7 @@ export function parseCommand(argv, { cwd, env }) {
|
|
|
164
165
|
const { values, positionals } = parseArgs({
|
|
165
166
|
args,
|
|
166
167
|
allowPositionals: true,
|
|
168
|
+
allowNegative: true,
|
|
167
169
|
options: {
|
|
168
170
|
help: { type: 'boolean', short: 'h' },
|
|
169
171
|
output: { type: 'string', short: 'o' },
|
|
@@ -173,6 +175,7 @@ export function parseCommand(argv, { cwd, env }) {
|
|
|
173
175
|
frame: { type: 'string' },
|
|
174
176
|
frames: { type: 'string' },
|
|
175
177
|
'every-nth-frame': { type: 'string' },
|
|
178
|
+
loop: { type: 'boolean' },
|
|
176
179
|
props: { type: 'string' },
|
|
177
180
|
scale: { type: 'string' },
|
|
178
181
|
crf: { type: 'string' },
|
|
@@ -214,6 +217,7 @@ export function parseCommand(argv, { cwd, env }) {
|
|
|
214
217
|
frame: integer(values.frame, '--frame', 0),
|
|
215
218
|
frames: values.frames === undefined ? undefined : parseFrames(values.frames),
|
|
216
219
|
everyNthFrame: integer(values['every-nth-frame'], '--every-nth-frame', 1),
|
|
220
|
+
loop: values.loop,
|
|
217
221
|
props: values.props === undefined ? undefined : parseProps(values.props, cwd),
|
|
218
222
|
scale: positive(values.scale, '--scale') ?? 1,
|
|
219
223
|
crf: values.crf === undefined ? undefined : integer(values.crf, '--crf', 0),
|
package/dist/node/ffmpeg.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
export type VideoFormat = 'mp4' | 'webm' | 'gif';
|
|
2
|
-
|
|
2
|
+
/** `png` is a still, `svg` an animated vector file; neither needs ffmpeg. */
|
|
3
|
+
export type OutputFormat = VideoFormat | 'png' | 'svg';
|
|
3
4
|
export declare const OUTPUT_FORMATS: readonly OutputFormat[];
|
|
5
|
+
/** Formats encoded by ffmpeg from PNG frames. */
|
|
6
|
+
export declare const isVideoFormat: (format: OutputFormat) => format is VideoFormat;
|
|
4
7
|
export declare const DEFAULT_CRF: Record<Exclude<VideoFormat, 'gif'>, number>;
|
|
5
8
|
export declare function formatFromPath(path: string): OutputFormat | null;
|
|
6
9
|
/** ffmpeg arguments that read PNG frames from stdin and encode them to `output`. */
|
|
7
|
-
export declare function ffmpegArgs({ format, fps, crf, output }: {
|
|
10
|
+
export declare function ffmpegArgs({ format, fps, crf, loop, output }: {
|
|
8
11
|
readonly format: VideoFormat;
|
|
9
12
|
readonly fps: number;
|
|
10
13
|
readonly crf?: number;
|
|
14
|
+
/** GIF: play again from the start (default), or stop on the last frame. */
|
|
15
|
+
readonly loop?: boolean;
|
|
11
16
|
readonly output: string;
|
|
12
17
|
}): string[];
|
|
13
18
|
/**
|
package/dist/node/ffmpeg.js
CHANGED
|
@@ -2,7 +2,9 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { extname, join } from 'node:path';
|
|
5
|
-
export const OUTPUT_FORMATS = ['mp4', 'webm', 'gif', 'png'];
|
|
5
|
+
export const OUTPUT_FORMATS = ['mp4', 'webm', 'gif', 'png', 'svg'];
|
|
6
|
+
/** Formats encoded by ffmpeg from PNG frames. */
|
|
7
|
+
export const isVideoFormat = (format) => format !== 'png' && format !== 'svg';
|
|
6
8
|
export const DEFAULT_CRF = { mp4: 18, webm: 32 };
|
|
7
9
|
export function formatFromPath(path) {
|
|
8
10
|
const extension = extname(path).slice(1).toLowerCase();
|
|
@@ -12,7 +14,7 @@ export function formatFromPath(path) {
|
|
|
12
14
|
// so players show the same colors the browser drew.
|
|
13
15
|
const YUV_FILTERS = 'scale=out_color_matrix=bt709:out_range=tv,format=yuv420p,setsar=1,setparams=range=tv:color_primaries=bt709:color_trc=iec61966-2-1:colorspace=bt709';
|
|
14
16
|
/** ffmpeg arguments that read PNG frames from stdin and encode them to `output`. */
|
|
15
|
-
export function ffmpegArgs({ format, fps, crf, output }) {
|
|
17
|
+
export function ffmpegArgs({ format, fps, crf, loop = true, output }) {
|
|
16
18
|
const input = ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'png', '-i', '-'];
|
|
17
19
|
switch (format) {
|
|
18
20
|
case 'mp4':
|
|
@@ -34,7 +36,7 @@ export function ffmpegArgs({ format, fps, crf, output }) {
|
|
|
34
36
|
return [
|
|
35
37
|
...input,
|
|
36
38
|
'-filter_complex', 'split[a][b];[a]palettegen=stats_mode=full[palette];[b][palette]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle',
|
|
37
|
-
'-loop', '0',
|
|
39
|
+
'-loop', loop ? '0' : '-1',
|
|
38
40
|
output,
|
|
39
41
|
];
|
|
40
42
|
}
|
package/dist/node/render.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type RenderOptions = {
|
|
|
23
23
|
readonly frames?: readonly [number, number];
|
|
24
24
|
/** Videos: keep one frame out of every n, and lower the frame rate to match. Default `1`. */
|
|
25
25
|
readonly everyNthFrame?: number;
|
|
26
|
+
/** GIF and SVG: start over at the end (default), or stop on the last frame. */
|
|
27
|
+
readonly loop?: boolean;
|
|
26
28
|
/** Merged over each composition's `defaultProps`. */
|
|
27
29
|
readonly props?: Record<string, unknown>;
|
|
28
30
|
readonly concurrency: number;
|
package/dist/node/render.js
CHANGED
|
@@ -6,7 +6,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
6
6
|
import { build, mergeConfig, preview } from 'vite';
|
|
7
7
|
import { RENDER_API_GLOBAL } from '../shared/render-api.js';
|
|
8
8
|
import { launchBrowser } from './browser.js';
|
|
9
|
-
import { assertFfmpeg, ffmpegArgs, formatFromPath, resolveFfmpeg } from './ffmpeg.js';
|
|
9
|
+
import { assertFfmpeg, ffmpegArgs, formatFromPath, isVideoFormat, resolveFfmpeg } from './ffmpeg.js';
|
|
10
10
|
import { produceInOrder } from './ordered-frames.js';
|
|
11
11
|
import { RENDER_ENTRY } from './paths.js';
|
|
12
12
|
import { resolveEntry } from './project.js';
|
|
@@ -52,11 +52,8 @@ async function renderStill(session, job) {
|
|
|
52
52
|
await tab.page.close();
|
|
53
53
|
return { id: job.id, output: job.output, format: 'png', frames: 1, seconds: (Date.now() - startedAt) / 1000 };
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const startedAt = Date.now();
|
|
58
|
-
const first = await openTab(session, job.id);
|
|
59
|
-
const { meta } = first;
|
|
55
|
+
/** The frames of a video or animated SVG, honoring `--frames` and `--every-nth-frame`, and their frame rate. */
|
|
56
|
+
function planFrames(meta, options) {
|
|
60
57
|
const [from, to] = options.frames ?? [0, meta.durationInFrames - 1];
|
|
61
58
|
checkFrame(meta, from, '--frames start');
|
|
62
59
|
checkFrame(meta, to, '--frames end');
|
|
@@ -64,14 +61,21 @@ async function renderVideo(session, job, format) {
|
|
|
64
61
|
throw new Error(`--frames ${from}-${to} is empty.`);
|
|
65
62
|
}
|
|
66
63
|
const step = options.everyNthFrame ?? 1;
|
|
67
|
-
|
|
64
|
+
return { frames: Array.from({ length: Math.floor((to - from) / step) + 1 }, (_, i) => from + i * step), fps: meta.fps / step };
|
|
65
|
+
}
|
|
66
|
+
async function renderVideo(session, job, format) {
|
|
67
|
+
const { options } = session;
|
|
68
|
+
const startedAt = Date.now();
|
|
69
|
+
const first = await openTab(session, job.id);
|
|
70
|
+
const { meta } = first;
|
|
71
|
+
const { frames, fps } = planFrames(meta, options);
|
|
68
72
|
const [width, height] = [meta.width * options.scale, meta.height * options.scale];
|
|
69
73
|
if (format !== 'gif' && (width % 2 !== 0 || height % 2 !== 0)) {
|
|
70
74
|
throw new Error(`"${meta.id}" renders at ${width}×${height}; ${format.toUpperCase()} needs an even width and height.`);
|
|
71
75
|
}
|
|
72
76
|
const extra = Math.max(0, Math.min(options.concurrency, frames.length) - 1);
|
|
73
77
|
const tabs = [first, ...(await Promise.all(Array.from({ length: extra }, () => openTab(session, job.id))))];
|
|
74
|
-
const ffmpeg = spawn(session.ffmpeg, ffmpegArgs({ format, fps:
|
|
78
|
+
const ffmpeg = spawn(session.ffmpeg, ffmpegArgs({ format, fps, crf: options.crf, loop: options.loop, output: job.output }), {
|
|
75
79
|
stdio: ['pipe', 'inherit', 'inherit'],
|
|
76
80
|
});
|
|
77
81
|
const exited = once(ffmpeg, 'close');
|
|
@@ -102,6 +106,50 @@ async function renderVideo(session, job, format) {
|
|
|
102
106
|
}
|
|
103
107
|
return { id: job.id, output: job.output, format, frames: frames.length, seconds: (Date.now() - startedAt) / 1000 };
|
|
104
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Animated SVG: one tab draws the frames in order and, after each one, records the stage's DOM. At
|
|
111
|
+
* the end the page turns what changed between frames into CSS keyframes (see svg-recorder.ts).
|
|
112
|
+
* With `--frame` the file is a still of that frame.
|
|
113
|
+
*/
|
|
114
|
+
async function renderSvg(session, job) {
|
|
115
|
+
const { options } = session;
|
|
116
|
+
const startedAt = Date.now();
|
|
117
|
+
const tab = await openTab(session, job.id);
|
|
118
|
+
const { page, errors, meta } = tab;
|
|
119
|
+
let frames;
|
|
120
|
+
let fps = meta.fps;
|
|
121
|
+
if (options.frame === undefined) {
|
|
122
|
+
({ frames, fps } = planFrames(meta, options));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
checkFrame(meta, options.frame, '--frame');
|
|
126
|
+
frames = [options.frame];
|
|
127
|
+
}
|
|
128
|
+
let recorded = 0;
|
|
129
|
+
const progress = setInterval(() => options.log.progress(`${job.id} · ${recorded}/${frames.length} frames`), 250);
|
|
130
|
+
try {
|
|
131
|
+
await page.evaluate(name => window[name].beginSvg(), RENDER_API_GLOBAL);
|
|
132
|
+
for (const frame of frames) {
|
|
133
|
+
await page.evaluate(([name, value]) => window[name].setFrame(value), [RENDER_API_GLOBAL, frame]);
|
|
134
|
+
if (errors.length > 0) {
|
|
135
|
+
throw errors[0];
|
|
136
|
+
}
|
|
137
|
+
await page.evaluate(name => window[name].recordSvgFrame(), RENDER_API_GLOBAL);
|
|
138
|
+
recorded++;
|
|
139
|
+
}
|
|
140
|
+
const { svg, warnings } = await page.evaluate(([name, svgOptions]) => window[name].finishSvg(svgOptions), [RENDER_API_GLOBAL, { fps, loop: options.loop ?? true }]);
|
|
141
|
+
if (errors.length > 0) {
|
|
142
|
+
throw errors[0];
|
|
143
|
+
}
|
|
144
|
+
warnings.forEach(warning => options.log.info(` ${job.id}: ${warning}`));
|
|
145
|
+
await writeFile(job.output, svg);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
clearInterval(progress);
|
|
149
|
+
}
|
|
150
|
+
await page.close();
|
|
151
|
+
return { id: job.id, output: job.output, format: 'svg', frames: frames.length, seconds: (Date.now() - startedAt) / 1000 };
|
|
152
|
+
}
|
|
105
153
|
/** Decides the output file and format of each composition to render. */
|
|
106
154
|
export function planJobs(options, available) {
|
|
107
155
|
const ids = options.compositions === 'all' ? available : options.compositions;
|
|
@@ -120,7 +168,7 @@ export function planJobs(options, available) {
|
|
|
120
168
|
const output = options.output ?? join(options.outDir, `${id}.${fallback}`);
|
|
121
169
|
const format = options.format ?? formatFromPath(output);
|
|
122
170
|
if (!format) {
|
|
123
|
-
throw new Error(`Can't tell the format of "${output}". Use a .mp4, .webm, .gif or .
|
|
171
|
+
throw new Error(`Can't tell the format of "${output}". Use a .mp4, .webm, .gif, .png or .svg file, or pass --format.`);
|
|
124
172
|
}
|
|
125
173
|
return { id, output, format };
|
|
126
174
|
});
|
|
@@ -162,7 +210,7 @@ export async function render(options) {
|
|
|
162
210
|
const available = await probe.evaluate(name => window[name].compositions.map(item => item.id), RENDER_API_GLOBAL);
|
|
163
211
|
await probe.close();
|
|
164
212
|
const jobs = planJobs(options, available);
|
|
165
|
-
if (jobs.some(job => job.format
|
|
213
|
+
if (jobs.some(job => isVideoFormat(job.format))) {
|
|
166
214
|
await assertFfmpeg(session.ffmpeg);
|
|
167
215
|
}
|
|
168
216
|
const results = [];
|
|
@@ -171,7 +219,9 @@ export async function render(options) {
|
|
|
171
219
|
await mkdir(dirname(output), { recursive: true });
|
|
172
220
|
const result = job.format === 'png'
|
|
173
221
|
? await renderStill(session, { ...job, output })
|
|
174
|
-
:
|
|
222
|
+
: job.format === 'svg'
|
|
223
|
+
? await renderSvg(session, { ...job, output })
|
|
224
|
+
: await renderVideo(session, { ...job, output }, job.format);
|
|
175
225
|
log.done(`${result.id} · ${result.frames} ${result.frames === 1 ? 'frame' : 'frames'} in ${result.seconds.toFixed(1)}s → ${job.output}`);
|
|
176
226
|
results.push(result);
|
|
177
227
|
}
|
|
@@ -5,12 +5,26 @@ export type CompositionMeta = {
|
|
|
5
5
|
readonly height: number;
|
|
6
6
|
readonly durationInFrames: number;
|
|
7
7
|
};
|
|
8
|
+
export type SvgExportOptions = {
|
|
9
|
+
/** Frames per second of the output (already divided by `--every-nth-frame`). */
|
|
10
|
+
readonly fps: number;
|
|
11
|
+
readonly loop: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type SvgExportResult = {
|
|
14
|
+
readonly svg: string;
|
|
15
|
+
/** Things the SVG can't carry (a resource that couldn't be embedded, a stylesheet that couldn't be read). */
|
|
16
|
+
readonly warnings: readonly string[];
|
|
17
|
+
};
|
|
8
18
|
export type RenderPageApi = {
|
|
9
19
|
readonly compositions: readonly CompositionMeta[];
|
|
10
20
|
/** Selects the composition (props merged over its defaults) for the next `setFrame()` calls. */
|
|
11
21
|
readonly mount: (id: string, props: Record<string, unknown> | null) => CompositionMeta;
|
|
12
22
|
/** Draws the frame and resolves once fonts, images, render holds and the paint are done. */
|
|
13
23
|
readonly setFrame: (frame: number) => Promise<void>;
|
|
24
|
+
/** Animated SVG: `beginSvg()` once, `recordSvgFrame()` after each `setFrame()`, then `finishSvg()` builds the file. */
|
|
25
|
+
readonly beginSvg: () => void;
|
|
26
|
+
readonly recordSvgFrame: () => void;
|
|
27
|
+
readonly finishSvg: (options: SvgExportOptions) => Promise<SvgExportResult>;
|
|
14
28
|
};
|
|
15
29
|
export declare const RENDER_API_GLOBAL = "__freemotion";
|
|
16
30
|
declare global {
|
|
@@ -7,6 +7,7 @@ import { validateCompositions } from '../runtime/composition.js';
|
|
|
7
7
|
import { waitForRenderHolds } from '../runtime/delay-render.js';
|
|
8
8
|
import { Stage } from '../runtime/stage.js';
|
|
9
9
|
import { RENDER_API_GLOBAL } from '../shared/render-api.js';
|
|
10
|
+
import { SvgRecorder } from './svg-recorder.js';
|
|
10
11
|
const list = validateCompositions(compositions);
|
|
11
12
|
// React 19 reports render errors instead of throwing them out of flushSync(); keep the last one so
|
|
12
13
|
// setFrame() can fail with it instead of capturing an empty frame.
|
|
@@ -54,9 +55,29 @@ async function setFrame(frame) {
|
|
|
54
55
|
await nextPaint();
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
let recorder = null;
|
|
59
|
+
function beginSvg() {
|
|
60
|
+
recorder = new SvgRecorder();
|
|
61
|
+
}
|
|
62
|
+
function recordSvgFrame() {
|
|
63
|
+
const stage = document.querySelector('[data-freemotion-stage]');
|
|
64
|
+
if (!recorder || !stage) {
|
|
65
|
+
throw new Error('recordSvgFrame() must be called after beginSvg() and setFrame().');
|
|
66
|
+
}
|
|
67
|
+
recorder.record(stage);
|
|
68
|
+
}
|
|
69
|
+
function finishSvg(options) {
|
|
70
|
+
if (!recorder || !mounted) {
|
|
71
|
+
throw new Error('finishSvg() must be called after beginSvg().');
|
|
72
|
+
}
|
|
73
|
+
const { width, height } = mounted.composition;
|
|
74
|
+
const result = recorder.finish({ ...options, width, height });
|
|
75
|
+
recorder = null;
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
57
78
|
async function boot() {
|
|
58
79
|
// With unicode-range, a face is only fetched on first use. Load all of them up front.
|
|
59
80
|
await Promise.all([...document.fonts].map(face => face.load().catch(() => undefined)));
|
|
60
|
-
window[RENDER_API_GLOBAL] = { compositions: list.map(meta), mount, setFrame };
|
|
81
|
+
window[RENDER_API_GLOBAL] = { compositions: list.map(meta), mount, setFrame, beginSvg, recordSvgFrame, finishSvg };
|
|
61
82
|
}
|
|
62
83
|
void boot();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export declare const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
|
2
|
+
export declare const SVG_NS = "http://www.w3.org/2000/svg";
|
|
3
|
+
export type Sample<T> = {
|
|
4
|
+
readonly at: number;
|
|
5
|
+
readonly value: T;
|
|
6
|
+
};
|
|
7
|
+
/** Run-length samples over frame indices: each entry holds from its index until the next entry. */
|
|
8
|
+
export type Track<T> = Sample<T>[];
|
|
9
|
+
/** Appends a sample unless the value is the one already in force. */
|
|
10
|
+
export declare function pushSample<T>(track: Track<T>, at: number, value: T): void;
|
|
11
|
+
/** The value in force at a frame index; `undefined` before the first sample. */
|
|
12
|
+
export declare function sampleAt<T>(track: readonly Sample<T>[], at: number): T | undefined;
|
|
13
|
+
export type ElementRecord = {
|
|
14
|
+
readonly kind: 'element';
|
|
15
|
+
readonly id: number;
|
|
16
|
+
readonly parent: number | null;
|
|
17
|
+
readonly namespace: string;
|
|
18
|
+
readonly tag: string;
|
|
19
|
+
/** Child ids in document order, merged over every frame the element was seen in. */
|
|
20
|
+
readonly children: number[];
|
|
21
|
+
readonly presence: Track<boolean>;
|
|
22
|
+
/** Attributes other than `style`; `null` once an attribute is removed. */
|
|
23
|
+
readonly attributes: Map<string, Track<string | null>>;
|
|
24
|
+
/** Longhand declarations of the inline style; `null` once a declaration is removed. */
|
|
25
|
+
readonly style: Map<string, Track<string | null>>;
|
|
26
|
+
/** The inline style as written (`cssText`), for the static part of the output. */
|
|
27
|
+
readonly inlineStyle: Track<string>;
|
|
28
|
+
/** Computed `display`, sampled while present. */
|
|
29
|
+
readonly display: Track<string>;
|
|
30
|
+
};
|
|
31
|
+
export type TextRecord = {
|
|
32
|
+
readonly kind: 'text';
|
|
33
|
+
readonly id: number;
|
|
34
|
+
readonly parent: number | null;
|
|
35
|
+
readonly presence: Track<boolean>;
|
|
36
|
+
readonly data: Track<string>;
|
|
37
|
+
};
|
|
38
|
+
export type NodeRecord = ElementRecord | TextRecord;
|
|
39
|
+
export type SvgDocumentOptions = {
|
|
40
|
+
readonly records: ReadonlyMap<number, NodeRecord>;
|
|
41
|
+
/** Id of the stage element. */
|
|
42
|
+
readonly root: number;
|
|
43
|
+
/** How many frames were recorded. */
|
|
44
|
+
readonly frames: number;
|
|
45
|
+
/** Frames per second of the output (already divided by `--every-nth-frame`). */
|
|
46
|
+
readonly fps: number;
|
|
47
|
+
readonly loop: boolean;
|
|
48
|
+
readonly width: number;
|
|
49
|
+
readonly height: number;
|
|
50
|
+
/** Stylesheet text to embed, with its resources already inlined. */
|
|
51
|
+
readonly css: string;
|
|
52
|
+
};
|
|
53
|
+
/** `rotate(45 10 10) translate(5)` (SVG attribute syntax) → the same transform in CSS syntax. */
|
|
54
|
+
export declare function svgTransformToCss(value: string): string | null;
|
|
55
|
+
/** The CSS declaration that reproduces an SVG attribute, or `null` when CSS can't express it. */
|
|
56
|
+
export declare function attributeAsCss(namespace: string, tag: string, name: string, value: string): {
|
|
57
|
+
property: string;
|
|
58
|
+
value: string;
|
|
59
|
+
} | null;
|
|
60
|
+
export declare function escapeText(value: string): string;
|
|
61
|
+
export declare function escapeAttribute(value: string): string;
|
|
62
|
+
/** Percentage of the timeline where frame index `at` starts. */
|
|
63
|
+
export declare function keyframeOffset(at: number, frames: number): string;
|
|
64
|
+
/**
|
|
65
|
+
* Chromium only starts the animations of an element once every ancestor is displayed, so whatever
|
|
66
|
+
* sits inside an element that appears at frame k would run k frames late. Every emitted element
|
|
67
|
+
* therefore covers one contiguous run of frames, and everything inside it starts with a negative
|
|
68
|
+
* delay of the run's first frame, which puts it back in step with the rest of the file.
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildSvgDocument(options: SvgDocumentOptions): string;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Turns the frames a composition drew into one animated SVG. The stage's DOM goes inside a
|
|
2
|
+
// <foreignObject>; every value that changed between frames (inline styles, SVG attributes, which
|
|
3
|
+
// elements exist, what a text node says) is replayed by CSS @keyframes that step at the frame rate,
|
|
4
|
+
// so the file shows the same picture at the same instant as the video. No DOM APIs here: the
|
|
5
|
+
// recorder (svg-recorder.ts) feeds samples in, this module only builds strings.
|
|
6
|
+
export const XHTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
7
|
+
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
8
|
+
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
9
|
+
/** Appends a sample unless the value is the one already in force. */
|
|
10
|
+
export function pushSample(track, at, value) {
|
|
11
|
+
const last = track[track.length - 1];
|
|
12
|
+
if (!last || last.value !== value) {
|
|
13
|
+
track.push({ at, value });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The value in force at a frame index; `undefined` before the first sample. */
|
|
17
|
+
export function sampleAt(track, at) {
|
|
18
|
+
let result;
|
|
19
|
+
for (const sample of track) {
|
|
20
|
+
if (sample.at > at) {
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
result = sample.value;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
// --- SVG attributes that CSS can animate ---------------------------------------------------------
|
|
28
|
+
const PRESENTATION_ATTRIBUTES = new Set([
|
|
29
|
+
'opacity', 'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-opacity', 'stroke-width',
|
|
30
|
+
'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
|
|
31
|
+
'color', 'visibility',
|
|
32
|
+
]);
|
|
33
|
+
const GEOMETRY_ATTRIBUTES = new Set(['x', 'y', 'cx', 'cy', 'r', 'rx', 'ry', 'width', 'height']);
|
|
34
|
+
const GEOMETRY_ELEMENTS = new Set(['rect', 'circle', 'ellipse', 'image', 'svg', 'foreignObject', 'use']);
|
|
35
|
+
const NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i;
|
|
36
|
+
/** `rotate(45 10 10) translate(5)` (SVG attribute syntax) → the same transform in CSS syntax. */
|
|
37
|
+
export function svgTransformToCss(value) {
|
|
38
|
+
const functions = [];
|
|
39
|
+
const pattern = /([a-zA-Z]+)\s*\(([^)]*)\)/g;
|
|
40
|
+
const leftover = value.replace(pattern, (_, name, rawArgs) => {
|
|
41
|
+
const args = rawArgs.trim().split(/[\s,]+/).filter(Boolean).map(Number);
|
|
42
|
+
if (args.some(Number.isNaN)) {
|
|
43
|
+
functions.push('\0');
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
const [a = 0, b, c] = args;
|
|
47
|
+
switch (name) {
|
|
48
|
+
case 'translate':
|
|
49
|
+
functions.push(`translate(${a}px, ${b ?? 0}px)`);
|
|
50
|
+
break;
|
|
51
|
+
case 'scale':
|
|
52
|
+
functions.push(`scale(${a}, ${b ?? a})`);
|
|
53
|
+
break;
|
|
54
|
+
case 'rotate':
|
|
55
|
+
functions.push(b === undefined
|
|
56
|
+
? `rotate(${a}deg)`
|
|
57
|
+
: `translate(${b}px, ${c ?? 0}px) rotate(${a}deg) translate(${-b}px, ${-(c ?? 0)}px)`);
|
|
58
|
+
break;
|
|
59
|
+
case 'skewX':
|
|
60
|
+
case 'skewY':
|
|
61
|
+
functions.push(`${name}(${a}deg)`);
|
|
62
|
+
break;
|
|
63
|
+
case 'matrix':
|
|
64
|
+
functions.push(args.length === 6 ? `matrix(${args.join(', ')})` : '\0');
|
|
65
|
+
break;
|
|
66
|
+
default:
|
|
67
|
+
functions.push('\0');
|
|
68
|
+
}
|
|
69
|
+
return '';
|
|
70
|
+
});
|
|
71
|
+
if (leftover.trim() !== '' || functions.includes('\0')) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return functions.length === 0 ? 'none' : functions.join(' ');
|
|
75
|
+
}
|
|
76
|
+
/** The CSS declaration that reproduces an SVG attribute, or `null` when CSS can't express it. */
|
|
77
|
+
export function attributeAsCss(namespace, tag, name, value) {
|
|
78
|
+
if (namespace !== SVG_NS) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (name === 'transform') {
|
|
82
|
+
const converted = svgTransformToCss(value);
|
|
83
|
+
return converted === null ? null : { property: 'transform', value: converted };
|
|
84
|
+
}
|
|
85
|
+
if (PRESENTATION_ATTRIBUTES.has(name)) {
|
|
86
|
+
return { property: name, value };
|
|
87
|
+
}
|
|
88
|
+
if (GEOMETRY_ATTRIBUTES.has(name) && GEOMETRY_ELEMENTS.has(tag)) {
|
|
89
|
+
const trimmed = value.trim();
|
|
90
|
+
return { property: name, value: NUMBER.test(trimmed) && Number(trimmed) !== 0 ? `${trimmed}px` : trimmed };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
// --- Serialization helpers -----------------------------------------------------------------------
|
|
95
|
+
// eslint-disable-next-line no-control-regex
|
|
96
|
+
const INVALID_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g;
|
|
97
|
+
const XML_NAME = /^[A-Za-z_:][\w.:-]*$/;
|
|
98
|
+
const VOID_HTML = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
99
|
+
export function escapeText(value) {
|
|
100
|
+
return value.replace(INVALID_XML, '').replace(/[&<>]/g, char => (char === '&' ? '&' : char === '<' ? '<' : '>'));
|
|
101
|
+
}
|
|
102
|
+
export function escapeAttribute(value) {
|
|
103
|
+
return escapeText(value).replace(/"/g, '"');
|
|
104
|
+
}
|
|
105
|
+
/** Percentage of the timeline where frame index `at` starts. */
|
|
106
|
+
export function keyframeOffset(at, frames) {
|
|
107
|
+
return `${Number(((at / frames) * 100).toFixed(6))}%`;
|
|
108
|
+
}
|
|
109
|
+
const isVarying = (track) => track.length > 1;
|
|
110
|
+
/** Value of a track at an index, falling back to its first value before the track starts. */
|
|
111
|
+
function trackValue(track, at) {
|
|
112
|
+
const first = track[0];
|
|
113
|
+
return first && at < first.at ? first.value : sampleAt(track, at);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Chromium only starts the animations of an element once every ancestor is displayed, so whatever
|
|
117
|
+
* sits inside an element that appears at frame k would run k frames late. Every emitted element
|
|
118
|
+
* therefore covers one contiguous run of frames, and everything inside it starts with a negative
|
|
119
|
+
* delay of the run's first frame, which puts it back in step with the rest of the file.
|
|
120
|
+
*/
|
|
121
|
+
export function buildSvgDocument(options) {
|
|
122
|
+
const { records, frames, fps, loop, width, height } = options;
|
|
123
|
+
const keyframes = [];
|
|
124
|
+
const seconds = (count) => `${Number((count / fps).toFixed(6))}s`;
|
|
125
|
+
const animation = (name, revealAt) => `animation:${name} ${seconds(frames)} step-end${revealAt > 0 ? ` -${seconds(revealAt)}` : ''} ${loop ? 'infinite' : '1 forwards'}`;
|
|
126
|
+
const presentAt = (record, at) => sampleAt(record.presence, at) === true;
|
|
127
|
+
/** Present and not hidden by its own `display: none`. */
|
|
128
|
+
const renderedAt = (record, at) => presentAt(record, at) && trackValue(record.display, at) !== 'none';
|
|
129
|
+
/** `@keyframes` holding every animated property at every index where any of them changes. */
|
|
130
|
+
const defineKeyframes = (name, animated) => {
|
|
131
|
+
const indices = new Set([0]);
|
|
132
|
+
for (const track of animated.values()) {
|
|
133
|
+
for (const sample of track) {
|
|
134
|
+
if (sample.at < frames) {
|
|
135
|
+
indices.add(sample.at);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const declarations = (at) => [...animated]
|
|
140
|
+
.map(([property, track]) => `${property}:${trackValue(track, at) ?? 'revert-layer'}`)
|
|
141
|
+
.join(';');
|
|
142
|
+
// A keyframe that repeats the previous one changes nothing: skip it.
|
|
143
|
+
const body = [];
|
|
144
|
+
let previous = null;
|
|
145
|
+
for (const at of [...indices].sort((a, b) => a - b)) {
|
|
146
|
+
const current = declarations(at);
|
|
147
|
+
if (current !== previous) {
|
|
148
|
+
body.push(`${keyframeOffset(at, frames)}{${current}}`);
|
|
149
|
+
previous = current;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
body.push(`100%{${declarations(frames - 1)}}`);
|
|
153
|
+
keyframes.push(`@keyframes ${name}{${body.join('')}}`);
|
|
154
|
+
};
|
|
155
|
+
/** A `display` track that hides the node outside `range`; `null` when the enclosing element already does. */
|
|
156
|
+
const displayTrack = (range, parentRange, shown) => {
|
|
157
|
+
if (range.from === parentRange.from && range.to === parentRange.to) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const track = [];
|
|
161
|
+
for (let at = 0; at < frames; at++) {
|
|
162
|
+
pushSample(track, at, at >= range.from && at <= range.to ? shown(at) : 'none');
|
|
163
|
+
}
|
|
164
|
+
return track;
|
|
165
|
+
};
|
|
166
|
+
/** Splits the frames where `visible` holds into runs where `signature` doesn't change. */
|
|
167
|
+
const runsOf = (visible, signature) => {
|
|
168
|
+
const runs = [];
|
|
169
|
+
let current = null;
|
|
170
|
+
for (let at = 0; at < frames; at++) {
|
|
171
|
+
if (!visible(at)) {
|
|
172
|
+
current = null;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const key = signature(at);
|
|
176
|
+
if (current && current.key === key) {
|
|
177
|
+
current.to = at;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
current = { from: at, to: at, key };
|
|
181
|
+
runs.push(current);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return runs;
|
|
185
|
+
};
|
|
186
|
+
const emitNode = (id, parentNamespace, parentRange) => {
|
|
187
|
+
const record = records.get(id);
|
|
188
|
+
if (!record) {
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
191
|
+
return record.kind === 'text' ? emitText(record, parentNamespace, parentRange) : emitElement(record, parentNamespace, parentRange);
|
|
192
|
+
};
|
|
193
|
+
const emitText = (record, parentNamespace, parentRange) => {
|
|
194
|
+
const runs = runsOf(at => presentAt(record, at), at => trackValue(record.data, at) ?? '');
|
|
195
|
+
if (runs.length === 1 && runs[0].from === parentRange.from && runs[0].to === parentRange.to) {
|
|
196
|
+
return escapeText(trackValue(record.data, parentRange.from) ?? '');
|
|
197
|
+
}
|
|
198
|
+
// The text changes or comes and goes: one wrapper per run, shown only during it.
|
|
199
|
+
const svgParent = parentNamespace === SVG_NS;
|
|
200
|
+
const tag = svgParent ? 'tspan' : 'span';
|
|
201
|
+
return runs.map((run) => {
|
|
202
|
+
const name = `fm${record.id}-${run.from}`;
|
|
203
|
+
defineKeyframes(name, new Map([['display', displayTrack(run, parentRange, () => (svgParent ? 'inline' : 'contents'))]]));
|
|
204
|
+
const text = escapeText(trackValue(record.data, run.from) ?? '');
|
|
205
|
+
return `<${tag} style="${escapeAttribute(animation(name, parentRange.from))}">${text}</${tag}>`;
|
|
206
|
+
}).join('');
|
|
207
|
+
};
|
|
208
|
+
const emitElement = (record, parentNamespace, parentRange) => {
|
|
209
|
+
const { namespace, tag } = record;
|
|
210
|
+
if (tag === 'script') {
|
|
211
|
+
return '';
|
|
212
|
+
}
|
|
213
|
+
// One copy per run of frames where the element is rendered and the attributes CSS can't
|
|
214
|
+
// animate keep their values.
|
|
215
|
+
const variants = [...record.attributes].filter(([name, track]) => isVarying(track) && attributeAsCss(namespace, tag, name, trackValue(track, 0) ?? '') === null);
|
|
216
|
+
const runs = runsOf(at => renderedAt(record, at), at => variants.map(([, track]) => trackValue(track, at) ?? '\0').join('\u0001'));
|
|
217
|
+
return runs.map(run => emitCopy(record, parentNamespace, parentRange, run, runs.length === 1 ? `fm${record.id}` : `fm${record.id}-${run.from}`)).join('');
|
|
218
|
+
};
|
|
219
|
+
const emitCopy = (record, parentNamespace, parentRange, range, name) => {
|
|
220
|
+
const { namespace, tag } = record;
|
|
221
|
+
const animated = new Map();
|
|
222
|
+
const attributes = [];
|
|
223
|
+
const declarations = [];
|
|
224
|
+
for (const [attribute, track] of record.attributes) {
|
|
225
|
+
const value = trackValue(track, range.from);
|
|
226
|
+
if (value !== null && value !== undefined && XML_NAME.test(attribute) && !/^on/i.test(attribute)) {
|
|
227
|
+
attributes.push(` ${attribute}="${escapeAttribute(value)}"`);
|
|
228
|
+
}
|
|
229
|
+
if (isVarying(track)) {
|
|
230
|
+
const css = attributeAsCss(namespace, tag, attribute, value ?? '');
|
|
231
|
+
if (css) {
|
|
232
|
+
animated.set(css.property, track.map(sample => ({
|
|
233
|
+
at: sample.at,
|
|
234
|
+
value: sample.value === null ? null : (attributeAsCss(namespace, tag, attribute, sample.value)?.value ?? null),
|
|
235
|
+
})));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const inlineStyle = trackValue(record.inlineStyle, range.from)?.trim().replace(/;$/, '');
|
|
240
|
+
if (inlineStyle) {
|
|
241
|
+
declarations.push(inlineStyle);
|
|
242
|
+
}
|
|
243
|
+
for (const [property, track] of record.style) {
|
|
244
|
+
if (isVarying(track)) {
|
|
245
|
+
animated.set(property, track);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const display = displayTrack(range, parentRange, at => trackValue(record.display, at) ?? 'block');
|
|
249
|
+
if (display) {
|
|
250
|
+
animated.set('display', display);
|
|
251
|
+
}
|
|
252
|
+
if (animated.size > 0) {
|
|
253
|
+
defineKeyframes(name, animated);
|
|
254
|
+
declarations.push(animation(name, parentRange.from));
|
|
255
|
+
}
|
|
256
|
+
if (declarations.length > 0) {
|
|
257
|
+
attributes.push(` style="${escapeAttribute(declarations.join(';'))}"`);
|
|
258
|
+
}
|
|
259
|
+
const xmlns = namespace === parentNamespace ? '' : ` xmlns="${escapeAttribute(namespace)}"`;
|
|
260
|
+
const children = record.children.map(child => emitNode(child, namespace, range)).join('');
|
|
261
|
+
const open = `<${tag}${xmlns}${attributes.join('')}`;
|
|
262
|
+
if (children === '' && (namespace !== XHTML_NS || VOID_HTML.has(tag))) {
|
|
263
|
+
return `${open}/>`;
|
|
264
|
+
}
|
|
265
|
+
return `${open}>${children}</${tag}>`;
|
|
266
|
+
};
|
|
267
|
+
const content = emitNode(options.root, SVG_NS, { from: 0, to: frames - 1 });
|
|
268
|
+
const style = `${options.css}\n${keyframes.join('\n')}`;
|
|
269
|
+
return [
|
|
270
|
+
`<svg xmlns="${SVG_NS}" xmlns:xlink="${XLINK_NS}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
|
|
271
|
+
`<style>${escapeText(style)}</style>`,
|
|
272
|
+
`<foreignObject x="0" y="0" width="${width}" height="${height}">${content}</foreignObject>`,
|
|
273
|
+
'</svg>',
|
|
274
|
+
'',
|
|
275
|
+
].join('\n');
|
|
276
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type SvgRecorderFinish = {
|
|
2
|
+
readonly fps: number;
|
|
3
|
+
readonly loop: boolean;
|
|
4
|
+
readonly width: number;
|
|
5
|
+
readonly height: number;
|
|
6
|
+
};
|
|
7
|
+
/** `U+0000-00FF, U+0131, U+02??` → `[[0, 255], [305, 305], [512, 767]]`. */
|
|
8
|
+
export declare function parseUnicodeRange(value: string): [number, number][];
|
|
9
|
+
export declare class SvgRecorder {
|
|
10
|
+
private readonly records;
|
|
11
|
+
private readonly ids;
|
|
12
|
+
private readonly families;
|
|
13
|
+
private readonly codePoints;
|
|
14
|
+
private readonly warnings;
|
|
15
|
+
private readonly resources;
|
|
16
|
+
private present;
|
|
17
|
+
private nextId;
|
|
18
|
+
private root;
|
|
19
|
+
private frames;
|
|
20
|
+
/** Samples the stage as it is right now, as the next frame of the output. */
|
|
21
|
+
record(stage: Element): void;
|
|
22
|
+
finish({ fps, loop, width, height }: SvgRecorderFinish): Promise<{
|
|
23
|
+
svg: string;
|
|
24
|
+
warnings: string[];
|
|
25
|
+
}>;
|
|
26
|
+
private visitElement;
|
|
27
|
+
private visitText;
|
|
28
|
+
private elementRecord;
|
|
29
|
+
/** Keeps `child` right after `previous` in the merged child order of `parent`. */
|
|
30
|
+
private place;
|
|
31
|
+
private track;
|
|
32
|
+
/** The resource as a data: URL, fetched once; `null` (and a warning) when it can't be read. */
|
|
33
|
+
private resource;
|
|
34
|
+
/** Replaces every `url()` of a CSS text with a data: URL. */
|
|
35
|
+
private inlineCssUrls;
|
|
36
|
+
private inlineTrackResources;
|
|
37
|
+
private fontFaceIsUsed;
|
|
38
|
+
private collectSheet;
|
|
39
|
+
private collectCss;
|
|
40
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Runs in the render page. After each frame is drawn it samples the stage's DOM (which nodes exist,
|
|
2
|
+
// their attributes and inline styles, the text) and, at the end, embeds the stylesheets and the
|
|
3
|
+
// resources they use and hands everything to svg-export.ts to build the animated SVG.
|
|
4
|
+
import { buildSvgDocument, pushSample, XHTML_NS, } from './svg-export.js';
|
|
5
|
+
const URL_IN_CSS = /url\(\s*(["']?)([^"')]+)\1\s*\)/g;
|
|
6
|
+
const RESOURCE_ATTRIBUTES = new Set(['src', 'href', 'xlink:href', 'poster']);
|
|
7
|
+
const normalizeFamily = (name) => name.trim().replace(/^["']|["']$/g, '').trim().toLowerCase();
|
|
8
|
+
/** `U+0000-00FF, U+0131, U+02??` → `[[0, 255], [305, 305], [512, 767]]`. */
|
|
9
|
+
export function parseUnicodeRange(value) {
|
|
10
|
+
const ranges = [];
|
|
11
|
+
for (const part of value.split(',')) {
|
|
12
|
+
const match = /^\s*U\+([0-9A-F?]+)(?:-([0-9A-F]+))?\s*$/i.exec(part);
|
|
13
|
+
if (!match) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const [, start, end] = match;
|
|
17
|
+
if (start.includes('?')) {
|
|
18
|
+
ranges.push([parseInt(start.replace(/\?/g, '0'), 16), parseInt(start.replace(/\?/g, 'F'), 16)]);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
ranges.push([parseInt(start, 16), parseInt(end ?? start, 16)]);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return ranges;
|
|
25
|
+
}
|
|
26
|
+
export class SvgRecorder {
|
|
27
|
+
records = new Map();
|
|
28
|
+
ids = new WeakMap();
|
|
29
|
+
families = new Set();
|
|
30
|
+
codePoints = new Set();
|
|
31
|
+
warnings = [];
|
|
32
|
+
resources = new Map();
|
|
33
|
+
present = new Set();
|
|
34
|
+
nextId = 1;
|
|
35
|
+
root = null;
|
|
36
|
+
frames = 0;
|
|
37
|
+
/** Samples the stage as it is right now, as the next frame of the output. */
|
|
38
|
+
record(stage) {
|
|
39
|
+
const at = this.frames++;
|
|
40
|
+
const now = new Set();
|
|
41
|
+
this.root = this.visitElement(stage, null, at, now).id;
|
|
42
|
+
for (const id of this.present) {
|
|
43
|
+
if (!now.has(id)) {
|
|
44
|
+
pushSample(this.records.get(id).presence, at, false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.present = now;
|
|
48
|
+
}
|
|
49
|
+
async finish({ fps, loop, width, height }) {
|
|
50
|
+
if (this.root === null) {
|
|
51
|
+
throw new Error('No frame was recorded.');
|
|
52
|
+
}
|
|
53
|
+
const css = await this.collectCss();
|
|
54
|
+
await this.inlineTrackResources();
|
|
55
|
+
const svg = buildSvgDocument({ records: this.records, root: this.root, frames: this.frames, fps, loop, width, height, css });
|
|
56
|
+
return { svg, warnings: this.warnings };
|
|
57
|
+
}
|
|
58
|
+
// --- Sampling ----------------------------------------------------------------------------------
|
|
59
|
+
visitElement(element, parent, at, now) {
|
|
60
|
+
const record = this.elementRecord(element, parent);
|
|
61
|
+
now.add(record.id);
|
|
62
|
+
pushSample(record.presence, at, true);
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const { name, value } of element.attributes) {
|
|
65
|
+
if (name === 'style') {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
seen.add(name);
|
|
69
|
+
pushSample(this.track(record.attributes, name), at, value);
|
|
70
|
+
}
|
|
71
|
+
if (element instanceof HTMLCanvasElement) {
|
|
72
|
+
// A canvas has no markup; what it drew travels as an image.
|
|
73
|
+
seen.add('src');
|
|
74
|
+
pushSample(this.track(record.attributes, 'src'), at, element.toDataURL());
|
|
75
|
+
}
|
|
76
|
+
for (const [name, track] of record.attributes) {
|
|
77
|
+
if (!seen.has(name)) {
|
|
78
|
+
pushSample(track, at, null);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const style = element.style;
|
|
82
|
+
const declared = new Set();
|
|
83
|
+
pushSample(record.inlineStyle, at, style?.cssText ?? '');
|
|
84
|
+
if (style) {
|
|
85
|
+
for (let i = 0; i < style.length; i++) {
|
|
86
|
+
const property = style.item(i);
|
|
87
|
+
declared.add(property);
|
|
88
|
+
pushSample(this.track(record.style, property), at, style.getPropertyValue(property));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const [property, track] of record.style) {
|
|
92
|
+
if (!declared.has(property)) {
|
|
93
|
+
pushSample(track, at, null);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const computed = getComputedStyle(element);
|
|
97
|
+
pushSample(record.display, at, computed.display);
|
|
98
|
+
let previous = null;
|
|
99
|
+
let hasText = false;
|
|
100
|
+
for (const child of element.childNodes) {
|
|
101
|
+
let childRecord = null;
|
|
102
|
+
if (child instanceof Element) {
|
|
103
|
+
childRecord = this.visitElement(child, record, at, now);
|
|
104
|
+
}
|
|
105
|
+
else if (child.nodeType === Node.TEXT_NODE) {
|
|
106
|
+
childRecord = this.visitText(child, record, at, now);
|
|
107
|
+
hasText ||= child.data.trim() !== '';
|
|
108
|
+
}
|
|
109
|
+
if (childRecord) {
|
|
110
|
+
this.place(record, childRecord.id, previous);
|
|
111
|
+
previous = childRecord.id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (hasText) {
|
|
115
|
+
computed.fontFamily.split(',').forEach(family => this.families.add(normalizeFamily(family)));
|
|
116
|
+
}
|
|
117
|
+
return record;
|
|
118
|
+
}
|
|
119
|
+
visitText(text, parent, at, now) {
|
|
120
|
+
let id = this.ids.get(text);
|
|
121
|
+
let record = id === undefined ? undefined : this.records.get(id);
|
|
122
|
+
if (!record || record.parent !== parent.id) {
|
|
123
|
+
id = this.nextId++;
|
|
124
|
+
record = { kind: 'text', id, parent: parent.id, presence: [], data: [] };
|
|
125
|
+
this.ids.set(text, id);
|
|
126
|
+
this.records.set(id, record);
|
|
127
|
+
}
|
|
128
|
+
now.add(record.id);
|
|
129
|
+
pushSample(record.presence, at, true);
|
|
130
|
+
pushSample(record.data, at, text.data);
|
|
131
|
+
for (const char of text.data) {
|
|
132
|
+
this.codePoints.add(char.codePointAt(0));
|
|
133
|
+
}
|
|
134
|
+
return record;
|
|
135
|
+
}
|
|
136
|
+
elementRecord(element, parent) {
|
|
137
|
+
const id = this.ids.get(element);
|
|
138
|
+
const existing = id === undefined ? undefined : this.records.get(id);
|
|
139
|
+
// A node that moved to another parent starts a new life; the old record simply ends.
|
|
140
|
+
if (existing && existing.parent === (parent?.id ?? null)) {
|
|
141
|
+
return existing;
|
|
142
|
+
}
|
|
143
|
+
const canvas = element instanceof HTMLCanvasElement;
|
|
144
|
+
const record = {
|
|
145
|
+
kind: 'element',
|
|
146
|
+
id: this.nextId++,
|
|
147
|
+
parent: parent?.id ?? null,
|
|
148
|
+
namespace: element.namespaceURI ?? XHTML_NS,
|
|
149
|
+
tag: canvas ? 'img' : element.localName,
|
|
150
|
+
children: [],
|
|
151
|
+
presence: [],
|
|
152
|
+
attributes: new Map(),
|
|
153
|
+
style: new Map(),
|
|
154
|
+
inlineStyle: [],
|
|
155
|
+
display: [],
|
|
156
|
+
};
|
|
157
|
+
this.ids.set(element, record.id);
|
|
158
|
+
this.records.set(record.id, record);
|
|
159
|
+
return record;
|
|
160
|
+
}
|
|
161
|
+
/** Keeps `child` right after `previous` in the merged child order of `parent`. */
|
|
162
|
+
place(parent, child, previous) {
|
|
163
|
+
if (parent.children.includes(child)) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const index = previous === null ? -1 : parent.children.indexOf(previous);
|
|
167
|
+
parent.children.splice(index + 1, 0, child);
|
|
168
|
+
}
|
|
169
|
+
track(map, key) {
|
|
170
|
+
let track = map.get(key);
|
|
171
|
+
if (!track) {
|
|
172
|
+
track = [];
|
|
173
|
+
map.set(key, track);
|
|
174
|
+
}
|
|
175
|
+
return track;
|
|
176
|
+
}
|
|
177
|
+
// --- Resources ---------------------------------------------------------------------------------
|
|
178
|
+
/** The resource as a data: URL, fetched once; `null` (and a warning) when it can't be read. */
|
|
179
|
+
resource(url, base) {
|
|
180
|
+
let absolute;
|
|
181
|
+
try {
|
|
182
|
+
absolute = new URL(url, base).href;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return Promise.resolve(null);
|
|
186
|
+
}
|
|
187
|
+
if (/^(data|blob|about):/.test(absolute) || url.startsWith('#')) {
|
|
188
|
+
return Promise.resolve(null);
|
|
189
|
+
}
|
|
190
|
+
let pending = this.resources.get(absolute);
|
|
191
|
+
if (!pending) {
|
|
192
|
+
pending = fetch(absolute)
|
|
193
|
+
.then(async (response) => {
|
|
194
|
+
if (!response.ok) {
|
|
195
|
+
throw new Error(`HTTP ${response.status}`);
|
|
196
|
+
}
|
|
197
|
+
const blob = await response.blob();
|
|
198
|
+
return await new Promise((resolve, reject) => {
|
|
199
|
+
const reader = new FileReader();
|
|
200
|
+
reader.onload = () => resolve(reader.result);
|
|
201
|
+
reader.onerror = () => reject(reader.error);
|
|
202
|
+
reader.readAsDataURL(blob);
|
|
203
|
+
});
|
|
204
|
+
})
|
|
205
|
+
.catch((error) => {
|
|
206
|
+
this.warnings.push(`Could not embed ${absolute} (${error instanceof Error ? error.message : String(error)}); the SVG links to it instead.`);
|
|
207
|
+
return null;
|
|
208
|
+
});
|
|
209
|
+
this.resources.set(absolute, pending);
|
|
210
|
+
}
|
|
211
|
+
return pending;
|
|
212
|
+
}
|
|
213
|
+
/** Replaces every `url()` of a CSS text with a data: URL. */
|
|
214
|
+
async inlineCssUrls(css, base) {
|
|
215
|
+
const urls = [...css.matchAll(URL_IN_CSS)].map(match => match[2]);
|
|
216
|
+
const replacements = new Map(await Promise.all(urls.map(async (url) => [url, await this.resource(url, base)])));
|
|
217
|
+
return css.replace(URL_IN_CSS, (whole, _quote, url) => {
|
|
218
|
+
const data = replacements.get(url);
|
|
219
|
+
return data ? `url("${data}")` : whole;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async inlineTrackResources() {
|
|
223
|
+
const base = document.baseURI;
|
|
224
|
+
const jobs = [];
|
|
225
|
+
for (const record of this.records.values()) {
|
|
226
|
+
if (record.kind !== 'element') {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
for (const [name, track] of record.attributes) {
|
|
230
|
+
if (!RESOURCE_ATTRIBUTES.has(name)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
for (const sample of track) {
|
|
234
|
+
if (sample.value !== null) {
|
|
235
|
+
jobs.push(this.resource(sample.value, base).then((data) => {
|
|
236
|
+
if (data) {
|
|
237
|
+
sample.value = data;
|
|
238
|
+
}
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const track of [...record.style.values(), record.inlineStyle]) {
|
|
244
|
+
for (const sample of track) {
|
|
245
|
+
if (sample.value !== null && sample.value.includes('url(')) {
|
|
246
|
+
jobs.push(this.inlineCssUrls(sample.value, base).then((value) => {
|
|
247
|
+
sample.value = value;
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
await Promise.all(jobs);
|
|
254
|
+
}
|
|
255
|
+
// --- Stylesheets -------------------------------------------------------------------------------
|
|
256
|
+
fontFaceIsUsed(rule) {
|
|
257
|
+
const family = normalizeFamily(rule.style.getPropertyValue('font-family'));
|
|
258
|
+
if (!this.families.has(family)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
const range = rule.style.getPropertyValue('unicode-range');
|
|
262
|
+
if (!range) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
const ranges = parseUnicodeRange(range);
|
|
266
|
+
for (const codePoint of this.codePoints) {
|
|
267
|
+
if (ranges.some(([start, end]) => codePoint >= start && codePoint <= end)) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
async collectSheet(sheet, parts) {
|
|
274
|
+
const base = sheet.href ?? document.baseURI;
|
|
275
|
+
let rules;
|
|
276
|
+
try {
|
|
277
|
+
rules = sheet.cssRules;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Cross-origin: take the text as served.
|
|
281
|
+
const text = sheet.href ? await fetch(sheet.href).then(response => (response.ok ? response.text() : null)).catch(() => null) : null;
|
|
282
|
+
if (text === null) {
|
|
283
|
+
this.warnings.push(`Could not read the stylesheet ${sheet.href}; its rules are missing from the SVG.`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
parts.push(await this.inlineCssUrls(text, base));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
for (const rule of rules) {
|
|
290
|
+
if (rule instanceof CSSImportRule) {
|
|
291
|
+
if (rule.styleSheet) {
|
|
292
|
+
await this.collectSheet(rule.styleSheet, parts);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (rule instanceof CSSFontFaceRule) {
|
|
296
|
+
if (this.fontFaceIsUsed(rule)) {
|
|
297
|
+
parts.push(await this.inlineCssUrls(rule.cssText, base));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
parts.push(await this.inlineCssUrls(rule.cssText, base));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async collectCss() {
|
|
306
|
+
const parts = [];
|
|
307
|
+
for (const sheet of [...document.styleSheets, ...document.adoptedStyleSheets]) {
|
|
308
|
+
await this.collectSheet(sheet, parts);
|
|
309
|
+
}
|
|
310
|
+
return parts.join('\n');
|
|
311
|
+
}
|
|
312
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freemotion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Make videos with React. Frame-driven animations, a live studio and an MP4 renderer, all MIT.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"motion",
|
|
10
10
|
"mp4",
|
|
11
11
|
"gif",
|
|
12
|
+
"svg",
|
|
12
13
|
"ffmpeg",
|
|
13
14
|
"vite",
|
|
14
15
|
"programmatic-video",
|