reframe-video 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +77 -0
  3. package/assets/fonts/inter-400.woff2 +0 -0
  4. package/assets/fonts/inter-700.woff2 +0 -0
  5. package/assets/fonts/inter-800.woff2 +0 -0
  6. package/assets/sfx/LICENSE.md +12 -0
  7. package/assets/sfx/click_002.ogg +0 -0
  8. package/assets/sfx/click_003.ogg +0 -0
  9. package/assets/sfx/click_004.ogg +0 -0
  10. package/assets/sfx/confirmation_001.ogg +0 -0
  11. package/assets/sfx/keypress-001.wav +0 -0
  12. package/assets/sfx/keypress-004.wav +0 -0
  13. package/assets/sfx/keypress-007.wav +0 -0
  14. package/assets/sfx/keypress-010.wav +0 -0
  15. package/assets/sfx/keypress-014.wav +0 -0
  16. package/dist/analyze.js +344 -0
  17. package/dist/bin.js +1677 -0
  18. package/dist/browserEntry.js +532 -0
  19. package/dist/cli.js +1205 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +889 -0
  22. package/dist/renderer-canvas.js +89 -0
  23. package/dist/types/audio.d.ts +53 -0
  24. package/dist/types/behaviors.d.ts +7 -0
  25. package/dist/types/compile.d.ts +38 -0
  26. package/dist/types/compose.d.ts +64 -0
  27. package/dist/types/dsl.d.ts +66 -0
  28. package/dist/types/evaluate.d.ts +59 -0
  29. package/dist/types/index.d.ts +9 -0
  30. package/dist/types/interpolate.d.ts +12 -0
  31. package/dist/types/ir.d.ts +213 -0
  32. package/dist/types/validate.d.ts +12 -0
  33. package/guides/edsl-guide.md +202 -0
  34. package/guides/regen-contract.md +18 -0
  35. package/package.json +55 -0
  36. package/preview/index.html +60 -0
  37. package/preview/src/main.ts +162 -0
  38. package/preview/src/panel.ts +347 -0
  39. package/preview/src/store.ts +220 -0
  40. package/preview/src/virtual.d.ts +4 -0
  41. package/preview/vite.config.ts +52 -0
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kiyeon Jeon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Bundled assets: CC0 sound samples in assets/sfx/ retain their original
24
+ provenance — see assets/sfx/LICENSE.md.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # reframe
2
+
3
+ **Declarative motion graphics that AI can write, humans can tweak — and the
4
+ human's edits survive an AI regeneration.**
5
+
6
+ A scene is a single self-contained `.ts` file (plain-data IR, no React, no
7
+ project scaffold). Renders are deterministic: same input, byte-identical
8
+ frames. Human edits live in a non-destructive overlay JSON addressed by stable
9
+ node ids / state names / timeline labels — regenerate the scene with an AI and
10
+ the overlay reapplies; anything broken is reported loudly, never silently lost.
11
+
12
+ ```bash
13
+ brew install ffmpeg # system dep (or apt install ffmpeg)
14
+ npx playwright install chromium # one-time browser download
15
+ npx reframe-video new hello # scaffold hello.ts in this directory
16
+ npx reframe-video render hello.ts # → out/hello.mp4
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ | command | what it does |
22
+ |---|---|
23
+ | `reframe render <scene.ts> [--overlay edits.json] [-o out.mp4]` | deterministic mp4 |
24
+ | `reframe batch <scene.ts> <data.json\|csv>` | one mp4 per data row (row keys are overlay addresses) |
25
+ | `reframe preview` | scrub/play/edit UI for scenes in the current directory; edits export as overlay JSON |
26
+ | `reframe new <name>` | scaffold a documented starter scene |
27
+ | `reframe motion <mp4>` | calibrated motion profile of a rendered clip |
28
+ | `reframe guide [--regen]` | the scene-authoring guide / regeneration contract — **feed this to your AI** |
29
+
30
+ (Installed as both `reframe` and `reframe-video`; with npx use `npx reframe-video <cmd>`.)
31
+
32
+ ## Writing a scene
33
+
34
+ ```ts
35
+ import { scene, text, seq, to, wait } from "@reframe/core"; // or "reframe-video"
36
+
37
+ export default scene({
38
+ id: "hello",
39
+ size: { width: 1920, height: 1080 },
40
+ fps: 30,
41
+ background: "#101014",
42
+ nodes: [
43
+ text({ id: "title", x: 960, y: 540, anchor: "center",
44
+ content: "Hello", fontFamily: "Inter", fontSize: 120, fontWeight: 800, fill: "#FFF" }),
45
+ ],
46
+ states: {
47
+ hidden: { title: { opacity: 0, y: 580 } },
48
+ shown: { title: { opacity: 1, y: 540 } },
49
+ },
50
+ initial: "hidden",
51
+ timeline: seq(
52
+ to("shown", { duration: 0.6, ease: "easeOutCubic", label: "enter" }),
53
+ wait(2, "hold"),
54
+ ),
55
+ });
56
+ ```
57
+
58
+ The renderer resolves `@reframe/core` itself — a scene file needs no
59
+ package.json next to it. For editor IntelliSense, `npm i -D reframe-video`
60
+ and import from `"reframe-video"` (same API, both specifiers work).
61
+
62
+ Audio is label-anchored (`audio: { cues: [{ at: "enter", sfx: "whoosh" }] }`)
63
+ so sound design follows retiming and regeneration. Full syntax:
64
+ `npx reframe-video guide`.
65
+
66
+ ## Why this instead of generating Remotion/HTML?
67
+
68
+ One-shot generation quality is a wash (we measured it). The difference is the
69
+ second turn: reframe's output is an addressable document, so "tweak just the
70
+ timing", "redesign it but keep my edits", and "render 50 personalized
71
+ versions" are operations, not re-prompt-and-hope. Receipts, benchmarks, and
72
+ the full story: https://github.com/kiyeonjeon21/reframe
73
+
74
+ ## Requirements
75
+
76
+ Node ≥ 20, ffmpeg on PATH, Playwright chromium (one-time
77
+ `npx playwright install chromium`). macOS/Linux.
Binary file
Binary file
Binary file
@@ -0,0 +1,12 @@
1
+ # Vendored sound effects — all CC0 1.0 (public domain)
2
+
3
+ Verified against each asset page's license field on 2026-06-11.
4
+
5
+ | files | source | author | license |
6
+ |---|---|---|---|
7
+ | keypress-001/004/007/010/014.wav | [Keyboard Soundpack #1](https://opengameart.org/content/keyboard-soundpack-1-typing-and-single-keystrokes) (Cherry KC 1000 recordings) | unicaegames | CC0 |
8
+ | click_002/003/004.ogg, confirmation_001.ogg | [Interface Sounds](https://opengameart.org/content/interface-sounds) | Kenney (kenney.nl) | CC0 |
9
+
10
+ CC0 requires no attribution; this file records provenance anyway.
11
+ Files placed here named after a procedural SFX (e.g. `whoosh.wav`) override
12
+ the synthesizer for that name.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,344 @@
1
+ // ../../benchmark/harness/motion/analyze.ts
2
+ import { writeFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { readFileSync } from "node:fs";
5
+
6
+ // ../../benchmark/harness/motion/blockflow.ts
7
+ var DEFAULT_OPTIONS = {
8
+ scale: 4,
9
+ // 480x270 analysis of 1920x1080
10
+ blockSize: 16,
11
+ searchRadius: 12,
12
+ changedThreshold: 2,
13
+ blockActivityThreshold: 1.5,
14
+ movingThreshold: 0.3
15
+ };
16
+ function blockSad(prev, next, width, bx, by, dx, dy, size) {
17
+ let sad = 0;
18
+ for (let y = 0; y < size; y++) {
19
+ let nextRow = (by + y) * width + bx;
20
+ let prevRow = (by + y + dy) * width + bx + dx;
21
+ for (let x = 0; x < size; x++) {
22
+ sad += Math.abs(next[nextRow + x] - prev[prevRow + x]);
23
+ }
24
+ }
25
+ return sad;
26
+ }
27
+ function parabola(left, best, right) {
28
+ const denom = left - 2 * best + right;
29
+ if (denom <= 0) return 0;
30
+ return Math.max(-0.5, Math.min(0.5, (left - right) / (2 * denom)));
31
+ }
32
+ function analyzePair(prev, next, opts) {
33
+ const { width, height, scale, blockSize, searchRadius: R, changedThreshold } = opts;
34
+ let diffSum = 0;
35
+ let changed = 0;
36
+ const n = width * height;
37
+ for (let i = 0; i < n; i++) {
38
+ const d = Math.abs(next[i] - prev[i]);
39
+ diffSum += d;
40
+ if (d > changedThreshold) changed++;
41
+ }
42
+ const diffMean = diffSum / n;
43
+ const changedFraction = changed / n;
44
+ const speeds = [];
45
+ let movingCount = 0;
46
+ let saturatedCount = 0;
47
+ let residualSum = 0;
48
+ let zeroSadSum = 0;
49
+ let bestSadSum = 0;
50
+ let activeBlocks = 0;
51
+ const pxPerBlock = blockSize * blockSize;
52
+ for (let by = R; by + blockSize + R <= height; by += blockSize) {
53
+ for (let bx = R; bx + blockSize + R <= width; bx += blockSize) {
54
+ const zeroSad = blockSad(prev, next, width, bx, by, 0, 0, blockSize);
55
+ if (zeroSad / pxPerBlock < opts.blockActivityThreshold) continue;
56
+ activeBlocks++;
57
+ const PENALTY = 2;
58
+ let best = zeroSad;
59
+ let bestScore = zeroSad;
60
+ let bestDx = 0;
61
+ let bestDy = 0;
62
+ for (let dy = -R; dy <= R; dy++) {
63
+ for (let dx = -R; dx <= R; dx++) {
64
+ if (dx === 0 && dy === 0) continue;
65
+ const sad = blockSad(prev, next, width, bx, by, dx, dy, blockSize);
66
+ const score = sad + PENALTY * (Math.abs(dx) + Math.abs(dy));
67
+ if (score < bestScore) {
68
+ bestScore = score;
69
+ best = sad;
70
+ bestDx = dx;
71
+ bestDy = dy;
72
+ }
73
+ }
74
+ }
75
+ let fx = bestDx;
76
+ let fy = bestDy;
77
+ if (Math.abs(bestDx) < R) {
78
+ fx += parabola(
79
+ blockSad(prev, next, width, bx, by, bestDx - 1, bestDy, blockSize),
80
+ best,
81
+ blockSad(prev, next, width, bx, by, bestDx + 1, bestDy, blockSize)
82
+ );
83
+ }
84
+ if (Math.abs(bestDy) < R) {
85
+ fy += parabola(
86
+ blockSad(prev, next, width, bx, by, bestDx, bestDy - 1, blockSize),
87
+ best,
88
+ blockSad(prev, next, width, bx, by, bestDx, bestDy + 1, blockSize)
89
+ );
90
+ }
91
+ const speed = Math.hypot(fx, fy);
92
+ residualSum += best / pxPerBlock;
93
+ zeroSadSum += zeroSad;
94
+ bestSadSum += best;
95
+ if (Math.abs(bestDx) >= R || Math.abs(bestDy) >= R) saturatedCount++;
96
+ if ((bestDx !== 0 || bestDy !== 0) && speed > opts.movingThreshold && best < 0.6 * zeroSad) {
97
+ movingCount++;
98
+ speeds.push(speed * scale);
99
+ }
100
+ }
101
+ }
102
+ speeds.sort((a, b) => a - b);
103
+ const matchResidual = activeBlocks > 0 ? residualSum / activeBlocks : 0;
104
+ return {
105
+ blockSpeedMean: speeds.length ? speeds.reduce((a, b) => a + b, 0) / speeds.length : 0,
106
+ blockSpeedP95: speeds.length ? speeds[Math.min(speeds.length - 1, Math.floor(speeds.length * 0.95))] : 0,
107
+ movingFraction: activeBlocks > 0 ? movingCount / activeBlocks : 0,
108
+ diffMean,
109
+ changedFraction,
110
+ matchResidual,
111
+ nonGeometricRatio: zeroSadSum > 0 ? Math.min(1, bestSadSum / zeroSadSum) : 0,
112
+ saturatedFraction: activeBlocks > 0 ? saturatedCount / activeBlocks : 0,
113
+ activeBlocks
114
+ };
115
+ }
116
+
117
+ // ../../benchmark/harness/motion/frames.ts
118
+ import { spawn } from "node:child_process";
119
+ import { stat } from "node:fs/promises";
120
+ import { join } from "node:path";
121
+ async function resolveSource(path) {
122
+ const s = await stat(path);
123
+ if (s.isDirectory()) return { kind: "png", input: join(path, "%05d.png") };
124
+ return { kind: "mp4", input: path };
125
+ }
126
+ async function* frameStream(source, opts) {
127
+ const frameBytes = opts.width * opts.height;
128
+ const args = [
129
+ "-loglevel",
130
+ "error",
131
+ ...source.kind === "png" ? ["-framerate", "30"] : [],
132
+ "-i",
133
+ source.input,
134
+ "-f",
135
+ "rawvideo",
136
+ "-pix_fmt",
137
+ "gray",
138
+ "-s",
139
+ `${opts.width}x${opts.height}`,
140
+ "-"
141
+ ];
142
+ const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
143
+ let stderr = "";
144
+ proc.stderr.on("data", (d) => stderr += d.toString());
145
+ let pending = Buffer.alloc(0);
146
+ for await (const chunk of proc.stdout) {
147
+ pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]);
148
+ while (pending.length >= frameBytes) {
149
+ yield new Uint8Array(pending.subarray(0, frameBytes));
150
+ pending = pending.subarray(frameBytes);
151
+ }
152
+ }
153
+ const code = await new Promise((res) => proc.on("close", (c) => res(c ?? 1)));
154
+ if (code !== 0) throw new Error(`ffmpeg exited ${code}: ${stderr.slice(-1e3)}`);
155
+ if (pending.length !== 0) throw new Error(`trailing partial frame (${pending.length} bytes)`);
156
+ }
157
+
158
+ // ../../benchmark/harness/motion/analyze.ts
159
+ var ANALYSIS_WIDTH = 480;
160
+ var ANALYSIS_HEIGHT = 270;
161
+ var mean = (xs) => xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
162
+ function rollingMedian(xs, i, w) {
163
+ const half = Math.floor(w / 2);
164
+ const window = [];
165
+ for (let j = Math.max(0, i - half); j <= Math.min(xs.length - 1, i + half); j++) {
166
+ if (j !== i) window.push(xs[j]);
167
+ }
168
+ window.sort((a, b) => a - b);
169
+ return window.length ? window[Math.floor(window.length / 2)] : 0;
170
+ }
171
+ function spearman(xs, ys) {
172
+ const n = xs.length;
173
+ if (n < 4) return null;
174
+ const rank = (vs) => {
175
+ const sorted = vs.map((v, i) => [v, i]).sort((a, b) => a[0] - b[0]);
176
+ const ranks = new Array(n);
177
+ sorted.forEach(([, originalIndex], r) => ranks[originalIndex] = r);
178
+ return ranks;
179
+ };
180
+ const rx = rank(xs);
181
+ const ry = rank(ys);
182
+ const mx = mean(rx);
183
+ const my = mean(ry);
184
+ let cov = 0;
185
+ let vx = 0;
186
+ let vy = 0;
187
+ for (let i = 0; i < n; i++) {
188
+ cov += (rx[i] - mx) * (ry[i] - my);
189
+ vx += (rx[i] - mx) ** 2;
190
+ vy += (ry[i] - my) ** 2;
191
+ }
192
+ return vx > 0 && vy > 0 ? cov / Math.sqrt(vx * vy) : null;
193
+ }
194
+ function dominantPeriodicity(xs, fps) {
195
+ const n = xs.length;
196
+ if (n < 24) return null;
197
+ const m = mean(xs);
198
+ const centered = xs.map((x) => x - m);
199
+ const var0 = mean(centered.map((x) => x * x));
200
+ if (var0 < 1e-6) return null;
201
+ const maxLag = Math.floor(n / 2);
202
+ const corr = [];
203
+ for (let lag = 0; lag <= maxLag; lag++) {
204
+ let c = 0;
205
+ for (let i = 0; i + lag < n; i++) c += centered[i] * centered[i + lag];
206
+ corr.push(c / ((n - lag) * var0));
207
+ }
208
+ let bestLag = 0;
209
+ let bestCorr = 0;
210
+ for (let lag = 4; lag < maxLag; lag++) {
211
+ const isLocalMax = corr[lag] > corr[lag - 1] && corr[lag] >= corr[lag + 1];
212
+ if (isLocalMax && corr[lag] > bestCorr) {
213
+ bestCorr = corr[lag];
214
+ bestLag = lag;
215
+ }
216
+ }
217
+ return bestCorr > 0.3 && bestLag > 0 ? fps / bestLag : null;
218
+ }
219
+ function classifyEasing(speeds, nonGeometricRatios, saturatedFractions) {
220
+ const reliable = speeds.length >= 6 && mean(nonGeometricRatios) < 0.5 && saturatedFractions.filter((s) => s > 0.3).length / saturatedFractions.length < 0.2 && mean(speeds) > 0.5;
221
+ if (!reliable) {
222
+ return { class: "unreliable", thirdsRatio: null, spearman: null, reliable: false };
223
+ }
224
+ const third = Math.max(1, Math.floor(speeds.length / 3));
225
+ const first = mean(speeds.slice(0, third));
226
+ const last = mean(speeds.slice(-third));
227
+ const R = last > 0.01 ? first / last : Infinity;
228
+ const rho = spearman(
229
+ speeds.map((_, i) => i),
230
+ speeds
231
+ );
232
+ let cls = "other";
233
+ if (R > 1.8 && rho !== null && rho < -0.5) cls = "decelerating";
234
+ else if (R < 0.55 && rho !== null && rho > 0.5) cls = "accelerating";
235
+ else if (R >= 0.7 && R <= 1.4 && rho !== null && Math.abs(rho) < 0.4) cls = "linear";
236
+ return { class: cls, thirdsRatio: Number.isFinite(R) ? R : null, spearman: rho, reliable: true };
237
+ }
238
+ async function analyzeMotion(inputPath, opts = {}) {
239
+ const fps = opts.fps ?? 30;
240
+ const blockOpts = {
241
+ ...DEFAULT_OPTIONS,
242
+ width: ANALYSIS_WIDTH,
243
+ height: ANALYSIS_HEIGHT,
244
+ ...opts.changedThreshold !== void 0 && { changedThreshold: opts.changedThreshold }
245
+ };
246
+ const source = await resolveSource(resolve(inputPath));
247
+ const keys = [
248
+ "blockSpeedMean",
249
+ "blockSpeedP95",
250
+ "movingFraction",
251
+ "diffMean",
252
+ "changedFraction",
253
+ "matchResidual",
254
+ "nonGeometricRatio",
255
+ "saturatedFraction",
256
+ "activeBlocks"
257
+ ];
258
+ const series = Object.fromEntries([...keys.map((k) => [k, []]), ["t", []]]);
259
+ let prev = null;
260
+ let pair = 0;
261
+ for await (const frame of frameStream(source, blockOpts)) {
262
+ if (prev) {
263
+ const stats = analyzePair(prev, frame, blockOpts);
264
+ for (const k of keys) series[k].push(stats[k]);
265
+ series.t.push((pair + 0.5) / fps);
266
+ pair++;
267
+ }
268
+ prev = frame;
269
+ }
270
+ const d = series.diffMean;
271
+ const staticFlags = series.changedFraction.map((c) => c < 5e-3);
272
+ let longestRun = 0;
273
+ let run = 0;
274
+ for (const isStatic of staticFlags) {
275
+ run = isStatic ? run + 1 : 0;
276
+ longestRun = Math.max(longestRun, run);
277
+ }
278
+ const spikes = [];
279
+ for (let i = 0; i < d.length; i++) {
280
+ const med = rollingMedian(d, i, 9);
281
+ const neighbors = Math.max(i > 0 ? d[i - 1] : 0, i + 1 < d.length ? d[i + 1] : 0);
282
+ if (d[i] > Math.max(4 * med, 1) && series.changedFraction[i] > 0.01 && neighbors < d[i] / 2) {
283
+ spikes.push({ pair: i, t: series.t[i], ratio: med > 0 ? d[i] / med : Infinity });
284
+ }
285
+ }
286
+ const segments = (opts.segments ?? []).map((seg) => {
287
+ const idx = series.t.map((t, i) => t >= seg.t0 && t <= seg.t1 ? i : -1).filter((i) => i >= 0);
288
+ return {
289
+ ...seg,
290
+ easing: classifyEasing(
291
+ idx.map((i) => series.blockSpeedMean[i]),
292
+ idx.map((i) => series.nonGeometricRatio[i]),
293
+ idx.map((i) => series.saturatedFraction[i])
294
+ )
295
+ };
296
+ });
297
+ return {
298
+ params: { ...blockOpts, analysisWidth: ANALYSIS_WIDTH, analysisHeight: ANALYSIS_HEIGHT, source: source.kind },
299
+ fps,
300
+ framePairs: series.t.length,
301
+ series,
302
+ summary: {
303
+ meanSpeed: mean(series.blockSpeedMean.filter((s) => s > 0)),
304
+ peakSpeed: Math.max(0, ...series.blockSpeedP95),
305
+ meanDiff: mean(d),
306
+ staticFraction: staticFlags.filter(Boolean).length / Math.max(1, staticFlags.length),
307
+ longestStaticRunSec: longestRun / fps,
308
+ allStatic: staticFlags.every(Boolean),
309
+ spikes,
310
+ saturatedPairs: series.saturatedFraction.map((s, i) => s > 0.3 ? i : -1).filter((i) => i >= 0),
311
+ diffPeriodicityHz: dominantPeriodicity(d, fps)
312
+ },
313
+ segments
314
+ };
315
+ }
316
+ async function main() {
317
+ const [input, ...rest] = process.argv.slice(2);
318
+ if (!input) {
319
+ console.error("usage: tsx analyze.ts <mp4|framesDir> [-o out.json] [--fps N] [--segments seg.json] [--changed-threshold N]");
320
+ process.exit(2);
321
+ }
322
+ let out = null;
323
+ const opts = {};
324
+ for (let i = 0; i < rest.length; i++) {
325
+ if (rest[i] === "-o") out = rest[++i];
326
+ else if (rest[i] === "--fps") opts.fps = Number(rest[++i]);
327
+ else if (rest[i] === "--segments") opts.segments = JSON.parse(readFileSync(rest[++i], "utf8"));
328
+ else if (rest[i] === "--changed-threshold") opts.changedThreshold = Number(rest[++i]);
329
+ }
330
+ const profile = await analyzeMotion(input, opts);
331
+ const json = JSON.stringify(profile, null, 1);
332
+ if (out) await writeFile(out, json);
333
+ else console.log(json);
334
+ }
335
+ if (/analyze\.(ts|js)$/.test(process.argv[1] ?? "")) {
336
+ main().catch((err) => {
337
+ console.error(err);
338
+ process.exit(1);
339
+ });
340
+ }
341
+ export {
342
+ analyzeMotion,
343
+ classifyEasing
344
+ };