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.
- package/LICENSE +24 -0
- package/README.md +77 -0
- package/assets/fonts/inter-400.woff2 +0 -0
- package/assets/fonts/inter-700.woff2 +0 -0
- package/assets/fonts/inter-800.woff2 +0 -0
- package/assets/sfx/LICENSE.md +12 -0
- package/assets/sfx/click_002.ogg +0 -0
- package/assets/sfx/click_003.ogg +0 -0
- package/assets/sfx/click_004.ogg +0 -0
- package/assets/sfx/confirmation_001.ogg +0 -0
- package/assets/sfx/keypress-001.wav +0 -0
- package/assets/sfx/keypress-004.wav +0 -0
- package/assets/sfx/keypress-007.wav +0 -0
- package/assets/sfx/keypress-010.wav +0 -0
- package/assets/sfx/keypress-014.wav +0 -0
- package/dist/analyze.js +344 -0
- package/dist/bin.js +1677 -0
- package/dist/browserEntry.js +532 -0
- package/dist/cli.js +1205 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +889 -0
- package/dist/renderer-canvas.js +89 -0
- package/dist/types/audio.d.ts +53 -0
- package/dist/types/behaviors.d.ts +7 -0
- package/dist/types/compile.d.ts +38 -0
- package/dist/types/compose.d.ts +64 -0
- package/dist/types/dsl.d.ts +66 -0
- package/dist/types/evaluate.d.ts +59 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/interpolate.d.ts +12 -0
- package/dist/types/ir.d.ts +213 -0
- package/dist/types/validate.d.ts +12 -0
- package/guides/edsl-guide.md +202 -0
- package/guides/regen-contract.md +18 -0
- package/package.json +55 -0
- package/preview/index.html +60 -0
- package/preview/src/main.ts +162 -0
- package/preview/src/panel.ts +347 -0
- package/preview/src/store.ts +220 -0
- package/preview/src/virtual.d.ts +4 -0
- 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
|
package/dist/analyze.js
ADDED
|
@@ -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
|
+
};
|