@pixerate/flair 0.24.0 → 0.25.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/CHANGELOG.md +12 -0
- package/dist/catalog/index.js +2 -2
- package/dist/chunk-DBTLCXKY.js +332 -0
- package/dist/chunk-DBTLCXKY.js.map +1 -0
- package/dist/{chunk-VP6IEWIK.js → chunk-H6L67KHI.js} +1 -1
- package/dist/chunk-H6VDFBIO.js +498 -0
- package/dist/chunk-H6VDFBIO.js.map +1 -0
- package/dist/chunk-JL6TI6II.js +724 -0
- package/dist/chunk-JL6TI6II.js.map +1 -0
- package/dist/{chunk-NGKNI3QX.js → chunk-O44ZJTGG.js} +5 -485
- package/dist/chunk-O44ZJTGG.js.map +1 -0
- package/dist/chunk-OKYMKSXK.js +414 -0
- package/dist/chunk-OKYMKSXK.js.map +1 -0
- package/dist/{chunk-OHW7UG6D.js → chunk-ORAFCHX3.js} +26 -2
- package/dist/{chunk-OHW7UG6D.js.map → chunk-ORAFCHX3.js.map} +1 -1
- package/dist/{chunk-OQ4EWZ3B.js → chunk-PYDN5G2P.js} +5 -5
- package/dist/{chunk-ARKC7LDW.js → chunk-RDU63CDO.js} +2 -331
- package/dist/chunk-RDU63CDO.js.map +1 -0
- package/dist/chunk-S2RGHHKZ.js +902 -0
- package/dist/chunk-S2RGHHKZ.js.map +1 -0
- package/dist/{chunk-LVPIIWPP.js → chunk-STKEKXCK.js} +18 -8
- package/dist/chunk-STKEKXCK.js.map +1 -0
- package/dist/chunk-YBGRXVAE.js +407 -0
- package/dist/chunk-YBGRXVAE.js.map +1 -0
- package/dist/cli.js +68 -5
- package/dist/cli.js.map +1 -1
- package/dist/editor/index.d.ts +1 -1
- package/dist/editor/index.js +14 -12
- package/dist/editor/index.js.map +1 -1
- package/dist/effect-26CPF5HS.js +47 -0
- package/dist/embed/index.d.ts +215 -0
- package/dist/embed/index.js +21 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/frames-VEcqa40g.d.ts +161 -0
- package/dist/index.d.ts +6 -163
- package/dist/index.js +86 -78
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +167 -7
- package/dist/node.js +25 -3
- package/dist/react/index.d.ts +33 -2
- package/dist/react/index.js +54 -9
- package/dist/react/index.js.map +1 -1
- package/dist/recorder-CDPPTJMG.js +31 -0
- package/dist/recorder-CDPPTJMG.js.map +1 -0
- package/dist/{renderer-C2P1qGzP.d.ts → renderer-teaa98RE.d.ts} +1 -1
- package/dist/svelte/index.d.ts +33 -4
- package/dist/svelte/index.js +51 -9
- package/dist/svelte/index.js.map +1 -1
- package/dist/vector/index.d.ts +52 -1
- package/dist/vector/index.js +8 -4
- package/package.json +1 -1
- package/dist/chunk-ARKC7LDW.js.map +0 -1
- package/dist/chunk-LVPIIWPP.js.map +0 -1
- package/dist/chunk-NGKNI3QX.js.map +0 -1
- package/dist/chunk-UP6QQVRS.js +0 -331
- package/dist/chunk-UP6QQVRS.js.map +0 -1
- package/dist/recorder-HKFFHQES.js +0 -10
- /package/dist/{chunk-VP6IEWIK.js.map → chunk-H6L67KHI.js.map} +0 -0
- /package/dist/{chunk-OQ4EWZ3B.js.map → chunk-PYDN5G2P.js.map} +0 -0
- /package/dist/{recorder-HKFFHQES.js.map → effect-26CPF5HS.js.map} +0 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_CLICKS,
|
|
3
|
+
MAX_TRAIL,
|
|
4
|
+
PointerTracker,
|
|
5
|
+
SVG_EDGE,
|
|
6
|
+
TRAIL_SECONDS,
|
|
7
|
+
VERTEX_SOURCE,
|
|
8
|
+
wrapSource
|
|
9
|
+
} from "./chunk-RDU63CDO.js";
|
|
10
|
+
import {
|
|
11
|
+
controlValue,
|
|
12
|
+
parseControls
|
|
13
|
+
} from "./chunk-VYOMXE6I.js";
|
|
14
|
+
|
|
15
|
+
// src/export/recorder.ts
|
|
16
|
+
import http from "http";
|
|
17
|
+
import { spawn } from "child_process";
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import os from "os";
|
|
20
|
+
import path from "path";
|
|
21
|
+
var CHROME_CANDIDATES = [
|
|
22
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
23
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
24
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
25
|
+
"/usr/bin/google-chrome",
|
|
26
|
+
"/usr/bin/google-chrome-stable",
|
|
27
|
+
"/usr/bin/chromium",
|
|
28
|
+
"/usr/bin/chromium-browser"
|
|
29
|
+
];
|
|
30
|
+
function playwrightChromes() {
|
|
31
|
+
const roots = [
|
|
32
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH,
|
|
33
|
+
path.join(os.homedir(), "Library/Caches/ms-playwright"),
|
|
34
|
+
path.join(os.homedir(), ".cache/ms-playwright")
|
|
35
|
+
].filter((r) => Boolean(r));
|
|
36
|
+
const found = [];
|
|
37
|
+
for (const root of roots) {
|
|
38
|
+
let dirs = [];
|
|
39
|
+
try {
|
|
40
|
+
dirs = fs.readdirSync(root).filter((d) => /^chromium-\d+$/.test(d));
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
dirs.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
for (const bin of [
|
|
47
|
+
"chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
48
|
+
"chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
49
|
+
"chrome-mac/Chromium.app/Contents/MacOS/Chromium",
|
|
50
|
+
"chrome-linux/chrome",
|
|
51
|
+
"chrome-linux64/chrome"
|
|
52
|
+
]) found.push(path.join(root, dir, bin));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
function findChrome() {
|
|
58
|
+
const env = process.env.CHROME_PATH || process.env.CHROME_BIN;
|
|
59
|
+
if (env) return env;
|
|
60
|
+
return [...CHROME_CANDIDATES, ...playwrightChromes()].find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
61
|
+
}
|
|
62
|
+
function ffmpegPath() {
|
|
63
|
+
return process.env.FFMPEG_PATH || "ffmpeg";
|
|
64
|
+
}
|
|
65
|
+
var NO_FFMPEG = "ffmpeg is not installed (or not on the PATH). Install it (brew install ffmpeg, apt install ffmpeg), or set FFMPEG_PATH; GIF, PNG and SVG output do not need it.";
|
|
66
|
+
function planOutput(outputPath, options = {}) {
|
|
67
|
+
const resolved = path.resolve(process.cwd(), outputPath);
|
|
68
|
+
const trailingSlash = /[\\/]$/.test(outputPath);
|
|
69
|
+
const ext = trailingSlash ? "" : path.extname(resolved).toLowerCase();
|
|
70
|
+
const alpha = Boolean(options.alpha);
|
|
71
|
+
switch (ext) {
|
|
72
|
+
case "": {
|
|
73
|
+
return { format: "png", path: path.join(resolved, "frame_%04d.png"), alpha };
|
|
74
|
+
}
|
|
75
|
+
case ".png": {
|
|
76
|
+
if (/%0?\d*d/.test(resolved) || (options.frames ?? 2) === 1) return { format: "png", path: resolved, alpha };
|
|
77
|
+
return { format: "png", path: resolved.replace(/\.png$/i, "_%04d.png"), alpha };
|
|
78
|
+
}
|
|
79
|
+
case ".gif":
|
|
80
|
+
return { format: "gif", path: resolved, alpha };
|
|
81
|
+
case ".svg":
|
|
82
|
+
return { format: "svg", path: resolved, alpha };
|
|
83
|
+
case ".mp4":
|
|
84
|
+
case ".m4v": {
|
|
85
|
+
if (alpha) throw new Error("MP4 (H.264) cannot hold alpha; write .webm, .mov, .gif or .png frames for a transparent render, or drop --alpha.");
|
|
86
|
+
return {
|
|
87
|
+
format: "mp4",
|
|
88
|
+
path: resolved,
|
|
89
|
+
alpha,
|
|
90
|
+
ffmpegArgs: [
|
|
91
|
+
// yuv420p needs even sizes; sRGB pixels go to BT.709, tagged so players show the same colours.
|
|
92
|
+
"-vf",
|
|
93
|
+
"pad=ceil(iw/2)*2:ceil(ih/2)*2,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p",
|
|
94
|
+
"-c:v",
|
|
95
|
+
"libx264",
|
|
96
|
+
"-pix_fmt",
|
|
97
|
+
"yuv420p",
|
|
98
|
+
"-crf",
|
|
99
|
+
"18",
|
|
100
|
+
"-preset",
|
|
101
|
+
"medium",
|
|
102
|
+
"-colorspace",
|
|
103
|
+
"bt709",
|
|
104
|
+
"-color_primaries",
|
|
105
|
+
"bt709",
|
|
106
|
+
"-color_trc",
|
|
107
|
+
"bt709",
|
|
108
|
+
"-color_range",
|
|
109
|
+
"tv",
|
|
110
|
+
"-movflags",
|
|
111
|
+
"+faststart"
|
|
112
|
+
]
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
case ".webm":
|
|
116
|
+
return {
|
|
117
|
+
format: "webm",
|
|
118
|
+
path: resolved,
|
|
119
|
+
alpha,
|
|
120
|
+
ffmpegArgs: alpha ? ["-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", "30", "-auto-alt-ref", "0", "-row-mt", "1"] : [
|
|
121
|
+
"-vf",
|
|
122
|
+
"scale=out_color_matrix=bt709:out_range=tv,format=yuv420p",
|
|
123
|
+
"-c:v",
|
|
124
|
+
"libvpx-vp9",
|
|
125
|
+
"-pix_fmt",
|
|
126
|
+
"yuv420p",
|
|
127
|
+
"-b:v",
|
|
128
|
+
"0",
|
|
129
|
+
"-crf",
|
|
130
|
+
"30",
|
|
131
|
+
"-row-mt",
|
|
132
|
+
"1",
|
|
133
|
+
"-colorspace",
|
|
134
|
+
"bt709",
|
|
135
|
+
"-color_primaries",
|
|
136
|
+
"bt709",
|
|
137
|
+
"-color_trc",
|
|
138
|
+
"bt709"
|
|
139
|
+
]
|
|
140
|
+
};
|
|
141
|
+
case ".mov":
|
|
142
|
+
return {
|
|
143
|
+
format: "mov",
|
|
144
|
+
path: resolved,
|
|
145
|
+
alpha,
|
|
146
|
+
ffmpegArgs: [
|
|
147
|
+
"-c:v",
|
|
148
|
+
"prores_ks",
|
|
149
|
+
"-profile:v",
|
|
150
|
+
"4444",
|
|
151
|
+
"-vendor",
|
|
152
|
+
"apl0",
|
|
153
|
+
"-pix_fmt",
|
|
154
|
+
alpha ? "yuva444p10le" : "yuv444p10le",
|
|
155
|
+
...alpha ? ["-alpha_bits", "16"] : [],
|
|
156
|
+
"-colorspace",
|
|
157
|
+
"bt709",
|
|
158
|
+
"-color_primaries",
|
|
159
|
+
"bt709",
|
|
160
|
+
"-color_trc",
|
|
161
|
+
"bt709"
|
|
162
|
+
]
|
|
163
|
+
};
|
|
164
|
+
default:
|
|
165
|
+
throw new Error(`Flair cannot write "${ext}" files; write .mp4, .webm, .mov, .gif, .svg, or .png frames (a folder, or a pattern like out_%04d.png).`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function sequencePath(pattern, frame) {
|
|
169
|
+
return pattern.replace(/%(0?)(\d*)d/, (_, zero, width) => String(frame).padStart(Number(width || 0), zero ? "0" : " "));
|
|
170
|
+
}
|
|
171
|
+
function scriptedPointer(width, height, times, options = {}) {
|
|
172
|
+
const interval = options.interval ?? 0.8;
|
|
173
|
+
const period = Math.max(2, options.period ?? (times[times.length - 1] ?? 2));
|
|
174
|
+
const at = (t) => [
|
|
175
|
+
width * (0.5 + 0.3 * Math.sin(2 * Math.PI * t / period)),
|
|
176
|
+
height * (0.5 + 0.22 * Math.sin(4 * Math.PI * t / period))
|
|
177
|
+
];
|
|
178
|
+
const press = 0.25;
|
|
179
|
+
const tracker = new PointerTracker();
|
|
180
|
+
tracker.hover(true, 0);
|
|
181
|
+
tracker.move(...at(0), 0);
|
|
182
|
+
let clock = 0;
|
|
183
|
+
let nextClick = 0.25;
|
|
184
|
+
let release = Infinity;
|
|
185
|
+
let mouse = [0, 0, 0, 0];
|
|
186
|
+
const frames = [];
|
|
187
|
+
for (const time of times) {
|
|
188
|
+
while (clock < time) {
|
|
189
|
+
const next = Math.min(time, clock + TRAIL_SECONDS, nextClick, release);
|
|
190
|
+
clock = next;
|
|
191
|
+
const [x, y] = at(clock);
|
|
192
|
+
tracker.move(x, y, clock);
|
|
193
|
+
if (mouse[2] > 0) mouse = [x, y, mouse[2], mouse[3]];
|
|
194
|
+
if (clock >= release) {
|
|
195
|
+
tracker.up(clock);
|
|
196
|
+
mouse = [mouse[0], mouse[1], -mouse[2], -Math.abs(mouse[3])];
|
|
197
|
+
release = Infinity;
|
|
198
|
+
}
|
|
199
|
+
if (clock >= nextClick) {
|
|
200
|
+
tracker.down(x, y, clock);
|
|
201
|
+
mouse = [x, y, x, y];
|
|
202
|
+
release = clock + press;
|
|
203
|
+
nextClick = clock + interval;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
frames.push({
|
|
207
|
+
mouse: [...mouse],
|
|
208
|
+
pointer: tracker.pointer(time),
|
|
209
|
+
clicks: [...tracker.clicksAt(time)],
|
|
210
|
+
trail: [...tracker.trailAt(time)]
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return frames;
|
|
214
|
+
}
|
|
215
|
+
var scriptJson = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
216
|
+
function renderPageHtml(config) {
|
|
217
|
+
return `<!doctype html>
|
|
218
|
+
<html>
|
|
219
|
+
<head>
|
|
220
|
+
<meta charset="utf-8">
|
|
221
|
+
<style>html,body{margin:0;background:transparent;overflow:hidden}</style>
|
|
222
|
+
</head>
|
|
223
|
+
<body>
|
|
224
|
+
<script>
|
|
225
|
+
const CONFIG = ${scriptJson(config)};
|
|
226
|
+
const VERTEX = ${scriptJson(VERTEX_SOURCE)};
|
|
227
|
+
const W = CONFIG.width, H = CONFIG.height;
|
|
228
|
+
const report = (message) => fetch("/error", { method: "POST", body: String(message) });
|
|
229
|
+
addEventListener("error", (e) => report("The render page failed: " + e.message));
|
|
230
|
+
addEventListener("unhandledrejection", (e) => report((e.reason && e.reason.message) || String(e.reason)));
|
|
231
|
+
|
|
232
|
+
// SVGs decode at their own (often icon) size: give them ${SVG_EDGE} px on the long edge, and a viewBox.
|
|
233
|
+
function sizeSvg(markup, w0, h0) {
|
|
234
|
+
const doc = new DOMParser().parseFromString(markup, "image/svg+xml");
|
|
235
|
+
const svg = doc.documentElement;
|
|
236
|
+
if (svg.localName !== "svg" || doc.getElementsByTagName("parsererror").length) throw new Error("Not an SVG document.");
|
|
237
|
+
const len = (n) => { const m = (svg.getAttribute(n) || "").trim().match(/^([\\d.]+)(px)?$/); return m && +m[1] > 0 ? +m[1] : 0; };
|
|
238
|
+
const box = (svg.getAttribute("viewBox") || "").trim().split(/[\\s,]+/).map(Number);
|
|
239
|
+
const hasBox = box.length === 4 && box[2] > 0 && box[3] > 0;
|
|
240
|
+
const w = len("width") || (hasBox ? box[2] : 300), h = len("height") || (hasBox ? box[3] : 150);
|
|
241
|
+
if (!hasBox) svg.setAttribute("viewBox", "0 0 " + w + " " + h);
|
|
242
|
+
const scale = w0 ? 1 : ${SVG_EDGE} / Math.max(w, h);
|
|
243
|
+
svg.setAttribute("width", w0 || Math.max(1, Math.round(w * scale)));
|
|
244
|
+
svg.setAttribute("height", h0 || Math.max(1, Math.round(h * scale)));
|
|
245
|
+
return new XMLSerializer().serializeToString(svg);
|
|
246
|
+
}
|
|
247
|
+
const isSvg = (url) => /^data:image\\/svg\\+xml[;,]/i.test(url) || /^(?!data:)[^?#]*\\.svg([?#]|$)/i.test(url);
|
|
248
|
+
async function decode(src, name) {
|
|
249
|
+
const img = new Image();
|
|
250
|
+
img.src = src;
|
|
251
|
+
try { await img.decode(); } catch (e) { throw new Error("Could not load the image " + name + "."); }
|
|
252
|
+
return img;
|
|
253
|
+
}
|
|
254
|
+
async function loadImage(url, name) {
|
|
255
|
+
if (!isSvg(url)) return decode(url, name);
|
|
256
|
+
const text = await (await fetch(url)).text();
|
|
257
|
+
const blob = URL.createObjectURL(new Blob([sizeSvg(text)], { type: "image/svg+xml" }));
|
|
258
|
+
try { return await decode(blob, name); } finally { URL.revokeObjectURL(blob); }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const out = document.createElement("canvas");
|
|
262
|
+
out.width = W; out.height = H;
|
|
263
|
+
const ctx = out.getContext("2d", { willReadFrequently: CONFIG.send === "rgba" });
|
|
264
|
+
let gl = null, glCanvas = null, u = null;
|
|
265
|
+
const textures = {};
|
|
266
|
+
|
|
267
|
+
function setupShader() {
|
|
268
|
+
glCanvas = document.createElement("canvas");
|
|
269
|
+
glCanvas.width = W; glCanvas.height = H;
|
|
270
|
+
// As createContext: no antialiasing, depth or stencil; unpremultiplied alpha so pixels read back exactly.
|
|
271
|
+
gl = glCanvas.getContext("webgl2", { alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true, antialias: false, depth: false, stencil: false, powerPreference: "high-performance" });
|
|
272
|
+
if (!gl) throw new Error("WebGL2 is not available in headless Chrome.");
|
|
273
|
+
const compile = (type, src) => {
|
|
274
|
+
const s = gl.createShader(type);
|
|
275
|
+
gl.shaderSource(s, src);
|
|
276
|
+
gl.compileShader(s);
|
|
277
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error("The shader does not compile: " + gl.getShaderInfoLog(s));
|
|
278
|
+
return s;
|
|
279
|
+
};
|
|
280
|
+
const program = gl.createProgram();
|
|
281
|
+
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX));
|
|
282
|
+
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, CONFIG.shader.fragment));
|
|
283
|
+
gl.linkProgram(program);
|
|
284
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error("The shader does not link: " + gl.getProgramInfoLog(program));
|
|
285
|
+
gl.useProgram(program);
|
|
286
|
+
u = (n) => gl.getUniformLocation(program, n);
|
|
287
|
+
gl.viewport(0, 0, W, H);
|
|
288
|
+
if (CONFIG.shader.opaque) {
|
|
289
|
+
// An opaque canvas, as ShaderRenderer has without alpha: alpha stays 1 whatever the shader writes.
|
|
290
|
+
gl.clearColor(0, 0, 0, 1);
|
|
291
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
292
|
+
gl.colorMask(true, true, true, false);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function makeTexture(unit, texture) {
|
|
297
|
+
const t = gl.createTexture();
|
|
298
|
+
const settings = texture || { filter: "mipmap", wrap: "repeat" };
|
|
299
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
300
|
+
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
301
|
+
// Black until (or unless) a picture arrives, as TextureCache does.
|
|
302
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255]));
|
|
303
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, { mipmap: gl.LINEAR_MIPMAP_LINEAR, linear: gl.LINEAR, nearest: gl.NEAREST }[settings.filter]);
|
|
304
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, settings.filter === "nearest" ? gl.NEAREST : gl.LINEAR);
|
|
305
|
+
const wrap = { repeat: gl.REPEAT, clamp: gl.CLAMP_TO_EDGE, mirror: gl.MIRRORED_REPEAT }[settings.wrap];
|
|
306
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
|
|
307
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
|
|
308
|
+
return { unit, texture: t };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function upload(name, img) {
|
|
312
|
+
const slot = textures[name];
|
|
313
|
+
if (!slot) return;
|
|
314
|
+
gl.activeTexture(gl.TEXTURE0 + slot.unit);
|
|
315
|
+
gl.bindTexture(gl.TEXTURE_2D, slot.texture);
|
|
316
|
+
// Rows bottom up, as the renderer's TextureCache uploads them.
|
|
317
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
318
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
319
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
320
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function setControls() {
|
|
324
|
+
let unit = 0;
|
|
325
|
+
const loads = [];
|
|
326
|
+
for (const c of CONFIG.shader.controls) {
|
|
327
|
+
const loc = u(c.name);
|
|
328
|
+
const v = c.value;
|
|
329
|
+
if (c.type === "sampler2D") {
|
|
330
|
+
textures[c.name] = makeTexture(unit, c.texture);
|
|
331
|
+
if (loc) gl.uniform1i(loc, unit);
|
|
332
|
+
unit++;
|
|
333
|
+
if (v) loads.push(loadImage(v, "for " + c.name).then((img) => upload(c.name, img)));
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (!loc) continue;
|
|
337
|
+
if (c.type === "float") gl.uniform1f(loc, v);
|
|
338
|
+
else if (c.type === "int") gl.uniform1i(loc, Math.round(v));
|
|
339
|
+
else if (c.type === "bool") gl.uniform1i(loc, v ? 1 : 0);
|
|
340
|
+
else if (Array.isArray(v)) gl["uniform" + v.length + "fv"](loc, v);
|
|
341
|
+
}
|
|
342
|
+
await Promise.all(loads);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function drawShader(s) {
|
|
346
|
+
gl.viewport(0, 0, W, H);
|
|
347
|
+
const set = (n, f) => { const l = u(n); if (l) f(l); };
|
|
348
|
+
set("iResolution", (l) => gl.uniform3f(l, W, H, 1));
|
|
349
|
+
set("iTime", (l) => gl.uniform1f(l, s.time));
|
|
350
|
+
set("iTimeDelta", (l) => gl.uniform1f(l, s.delta));
|
|
351
|
+
set("iFrame", (l) => gl.uniform1i(l, s.frame));
|
|
352
|
+
set("iMouse", (l) => gl.uniform4fv(l, s.mouse));
|
|
353
|
+
set("iPointer", (l) => gl.uniform4fv(l, s.pointer));
|
|
354
|
+
set("iClicks", (l) => gl.uniform4fv(l, new Float32Array(s.clicks)));
|
|
355
|
+
set("iTrail", (l) => gl.uniform4fv(l, new Float32Array(s.trail)));
|
|
356
|
+
// glslsandbox-style names, for complete programs.
|
|
357
|
+
set("u_resolution", (l) => gl.uniform2f(l, W, H));
|
|
358
|
+
set("u_time", (l) => gl.uniform1f(l, s.time));
|
|
359
|
+
set("u_mouse", (l) => gl.uniform2f(l, s.pointer[0], s.pointer[1]));
|
|
360
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function frameImage(s) {
|
|
364
|
+
if (CONFIG.shader) {
|
|
365
|
+
for (const [name, url] of Object.entries(s.video || {})) upload(name, await decode(url, "of the video frame"));
|
|
366
|
+
drawShader(s);
|
|
367
|
+
return glCanvas;
|
|
368
|
+
}
|
|
369
|
+
const markup = sizeSvg(s.svg, W, H);
|
|
370
|
+
const url = URL.createObjectURL(new Blob([markup], { type: "image/svg+xml" }));
|
|
371
|
+
try { return await decode(url, "of the SVG frame"); } finally { URL.revokeObjectURL(url); }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function capture(src) {
|
|
375
|
+
// The WebGL canvas reads back exactly when nothing is composited under it.
|
|
376
|
+
const direct = CONFIG.shader && (CONFIG.alpha || CONFIG.shader.opaque);
|
|
377
|
+
if (direct) {
|
|
378
|
+
if (CONFIG.send === "png") return new Promise((r) => glCanvas.toBlob(r, "image/png"));
|
|
379
|
+
const px = new Uint8Array(W * H * 4);
|
|
380
|
+
gl.readPixels(0, 0, W, H, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
|
381
|
+
const flipped = new Uint8Array(px.length), row = W * 4;
|
|
382
|
+
for (let y = 0; y < H; y++) flipped.set(px.subarray((H - 1 - y) * row, (H - y) * row), y * row);
|
|
383
|
+
return flipped;
|
|
384
|
+
}
|
|
385
|
+
ctx.clearRect(0, 0, W, H);
|
|
386
|
+
if (!CONFIG.alpha) { ctx.fillStyle = CONFIG.background; ctx.fillRect(0, 0, W, H); }
|
|
387
|
+
ctx.drawImage(src, 0, 0, W, H);
|
|
388
|
+
if (CONFIG.send === "png") return new Promise((r) => out.toBlob(r, "image/png"));
|
|
389
|
+
return ctx.getImageData(0, 0, W, H).data;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
(async () => {
|
|
393
|
+
if (CONFIG.shader) { setupShader(); await setControls(); }
|
|
394
|
+
for (let i = 0; i < CONFIG.frames; i++) {
|
|
395
|
+
const s = await (await fetch("/state?i=" + i)).json();
|
|
396
|
+
const body = await capture(await frameImage(s));
|
|
397
|
+
const r = await fetch("/frame?i=" + i, { method: "POST", body });
|
|
398
|
+
if (!r.ok) return;
|
|
399
|
+
}
|
|
400
|
+
})().catch((e) => report((e && e.message) || String(e)));
|
|
401
|
+
</script>
|
|
402
|
+
</body>
|
|
403
|
+
</html>
|
|
404
|
+
`;
|
|
405
|
+
}
|
|
406
|
+
function ffmpegSink(plan, fps) {
|
|
407
|
+
const args = ["-y", "-hide_banner", "-loglevel", "error", "-f", "image2pipe", "-framerate", String(fps), "-c:v", "png", "-i", "-", ...plan.ffmpegArgs, "-r", String(fps), plan.path];
|
|
408
|
+
const proc = spawn(ffmpegPath(), args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
409
|
+
let stderr = "";
|
|
410
|
+
let failed = null;
|
|
411
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
412
|
+
const done = new Promise((resolve, reject) => {
|
|
413
|
+
proc.on("error", (err) => {
|
|
414
|
+
failed = err.code === "ENOENT" ? new Error(NO_FFMPEG) : new Error(`ffmpeg could not start: ${err.message}`);
|
|
415
|
+
reject(failed);
|
|
416
|
+
});
|
|
417
|
+
proc.on("close", (code) => {
|
|
418
|
+
if (code === 0) resolve();
|
|
419
|
+
else reject(failed ??= new Error(`ffmpeg failed (exit ${code}): ${stderr.trim() || "no message"}`));
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
done.catch(() => {
|
|
423
|
+
});
|
|
424
|
+
proc.stdin.on("error", () => {
|
|
425
|
+
});
|
|
426
|
+
return {
|
|
427
|
+
async write(_frame, data) {
|
|
428
|
+
if (failed) throw failed;
|
|
429
|
+
if (!proc.stdin.write(data)) await new Promise((r) => proc.stdin.once("drain", r));
|
|
430
|
+
},
|
|
431
|
+
async finish() {
|
|
432
|
+
proc.stdin.end();
|
|
433
|
+
await done;
|
|
434
|
+
},
|
|
435
|
+
abort() {
|
|
436
|
+
proc.kill("SIGKILL");
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function pngSink(plan) {
|
|
441
|
+
fs.mkdirSync(path.dirname(plan.path), { recursive: true });
|
|
442
|
+
return {
|
|
443
|
+
async write(frame, data) {
|
|
444
|
+
await fs.promises.writeFile(plan.path.includes("%") ? sequencePath(plan.path, frame) : plan.path, data);
|
|
445
|
+
},
|
|
446
|
+
async finish() {
|
|
447
|
+
},
|
|
448
|
+
abort() {
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
async function gifSink(plan, width, height, fps) {
|
|
453
|
+
const mod = await import("gifenc");
|
|
454
|
+
const { GIFEncoder, quantize, applyPalette } = typeof mod.GIFEncoder === "function" ? mod : mod.default;
|
|
455
|
+
const gif = GIFEncoder();
|
|
456
|
+
const delay = 1e3 / fps;
|
|
457
|
+
return {
|
|
458
|
+
async write(frame, data) {
|
|
459
|
+
const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
460
|
+
const first = frame === 0 ? { repeat: 0 } : {};
|
|
461
|
+
if (plan.alpha) {
|
|
462
|
+
const palette = quantize(rgba, 256, { format: "rgba4444", oneBitAlpha: true });
|
|
463
|
+
const transparentIndex = palette.findIndex((c) => c[3] === 0);
|
|
464
|
+
gif.writeFrame(applyPalette(rgba, palette, "rgba4444"), width, height, {
|
|
465
|
+
palette,
|
|
466
|
+
delay,
|
|
467
|
+
dispose: 2,
|
|
468
|
+
...transparentIndex >= 0 ? { transparent: true, transparentIndex } : {},
|
|
469
|
+
...first
|
|
470
|
+
});
|
|
471
|
+
} else {
|
|
472
|
+
const palette = quantize(rgba, 256);
|
|
473
|
+
gif.writeFrame(applyPalette(rgba, palette), width, height, { palette, delay, ...first });
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
async finish() {
|
|
477
|
+
gif.finish();
|
|
478
|
+
fs.mkdirSync(path.dirname(plan.path), { recursive: true });
|
|
479
|
+
await fs.promises.writeFile(plan.path, gif.bytes());
|
|
480
|
+
},
|
|
481
|
+
abort() {
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function chromeArgs(userDataDir, width, height, url) {
|
|
486
|
+
return [
|
|
487
|
+
"--headless=new",
|
|
488
|
+
`--user-data-dir=${userDataDir}`,
|
|
489
|
+
`--window-size=${Math.max(64, width)},${Math.max(64, height)}`,
|
|
490
|
+
"--use-gl=angle",
|
|
491
|
+
...process.platform === "darwin" ? ["--use-angle=metal"] : [],
|
|
492
|
+
"--enable-webgl",
|
|
493
|
+
"--enable-unsafe-swiftshader",
|
|
494
|
+
"--ignore-gpu-blocklist",
|
|
495
|
+
"--disable-gpu-sandbox",
|
|
496
|
+
"--force-color-profile=srgb",
|
|
497
|
+
"--no-first-run",
|
|
498
|
+
"--no-default-browser-check",
|
|
499
|
+
"--disable-background-networking",
|
|
500
|
+
"--disable-background-timer-throttling",
|
|
501
|
+
"--disable-renderer-backgrounding",
|
|
502
|
+
"--disable-extensions",
|
|
503
|
+
url
|
|
504
|
+
];
|
|
505
|
+
}
|
|
506
|
+
var STALL_MS = 6e4;
|
|
507
|
+
async function runRender(job) {
|
|
508
|
+
const { page, plan, fps, onProgress } = job;
|
|
509
|
+
const total = page.frames;
|
|
510
|
+
const chromeBin = findChrome();
|
|
511
|
+
if (!chromeBin) {
|
|
512
|
+
throw new Error('Chrome or Chromium is needed to render, and none was found. Install Google Chrome, run "npx playwright install chromium", or set CHROME_PATH.');
|
|
513
|
+
}
|
|
514
|
+
fs.mkdirSync(path.dirname(plan.path), { recursive: true });
|
|
515
|
+
const sink = plan.format === "gif" ? await gifSink(plan, page.width, page.height, fps) : plan.format === "png" ? pngSink(plan) : ffmpegSink(plan, fps);
|
|
516
|
+
const html = renderPageHtml(page);
|
|
517
|
+
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "flair-chrome-"));
|
|
518
|
+
return new Promise((resolve, reject) => {
|
|
519
|
+
let chrome = null;
|
|
520
|
+
let finished = false;
|
|
521
|
+
let written = 0;
|
|
522
|
+
let stall;
|
|
523
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
524
|
+
const cleanup = () => {
|
|
525
|
+
clearTimeout(stall);
|
|
526
|
+
try {
|
|
527
|
+
chrome?.kill("SIGKILL");
|
|
528
|
+
} catch {
|
|
529
|
+
}
|
|
530
|
+
for (const s of sockets) s.destroy();
|
|
531
|
+
server.close();
|
|
532
|
+
fs.rm(userDataDir, { recursive: true, force: true }, () => {
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
const fail = (err) => {
|
|
536
|
+
if (finished) return;
|
|
537
|
+
finished = true;
|
|
538
|
+
sink.abort();
|
|
539
|
+
cleanup();
|
|
540
|
+
reject(err);
|
|
541
|
+
};
|
|
542
|
+
const succeed = () => {
|
|
543
|
+
if (finished) return;
|
|
544
|
+
finished = true;
|
|
545
|
+
cleanup();
|
|
546
|
+
resolve(plan.path);
|
|
547
|
+
};
|
|
548
|
+
const watchdog = () => {
|
|
549
|
+
clearTimeout(stall);
|
|
550
|
+
stall = setTimeout(() => fail(new Error(`The render stalled: no frame for ${STALL_MS / 1e3} s after frame ${written} of ${total}. Is WebGL2 available to headless Chrome?`)), STALL_MS);
|
|
551
|
+
};
|
|
552
|
+
const server = http.createServer((req, res) => {
|
|
553
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
554
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
555
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
556
|
+
res.end(html);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (url.pathname === "/state") {
|
|
560
|
+
const i = Number(url.searchParams.get("i"));
|
|
561
|
+
try {
|
|
562
|
+
const s = job.state(i);
|
|
563
|
+
const body = { ...s, ...s.video ? { video: Object.fromEntries(Object.keys(s.video).map((k) => [k, `/video?i=${i}&name=${encodeURIComponent(k)}`])) } : {} };
|
|
564
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
565
|
+
res.end(JSON.stringify(body));
|
|
566
|
+
} catch (e) {
|
|
567
|
+
res.writeHead(500);
|
|
568
|
+
res.end();
|
|
569
|
+
fail(e);
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (url.pathname === "/video") {
|
|
574
|
+
const i = Number(url.searchParams.get("i"));
|
|
575
|
+
const file = job.state(i).video?.[url.searchParams.get("name") ?? ""];
|
|
576
|
+
if (!file) {
|
|
577
|
+
res.writeHead(404);
|
|
578
|
+
res.end();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
res.writeHead(200, { "Content-Type": "image/png" });
|
|
582
|
+
fs.createReadStream(file).pipe(res);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (url.pathname === "/frame" && req.method === "POST") {
|
|
586
|
+
const i = Number(url.searchParams.get("i"));
|
|
587
|
+
const chunks = [];
|
|
588
|
+
req.on("data", (c) => chunks.push(c));
|
|
589
|
+
req.on("end", () => {
|
|
590
|
+
sink.write(i, Buffer.concat(chunks)).then(async () => {
|
|
591
|
+
written++;
|
|
592
|
+
watchdog();
|
|
593
|
+
onProgress?.(written, total);
|
|
594
|
+
res.writeHead(200);
|
|
595
|
+
res.end("ok");
|
|
596
|
+
if (written >= total) {
|
|
597
|
+
await sink.finish();
|
|
598
|
+
succeed();
|
|
599
|
+
}
|
|
600
|
+
}).catch((e) => {
|
|
601
|
+
res.writeHead(500);
|
|
602
|
+
res.end();
|
|
603
|
+
fail(e);
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (url.pathname === "/error" && req.method === "POST") {
|
|
609
|
+
let message = "";
|
|
610
|
+
req.on("data", (c) => message += c);
|
|
611
|
+
req.on("end", () => {
|
|
612
|
+
res.writeHead(200);
|
|
613
|
+
res.end();
|
|
614
|
+
fail(new Error(message || "The render page failed."));
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
res.writeHead(404);
|
|
619
|
+
res.end();
|
|
620
|
+
});
|
|
621
|
+
server.on("connection", (s) => {
|
|
622
|
+
sockets.add(s);
|
|
623
|
+
s.on("close", () => sockets.delete(s));
|
|
624
|
+
});
|
|
625
|
+
server.listen(0, "127.0.0.1", () => {
|
|
626
|
+
const { port } = server.address();
|
|
627
|
+
chrome = spawn(chromeBin, chromeArgs(userDataDir, page.width, page.height, `http://127.0.0.1:${port}/`), { stdio: "ignore" });
|
|
628
|
+
chrome.on("error", (err) => fail(new Error(`Could not launch Chrome at ${chromeBin}: ${err.message}`)));
|
|
629
|
+
chrome.on("exit", (code) => {
|
|
630
|
+
if (!finished && written < total) fail(new Error(`Chrome quit (exit ${code}) after ${written} of ${total} frames.`));
|
|
631
|
+
});
|
|
632
|
+
watchdog();
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
var MIME = {
|
|
637
|
+
".png": "image/png",
|
|
638
|
+
".jpg": "image/jpeg",
|
|
639
|
+
".jpeg": "image/jpeg",
|
|
640
|
+
".gif": "image/gif",
|
|
641
|
+
".webp": "image/webp",
|
|
642
|
+
".avif": "image/avif",
|
|
643
|
+
".bmp": "image/bmp",
|
|
644
|
+
".svg": "image/svg+xml"
|
|
645
|
+
};
|
|
646
|
+
async function imageDataUrl(source, cwd = process.cwd()) {
|
|
647
|
+
if (/^data:/i.test(source) || source === "") return source;
|
|
648
|
+
if (/^https?:/i.test(source)) {
|
|
649
|
+
const res = await fetch(source);
|
|
650
|
+
if (!res.ok) throw new Error(`Could not download the image at ${source} (HTTP ${res.status}).`);
|
|
651
|
+
const type2 = res.headers.get("content-type")?.split(";")[0] || MIME[path.extname(new URL(source).pathname).toLowerCase()] || "application/octet-stream";
|
|
652
|
+
return `data:${type2};base64,${Buffer.from(await res.arrayBuffer()).toString("base64")}`;
|
|
653
|
+
}
|
|
654
|
+
const file = path.resolve(cwd, source.replace(/^file:\/\//, ""));
|
|
655
|
+
if (!fs.existsSync(file)) throw new Error(`There is no image at ${file}.`);
|
|
656
|
+
const type = MIME[path.extname(file).toLowerCase()];
|
|
657
|
+
if (!type) throw new Error(`${path.basename(file)} is not an image Flair can show; give it a PNG, JPEG, GIF, WebP, AVIF, BMP or SVG.`);
|
|
658
|
+
return `data:${type};base64,${fs.readFileSync(file).toString("base64")}`;
|
|
659
|
+
}
|
|
660
|
+
function fragmentSource(source) {
|
|
661
|
+
if (!/\bvoid\s+main\s*\(\s*\)/.test(source)) return wrapSource(source);
|
|
662
|
+
return /^\s*#version\b/.test(source) ? source : `#version 300 es
|
|
663
|
+
#line 1
|
|
664
|
+
${source}`;
|
|
665
|
+
}
|
|
666
|
+
async function pageControls(source, values = {}) {
|
|
667
|
+
return Promise.all(
|
|
668
|
+
parseControls(source).map(async (c) => {
|
|
669
|
+
let value = controlValue(c, values);
|
|
670
|
+
if (c.type === "sampler2D" && typeof value === "string") value = await imageDataUrl(value);
|
|
671
|
+
return { name: c.name, type: c.type, value, ...c.texture ? { texture: c.texture } : {} };
|
|
672
|
+
})
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
function frameStates(frames, fps, width, height, pointer) {
|
|
676
|
+
const times = Array.from({ length: frames }, (_, i) => i / fps);
|
|
677
|
+
const scripted = pointer === "auto" ? scriptedPointer(width, height, times) : void 0;
|
|
678
|
+
const none = { mouse: [0, 0, 0, 0], pointer: [0, 0, 0, 0], clicks: new Array(MAX_CLICKS * 4).fill(0), trail: new Array(MAX_TRAIL * 4).fill(0) };
|
|
679
|
+
return times.map((time, i) => ({ time, delta: i === 0 ? 0 : 1 / fps, frame: i, ...scripted?.[i] ?? none }));
|
|
680
|
+
}
|
|
681
|
+
async function exportMotionGraphic(options) {
|
|
682
|
+
const { source, values = {}, outputPath, fps = 30, duration = 3, onProgress } = options;
|
|
683
|
+
const width = Math.max(1, Math.round(options.width ?? 720));
|
|
684
|
+
const height = Math.max(1, Math.round(options.height ?? 720));
|
|
685
|
+
const frames = Math.max(1, Math.round(duration * fps));
|
|
686
|
+
const plan = planOutput(outputPath, { alpha: options.alpha, frames });
|
|
687
|
+
if (plan.format === "svg") throw new Error("A shader cannot be written as SVG; write .mp4, .webm, .mov, .gif or .png frames.");
|
|
688
|
+
const states = frameStates(frames, fps, width, height, options.pointer ?? "none");
|
|
689
|
+
return runRender({
|
|
690
|
+
page: {
|
|
691
|
+
width,
|
|
692
|
+
height,
|
|
693
|
+
frames,
|
|
694
|
+
alpha: plan.alpha,
|
|
695
|
+
background: options.background ?? "#000000",
|
|
696
|
+
send: plan.format === "gif" ? "rgba" : "png",
|
|
697
|
+
shader: {
|
|
698
|
+
fragment: fragmentSource(source),
|
|
699
|
+
controls: await pageControls(source, values),
|
|
700
|
+
opaque: !plan.alpha && !options.transparent
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
state: (i) => states[i],
|
|
704
|
+
plan,
|
|
705
|
+
fps,
|
|
706
|
+
onProgress
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export {
|
|
711
|
+
findChrome,
|
|
712
|
+
ffmpegPath,
|
|
713
|
+
planOutput,
|
|
714
|
+
sequencePath,
|
|
715
|
+
scriptedPointer,
|
|
716
|
+
renderPageHtml,
|
|
717
|
+
runRender,
|
|
718
|
+
imageDataUrl,
|
|
719
|
+
fragmentSource,
|
|
720
|
+
pageControls,
|
|
721
|
+
frameStates,
|
|
722
|
+
exportMotionGraphic
|
|
723
|
+
};
|
|
724
|
+
//# sourceMappingURL=chunk-JL6TI6II.js.map
|