@open-take/compositor 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 +21 -0
- package/dist/chunk-Cl8Af3a2.js +11 -0
- package/dist/index.d.ts +482 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +973 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/src/ffmpeg.ts +60 -0
- package/src/index.ts +21 -0
- package/src/math.ts +459 -0
- package/src/plan.ts +157 -0
- package/src/presets.ts +147 -0
- package/src/render.ts +237 -0
- package/src/scene/project.ts +19 -0
- package/src/scene/scene.tsx +231 -0
- package/src/types.ts +349 -0
- package/src/validate.ts +245 -0
package/src/math.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// Pure, isomorphic math — imported by both the node-side planner and the
|
|
2
|
+
// revideo scene (compiled fresh by vite at render time). No node/browser
|
|
3
|
+
// APIs in here.
|
|
4
|
+
|
|
5
|
+
import type { BBox, Pt, TakeComposition } from "./types";
|
|
6
|
+
|
|
7
|
+
// smootherstep 6t^5-15t^4+10t^3
|
|
8
|
+
export function smoother(t: number): number {
|
|
9
|
+
t = Math.max(0, Math.min(1, t));
|
|
10
|
+
return t * t * t * (t * (t * 6 - 15) + 10);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Cubic-bezier easing y(x) with endpoints (0,0),(1,1) and control points
|
|
14
|
+
// (x1,y1),(x2,y2) — the same model CSS easing uses. Solves x(s)=x by
|
|
15
|
+
// bisection (cheap, monotone) then returns y(s). Lets the travel cursor use a
|
|
16
|
+
// decelerate-biased curve (long, gentle settle) instead of symmetric easing.
|
|
17
|
+
export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (x: number) => number {
|
|
18
|
+
const bx = (s: number) => {
|
|
19
|
+
const u = 1 - s;
|
|
20
|
+
return 3 * u * u * s * x1 + 3 * u * s * s * x2 + s * s * s;
|
|
21
|
+
};
|
|
22
|
+
const by = (s: number) => {
|
|
23
|
+
const u = 1 - s;
|
|
24
|
+
return 3 * u * u * s * y1 + 3 * u * s * s * y2 + s * s * s;
|
|
25
|
+
};
|
|
26
|
+
return (x: number) => {
|
|
27
|
+
if (x <= 0) return 0;
|
|
28
|
+
if (x >= 1) return 1;
|
|
29
|
+
let lo = 0,
|
|
30
|
+
hi = 1,
|
|
31
|
+
s = x;
|
|
32
|
+
for (let i = 0; i < 24; i++) {
|
|
33
|
+
const xt = bx(s);
|
|
34
|
+
if (Math.abs(xt - x) < 1e-4) break;
|
|
35
|
+
if (xt < x) lo = s;
|
|
36
|
+
else hi = s;
|
|
37
|
+
s = (lo + hi) / 2;
|
|
38
|
+
}
|
|
39
|
+
return by(s);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Spring-shaped easing y(p) over [0,1]: the unit step response of a damped
|
|
44
|
+
// spring, time-normalised so it rises (and, for bounce>0, slightly overshoots)
|
|
45
|
+
// then settles within the segment. `bounce` ∈ [0, ~0.6): 0 = critically damped
|
|
46
|
+
// (no overshoot — a soft, physical ease-out); higher = more overshoot/snap. The
|
|
47
|
+
// "silky" landing comes from a touch of overshoot (a near-critically-damped
|
|
48
|
+
// zoom spring, bounce ~0.06; a snappier cursor ~0.13).
|
|
49
|
+
//
|
|
50
|
+
// Under time-normalisation the curve depends ONLY on the damping ratio ζ=1−bounce
|
|
51
|
+
// (the natural frequency cancels), so a single `bounce` knob is the whole shape —
|
|
52
|
+
// the segment DURATION stays whatever the keyframes say (zoomInMs/zoomOutMs).
|
|
53
|
+
export function springEase(bounce: number): (p: number) => number {
|
|
54
|
+
const zeta = Math.max(0.4, Math.min(1, 1 - bounce));
|
|
55
|
+
const Ts = -Math.log(1e-3) / zeta; // settling time to the 0.1% band (ω0 = 1)
|
|
56
|
+
return (p: number) => {
|
|
57
|
+
if (p <= 0) return 0;
|
|
58
|
+
if (p >= 1) return 1;
|
|
59
|
+
const t = p * Ts;
|
|
60
|
+
if (zeta >= 1) return 1 - Math.exp(-t) * (1 + t); // critically damped
|
|
61
|
+
const wd = Math.sqrt(1 - zeta * zeta); // damped natural frequency (ω0 = 1)
|
|
62
|
+
return 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t));
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The stage (zoom + pan) easing, selected from the cursor config. The single
|
|
67
|
+
// source the revideo scene (scene.tsx) consumes — any other renderer must use
|
|
68
|
+
// it identically so renderers can never drift. Precedence:
|
|
69
|
+
// spring (zoomSpring) → cubic-bezier (zoomEase) → smootherstep.
|
|
70
|
+
export function stageEasing(cursor: TakeComposition["cursor"]): (u: number) => number {
|
|
71
|
+
if (cursor.zoomSpring != null) return springEase(cursor.zoomSpring);
|
|
72
|
+
if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
|
|
73
|
+
return smoother;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Easing for the CENTRE pan. Deliberately NEVER the spring: an overshooting
|
|
77
|
+
// spring on a pan makes the camera wobble past its target and back (and re-
|
|
78
|
+
// accelerates the zoom-out recenter = a stutter). Overshoot is only wanted on
|
|
79
|
+
// the scale zoom-IN, so centre stays on the monotone bezier/smoother. For a
|
|
80
|
+
// non-spring composition this equals stageEasing, so the default is unchanged.
|
|
81
|
+
export function panEasing(cursor: TakeComposition["cursor"]): (u: number) => number {
|
|
82
|
+
if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
|
|
83
|
+
return smoother;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Backdrop gradient endpoints in TOP-LEFT origin (0..oW, 0..oH), shared by the
|
|
87
|
+
// preview canvas and the revideo scene so the backdrop can't drift. `angle` is
|
|
88
|
+
// CSS-like degrees (0 = upward); absent ⇒ the legacy (0,0)→(oW,oH) diagonal, so
|
|
89
|
+
// existing compositions render pixel-identically. (Scene maps to centred coords
|
|
90
|
+
// by subtracting oW/2, oH/2.)
|
|
91
|
+
export function gradientEndpoints(
|
|
92
|
+
angleDeg: number | undefined,
|
|
93
|
+
oW: number,
|
|
94
|
+
oH: number,
|
|
95
|
+
): { x0: number; y0: number; x1: number; y1: number } {
|
|
96
|
+
if (angleDeg == null) return { x0: 0, y0: 0, x1: oW, y1: oH };
|
|
97
|
+
const th = (angleDeg * Math.PI) / 180;
|
|
98
|
+
const dx = Math.sin(th);
|
|
99
|
+
const dy = -Math.cos(th);
|
|
100
|
+
const L = Math.abs(oW * dx) + Math.abs(oH * dy); // CSS gradient line length
|
|
101
|
+
const cx = oW / 2;
|
|
102
|
+
const cy = oH / 2;
|
|
103
|
+
return {
|
|
104
|
+
x0: cx - (dx * L) / 2,
|
|
105
|
+
y0: cy - (dy * L) / 2,
|
|
106
|
+
x1: cx + (dx * L) / 2,
|
|
107
|
+
y1: cy + (dy * L) / 2,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type KF<T> = [number, T];
|
|
112
|
+
|
|
113
|
+
// `easeDown` (optional) eases segments where the value FALLS (v1<v0) differently
|
|
114
|
+
// from rising ones — used so the scale springs on zoom-IN but settles smoothly
|
|
115
|
+
// (bezier, no overshoot/abruptness) on zoom-OUT. Omit it ⇒ one easing for all
|
|
116
|
+
// (the default behaviour, unchanged).
|
|
117
|
+
export function keyvalN(
|
|
118
|
+
t: number,
|
|
119
|
+
kfs: KF<number>[],
|
|
120
|
+
ease: (u: number) => number = smoother,
|
|
121
|
+
easeDown?: (u: number) => number,
|
|
122
|
+
): number {
|
|
123
|
+
if (t <= kfs[0]![0]) return kfs[0]![1];
|
|
124
|
+
if (t >= kfs[kfs.length - 1]![0]) return kfs[kfs.length - 1]![1];
|
|
125
|
+
for (let i = 0; i < kfs.length - 1; i++) {
|
|
126
|
+
const [t0, v0] = kfs[i]!;
|
|
127
|
+
const [t1, v1] = kfs[i + 1]!;
|
|
128
|
+
if (t0 <= t && t <= t1) {
|
|
129
|
+
const e = easeDown && v1 < v0 ? easeDown : ease;
|
|
130
|
+
return v0 + (v1 - v0) * e((t - t0) / (t1 - t0));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return kfs[kfs.length - 1]![1];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function keyvalP(t: number, kfs: KF<Pt>[], ease: (u: number) => number = smoother): Pt {
|
|
137
|
+
if (t <= kfs[0]![0]) return kfs[0]![1];
|
|
138
|
+
if (t >= kfs[kfs.length - 1]![0]) return kfs[kfs.length - 1]![1];
|
|
139
|
+
for (let i = 0; i < kfs.length - 1; i++) {
|
|
140
|
+
const [t0, v0] = kfs[i]!;
|
|
141
|
+
const [t1, v1] = kfs[i + 1]!;
|
|
142
|
+
if (t0 <= t && t <= t1) {
|
|
143
|
+
const p = ease((t - t0) / (t1 - t0));
|
|
144
|
+
return { x: v0.x + (v1.x - v0.x) * p, y: v0.y + (v1.y - v0.y) * p };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return kfs[kfs.length - 1]![1];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- bbox-fit zoom (Finding 1) -----------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Scale (absolute, video-px → output-px) that fits `bbox` into `fillFrac`
|
|
154
|
+
* of the output frame, capped at `maxScale` and floored at `restScale`.
|
|
155
|
+
* Wide/long elements naturally get a gentler scale because the limiting
|
|
156
|
+
* dimension dominates min().
|
|
157
|
+
*/
|
|
158
|
+
export function bboxFitScale(
|
|
159
|
+
bbox: BBox,
|
|
160
|
+
outW: number,
|
|
161
|
+
outH: number,
|
|
162
|
+
fillFrac: number,
|
|
163
|
+
maxScale: number,
|
|
164
|
+
restScale: number,
|
|
165
|
+
): number {
|
|
166
|
+
const fitW = (outW * fillFrac) / Math.max(1, bbox.w);
|
|
167
|
+
const fitH = (outH * fillFrac) / Math.max(1, bbox.h);
|
|
168
|
+
return Math.min(maxScale, Math.max(restScale, Math.min(fitW, fitH)));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Stage scale at rest: video inset into the frame (leaves backdrop margin). */
|
|
172
|
+
export function restStageScale(
|
|
173
|
+
videoW: number,
|
|
174
|
+
videoH: number,
|
|
175
|
+
outW: number,
|
|
176
|
+
outH: number,
|
|
177
|
+
insetFrac: number,
|
|
178
|
+
): number {
|
|
179
|
+
return insetFrac * Math.min(outW / videoW, outH / videoH);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Clamp a desired video-px center so the scaled video still covers the output
|
|
184
|
+
* frame (the viewport crop stays inside the source video) when zoomed in. When
|
|
185
|
+
* the content does not cover an axis (zoomed out / inset), centre that axis.
|
|
186
|
+
*
|
|
187
|
+
* In the composition-camera model the whole composition
|
|
188
|
+
* (backdrop + inset screen) is zoomed by one camera; this keeps the camera crop
|
|
189
|
+
* within the screen so a zoom fills the frame (no backdrop margin) without ever
|
|
190
|
+
* panning past the recording's edge.
|
|
191
|
+
*/
|
|
192
|
+
export function clampCenter(
|
|
193
|
+
center: Pt,
|
|
194
|
+
scale: number,
|
|
195
|
+
videoW: number,
|
|
196
|
+
videoH: number,
|
|
197
|
+
outW: number,
|
|
198
|
+
outH: number,
|
|
199
|
+
): Pt {
|
|
200
|
+
const halfW = outW / (2 * scale);
|
|
201
|
+
const halfH = outH / (2 * scale);
|
|
202
|
+
const cx =
|
|
203
|
+
videoW * scale >= outW ? Math.min(Math.max(center.x, halfW), videoW - halfW) : videoW / 2;
|
|
204
|
+
const cy =
|
|
205
|
+
videoH * scale >= outH ? Math.min(Math.max(center.y, halfH), videoH - halfH) : videoH / 2;
|
|
206
|
+
return { x: cx, y: cy };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- stage (zoom/pan) keyframes from the composition -------------------
|
|
210
|
+
|
|
211
|
+
export type StageKeyframes = {
|
|
212
|
+
z: KF<number>[]; // absolute stage scale over time (seconds)
|
|
213
|
+
c: KF<Pt>[]; // raw video-px center over time (clamp at eval)
|
|
214
|
+
T: number; // total duration (s)
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export function buildStageKeyframes(comp: TakeComposition): StageKeyframes {
|
|
218
|
+
const { videoWidth: vW, videoHeight: vH } = comp.source;
|
|
219
|
+
const { width: oW, height: oH } = comp.output;
|
|
220
|
+
const rest = restStageScale(vW, vH, oW, oH, comp.framing.insetFrac);
|
|
221
|
+
const restC: Pt = { x: vW / 2, y: vH / 2 };
|
|
222
|
+
const HOLD = comp.cursor.holdMs / 1000;
|
|
223
|
+
const ZOUT = comp.cursor.zoomOutMs / 1000;
|
|
224
|
+
|
|
225
|
+
// Framing anchors over time. A zoom-enabled beat ramps to its target; a
|
|
226
|
+
// scroll (content pans) and a zoom-less press (Escape/Enter whose effect is
|
|
227
|
+
// global) ramp back to REST so the frame is full-view through them — without
|
|
228
|
+
// these, a prior zoom would persist across the scroll/keypress. Other
|
|
229
|
+
// disabled beats (a "never" click) keep holding the prior framing, unchanged.
|
|
230
|
+
type Anchor = {
|
|
231
|
+
tMs: number;
|
|
232
|
+
durationMs: number;
|
|
233
|
+
inAtMs: number;
|
|
234
|
+
scale: number;
|
|
235
|
+
center: Pt;
|
|
236
|
+
glide?: Pt;
|
|
237
|
+
};
|
|
238
|
+
const anchors: Anchor[] = [];
|
|
239
|
+
for (const e of comp.events) {
|
|
240
|
+
const base = { tMs: e.tMs, durationMs: e.durationMs ?? 0, inAtMs: e.zoom.inAtMs };
|
|
241
|
+
if (e.kind === "scroll" || (e.kind === "press" && !e.zoom.enabled)) {
|
|
242
|
+
anchors.push({ ...base, scale: rest, center: restC });
|
|
243
|
+
} else if (e.zoom.enabled) {
|
|
244
|
+
anchors.push({ ...base, scale: e.zoom.scale, center: e.zoom.center, glide: e.zoom.glide });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Scale and centre are evaluated independently (keyvalN / keyvalP), so their
|
|
249
|
+
// keyframe TIMES need not line up — `pushC` adds a centre-only keyframe.
|
|
250
|
+
const zf: { t: number; s: number }[] = [{ t: 0, s: rest }];
|
|
251
|
+
const cf: { t: number; c: Pt }[] = [{ t: 0, c: restC }];
|
|
252
|
+
const push = (t: number, s: number, c: Pt) => {
|
|
253
|
+
zf.push({ t: Math.max(t, zf[zf.length - 1]!.t + 1e-3), s });
|
|
254
|
+
cf.push({ t: Math.max(t, cf[cf.length - 1]!.t + 1e-3), c });
|
|
255
|
+
};
|
|
256
|
+
const pushC = (t: number, c: Pt) => {
|
|
257
|
+
cf.push({ t: Math.max(t, cf[cf.length - 1]!.t + 1e-3), c });
|
|
258
|
+
};
|
|
259
|
+
// Invert the zoom-OUT easing to time the centre recenter (the centre reaches
|
|
260
|
+
// rest exactly when the scale re-covers the frame — else the tightening centre-
|
|
261
|
+
// clamp catches the still-panning centre and re-accelerates it = the "two-stage"
|
|
262
|
+
// zoom-out stutter, commit b005cbe). The zoom-OUT scale uses panEasing (the
|
|
263
|
+
// monotone bezier — scale springs only on zoom-IN, settles smoothly on the way
|
|
264
|
+
// out), so invert THAT, by bisection.
|
|
265
|
+
const ze = panEasing(comp.cursor);
|
|
266
|
+
const invEase = (target: number) => {
|
|
267
|
+
let lo = 0;
|
|
268
|
+
let hi = 1;
|
|
269
|
+
for (let i = 0; i < 30; i++) {
|
|
270
|
+
const m = (lo + hi) / 2;
|
|
271
|
+
if (ze(m) < target) lo = m;
|
|
272
|
+
else hi = m;
|
|
273
|
+
}
|
|
274
|
+
return (lo + hi) / 2;
|
|
275
|
+
};
|
|
276
|
+
// Scale at which the video re-covers the frame on both axes; below it the
|
|
277
|
+
// centre is forced to video-centre (clampCenter), so a recenter must FINISH by
|
|
278
|
+
// here or the tightening clamp "catches" the still-panning centre.
|
|
279
|
+
const fillThreshold = Math.max(oW / vW, oH / vH);
|
|
280
|
+
|
|
281
|
+
let cur = { s: rest, c: restC };
|
|
282
|
+
anchors.forEach((e, i) => {
|
|
283
|
+
const rampStart = e.inAtMs / 1000;
|
|
284
|
+
const clickT = e.tMs / 1000;
|
|
285
|
+
// the action plays out (typing/drawing/scrolling) for durationMs after tMs
|
|
286
|
+
// — hold the target framing through it (a point click has duration 0).
|
|
287
|
+
const actionEnd = (e.tMs + e.durationMs) / 1000;
|
|
288
|
+
const next = anchors[i + 1];
|
|
289
|
+
const holdEndT = next ? next.inAtMs / 1000 : actionEnd + HOLD;
|
|
290
|
+
// glide: drift the held centre across the hold window (velocity px/s ·
|
|
291
|
+
// holdSeconds), so a held zoom slowly pans instead of sitting dead-static.
|
|
292
|
+
// (Eased with the stage easing like any centre segment; clampCenter keeps it
|
|
293
|
+
// in-bounds at read time. invEase below stays on the monotone bezier.)
|
|
294
|
+
let holdC = e.center;
|
|
295
|
+
if (e.glide && (e.glide.x !== 0 || e.glide.y !== 0)) {
|
|
296
|
+
const holdDur = Math.max(0, holdEndT - clickT);
|
|
297
|
+
holdC = { x: e.center.x + e.glide.x * holdDur, y: e.center.y + e.glide.y * holdDur };
|
|
298
|
+
}
|
|
299
|
+
push(rampStart, cur.s, cur.c); // hold current until ramp begins
|
|
300
|
+
// Zoom-OUT across the fill-threshold INTO a beat (e.g. a rest/full-view beat):
|
|
301
|
+
// land the centre on its target AT the crossing so the tightening centre-clamp
|
|
302
|
+
// doesn't catch the still-panning centre and re-accelerate it. This is the same
|
|
303
|
+
// two-stage stutter b005cbe killed for the FINAL zoom-out — here for the
|
|
304
|
+
// between-beats ones (which went through this ramp and never got the fix).
|
|
305
|
+
if (
|
|
306
|
+
cur.s > fillThreshold &&
|
|
307
|
+
e.scale < fillThreshold &&
|
|
308
|
+
fillThreshold > rest &&
|
|
309
|
+
clickT > rampStart &&
|
|
310
|
+
Math.hypot(e.center.x - cur.c.x, e.center.y - cur.c.y) > 1
|
|
311
|
+
) {
|
|
312
|
+
const uCross = invEase((fillThreshold - cur.s) / (e.scale - cur.s));
|
|
313
|
+
pushC(rampStart + uCross * (clickT - rampStart), e.center);
|
|
314
|
+
}
|
|
315
|
+
push(clickT, e.scale, e.center); // ramp to target by the action
|
|
316
|
+
cur = { s: e.scale, c: holdC };
|
|
317
|
+
if (next) {
|
|
318
|
+
// hold (or glide) the target until the next anchor begins ramping
|
|
319
|
+
push(holdEndT, cur.s, cur.c);
|
|
320
|
+
} else {
|
|
321
|
+
const holdEnd = holdEndT;
|
|
322
|
+
push(holdEnd, cur.s, cur.c);
|
|
323
|
+
// Final zoom-out. Land the CENTRE on rest at the fill-threshold crossing
|
|
324
|
+
// (the scale keeps its single smooth segment) so the centre clamp — which
|
|
325
|
+
// tightens to a point as the video re-covers the frame — never catches the
|
|
326
|
+
// still-panning centre and re-accelerates it (a two-stage stutter). Only
|
|
327
|
+
// when the zoom actually overfills AND sits off-centre.
|
|
328
|
+
const offset = Math.hypot(cur.c.x - restC.x, cur.c.y - restC.y) > 1;
|
|
329
|
+
if (offset && cur.s > fillThreshold && fillThreshold > rest) {
|
|
330
|
+
const uCross = invEase((fillThreshold - cur.s) / (rest - cur.s));
|
|
331
|
+
pushC(holdEnd + uCross * ZOUT, restC);
|
|
332
|
+
}
|
|
333
|
+
push(holdEnd + ZOUT, rest, restC); // zoom back out
|
|
334
|
+
cur = { s: rest, c: restC };
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const lastT = Math.max(zf[zf.length - 1]!.t, cf[cf.length - 1]!.t);
|
|
339
|
+
const T = Math.max(comp.durationMs / 1000, lastT) + 0.3;
|
|
340
|
+
push(T, rest, restC);
|
|
341
|
+
|
|
342
|
+
return { z: zf.map((f) => [f.t, f.s]), c: cf.map((f) => [f.t, f.c]), T };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- cursor path (eased, with gentle arc) ------------------------------
|
|
346
|
+
|
|
347
|
+
// A `drag` leg carries the polyline the cursor follows with the button held
|
|
348
|
+
// (no perpendicular arc — it traces the real stroke). A normal travel leg has
|
|
349
|
+
// no path and gets the gentle arc.
|
|
350
|
+
type Leg = {
|
|
351
|
+
t0: number;
|
|
352
|
+
t1: number;
|
|
353
|
+
a: Pt;
|
|
354
|
+
b: Pt;
|
|
355
|
+
drag?: boolean;
|
|
356
|
+
path?: Pt[];
|
|
357
|
+
ease?: "linear" | "smooth";
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
export function buildLegs(comp: TakeComposition): Leg[] {
|
|
361
|
+
const legs: Leg[] = [];
|
|
362
|
+
let cur: Pt = comp.start;
|
|
363
|
+
// Distance-aware travel: hold a roughly constant on-screen speed (premium
|
|
364
|
+
// feel) instead of a fixed duration (which makes short moves slow + long
|
|
365
|
+
// moves fast). Falls back to the fixed travelMs when speed is unset/0.
|
|
366
|
+
const { travelWidthsPerSec, travelMinMs, travelMaxMs, travelMs } = comp.cursor;
|
|
367
|
+
const speedPxPerMs = ((travelWidthsPerSec || 0) * comp.source.videoWidth) / 1000;
|
|
368
|
+
const travelDur = (a: Pt, b: Pt): number => {
|
|
369
|
+
if (speedPxPerMs <= 0) return travelMs / 1000;
|
|
370
|
+
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
|
371
|
+
return Math.min(travelMaxMs, Math.max(travelMinMs, dist / speedPxPerMs)) / 1000;
|
|
372
|
+
};
|
|
373
|
+
for (const e of comp.events) {
|
|
374
|
+
// scroll/press are not pointer-driven — the cursor holds where it was
|
|
375
|
+
// (the content pans / the keyboard acts). No travel leg; `cur` is untouched,
|
|
376
|
+
// so the between-legs parking logic keeps the cursor at its last anchor.
|
|
377
|
+
if (e.kind === "scroll" || e.kind === "press") continue;
|
|
378
|
+
const arrive = e.tMs / 1000;
|
|
379
|
+
// Start travelDur before arrival, but never before the previous leg ended
|
|
380
|
+
// (a long glide into a quick succession would otherwise overlap it — then
|
|
381
|
+
// the move just runs in the available window, a touch faster than target).
|
|
382
|
+
const prevEnd = legs.length ? legs[legs.length - 1]!.t1 : 0;
|
|
383
|
+
const t0 = Math.max(arrive - travelDur(cur, e.point), prevEnd, 0);
|
|
384
|
+
legs.push({ t0, t1: arrive, a: cur, b: e.point }); // travel to anchor
|
|
385
|
+
cur = e.point;
|
|
386
|
+
if (e.kind === "drag" && e.to) {
|
|
387
|
+
// Delay the stroke by dragLagMs so the cursor rides the captured ink front
|
|
388
|
+
// (the ink trails the pen by the capture-pipeline latency). The cursor
|
|
389
|
+
// holds at the start point during the gap [arrive, arrive+lag], then traces
|
|
390
|
+
// — matching when the ink actually appears on screen.
|
|
391
|
+
const lag = comp.cursor.dragLagMs / 1000;
|
|
392
|
+
const start = arrive + lag;
|
|
393
|
+
const dEnd = (e.tMs + (e.durationMs ?? 0)) / 1000 + lag;
|
|
394
|
+
const path = e.path && e.path.length >= 2 ? e.path : [e.point, e.to];
|
|
395
|
+
if (dEnd > start)
|
|
396
|
+
legs.push({ t0: start, t1: dEnd, a: e.point, b: e.to, drag: true, path, ease: e.ease });
|
|
397
|
+
cur = e.to;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return legs;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Point a fraction `u` (0..1) along a polyline, parameterised by arc length. */
|
|
404
|
+
function alongPath(path: Pt[], u: number): Pt {
|
|
405
|
+
if (path.length === 1) return path[0]!;
|
|
406
|
+
const seg: number[] = [];
|
|
407
|
+
let total = 0;
|
|
408
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
409
|
+
const d = Math.hypot(path[i + 1]!.x - path[i]!.x, path[i + 1]!.y - path[i]!.y);
|
|
410
|
+
seg.push(d);
|
|
411
|
+
total += d;
|
|
412
|
+
}
|
|
413
|
+
if (total === 0) return path[0]!;
|
|
414
|
+
let target = u * total;
|
|
415
|
+
for (let i = 0; i < seg.length; i++) {
|
|
416
|
+
if (target <= seg[i]! || i === seg.length - 1) {
|
|
417
|
+
const f = seg[i]! > 0 ? target / seg[i]! : 0;
|
|
418
|
+
return {
|
|
419
|
+
x: path[i]!.x + (path[i + 1]!.x - path[i]!.x) * f,
|
|
420
|
+
y: path[i]!.y + (path[i + 1]!.y - path[i]!.y) * f,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
target -= seg[i]!;
|
|
424
|
+
}
|
|
425
|
+
return path[path.length - 1]!;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function cursorPos(t: number, legs: Leg[], comp: TakeComposition): Pt {
|
|
429
|
+
for (const lg of legs) {
|
|
430
|
+
if (lg.t0 <= t && t <= lg.t1) {
|
|
431
|
+
const raw = Math.max(0, Math.min(1, (t - lg.t0) / (lg.t1 - lg.t0)));
|
|
432
|
+
// A drag replays the captured stroke's pacing so the cursor stays locked
|
|
433
|
+
// to the ink: "smooth" (accel-in / decel-out — a natural hand-draw) or
|
|
434
|
+
// "linear" (constant speed). The capture bakes the SAME curve into the ink
|
|
435
|
+
// (cdp-capture.ts) and records it on the event; absent ⇒ linear (legacy).
|
|
436
|
+
// (Held-button moves are fire-and-forget there, so the slow eased ends —
|
|
437
|
+
// sub-pixel, no paint — no longer stall the stroke; cursor and ink are
|
|
438
|
+
// both ~stationary at the ends, so they stay locked.)
|
|
439
|
+
if (lg.drag && lg.path) return alongPath(lg.path, lg.ease === "smooth" ? smoother(raw) : raw);
|
|
440
|
+
const e = comp.cursor.travelEase;
|
|
441
|
+
const p = e ? cubicBezier(e[0], e[1], e[2], e[3])(raw) : smoother(raw);
|
|
442
|
+
const base = { x: lg.a.x + (lg.b.x - lg.a.x) * p, y: lg.a.y + (lg.b.y - lg.a.y) * p };
|
|
443
|
+
const dx = lg.b.x - lg.a.x,
|
|
444
|
+
dy = lg.b.y - lg.a.y;
|
|
445
|
+
const L = Math.hypot(dx, dy) || 1;
|
|
446
|
+
const arc = Math.min(comp.cursor.arcFrac * L, comp.cursor.arcMax) * Math.sin(Math.PI * p);
|
|
447
|
+
return { x: base.x + (-dy / L) * arc, y: base.y + (dx / L) * arc };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (legs.length === 0 || t < legs[0]!.t0) return comp.start;
|
|
451
|
+
for (let i = 0; i < legs.length - 1; i++)
|
|
452
|
+
if (legs[i]!.t1 < t && t < legs[i + 1]!.t0) return legs[i]!.b;
|
|
453
|
+
return legs[legs.length - 1]!.b;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** True while a drag is mid-stroke (button held) — for the pressed cursor. */
|
|
457
|
+
export function isDragging(t: number, legs: Leg[]): boolean {
|
|
458
|
+
return legs.some((lg) => lg.drag === true && lg.t0 <= t && t <= lg.t1);
|
|
459
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// planComposition: capture event log -> default editable TakeComposition.
|
|
2
|
+
// The selective bbox-fit zoom heuristic lives here; every decision it
|
|
3
|
+
// makes is written into the composition so a human/agent can tune it.
|
|
4
|
+
|
|
5
|
+
import { bboxFitScale, restStageScale } from "./math";
|
|
6
|
+
import {
|
|
7
|
+
type BBox,
|
|
8
|
+
type CaptureLog,
|
|
9
|
+
type CompEvent,
|
|
10
|
+
type CursorConfig,
|
|
11
|
+
DEFAULT_CURSOR,
|
|
12
|
+
DEFAULT_FRAMING,
|
|
13
|
+
DEFAULT_MOTION_BLUR,
|
|
14
|
+
type FramingConfig,
|
|
15
|
+
type Pt,
|
|
16
|
+
type TakeComposition,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
export type PlanOpts = {
|
|
20
|
+
output?: { width?: number; height?: number; fps?: number };
|
|
21
|
+
framing?: Partial<FramingConfig>;
|
|
22
|
+
cursor?: Partial<CursorConfig>;
|
|
23
|
+
/** element should fill this fraction of the frame when zoomed (default 0.55) */
|
|
24
|
+
fillFrac?: number;
|
|
25
|
+
/** hard cap on zoom (default 1.5 — a gentle workhorse; lower than
|
|
26
|
+
* the old 2.0 reads more premium. Small targets still zoom, just not as hard;
|
|
27
|
+
* raise per-beat in the composition when a tiny element needs it.) */
|
|
28
|
+
maxScale?: number;
|
|
29
|
+
/** require fit-scale to exceed rest*this to bother zooming (default 1.3) */
|
|
30
|
+
zoomRatio?: number;
|
|
31
|
+
/** never zoom the first (orienting) action by default (Finding 1) */
|
|
32
|
+
zoomFirst?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeComposition {
|
|
36
|
+
const vW = log.video.width,
|
|
37
|
+
vH = log.video.height;
|
|
38
|
+
const oW = opts.output?.width ?? vW;
|
|
39
|
+
const oH = opts.output?.height ?? vH;
|
|
40
|
+
const fps = opts.output?.fps ?? 30;
|
|
41
|
+
const fillFrac = opts.fillFrac ?? 0.55;
|
|
42
|
+
const maxScale = opts.maxScale ?? 1.5;
|
|
43
|
+
const zoomRatio = opts.zoomRatio ?? 1.3;
|
|
44
|
+
const zoomFirst = opts.zoomFirst ?? false;
|
|
45
|
+
|
|
46
|
+
const framing: FramingConfig = { ...DEFAULT_FRAMING, ...opts.framing };
|
|
47
|
+
const cursor: CursorConfig = { ...DEFAULT_CURSOR, ...opts.cursor };
|
|
48
|
+
|
|
49
|
+
// viewport CSS px -> video px
|
|
50
|
+
const sx = vW / log.viewport.w;
|
|
51
|
+
const sy = vH / log.viewport.h;
|
|
52
|
+
const mapPt = (p: Pt): Pt => ({ x: p.x * sx, y: p.y * sy });
|
|
53
|
+
const mapBox = (b: BBox): BBox => ({ x: b.x * sx, y: b.y * sy, w: b.w * sx, h: b.h * sy });
|
|
54
|
+
|
|
55
|
+
const rest = restStageScale(vW, vH, oW, oH, framing.insetFrac);
|
|
56
|
+
|
|
57
|
+
// Axis-aligned bbox of a polyline (video-px). Used so a drag zooms to fit
|
|
58
|
+
// the WHOLE stroke (a path, not a point): big cross-canvas drags fit ≈ rest
|
|
59
|
+
// → no zoom (correct, global); small localised drags zoom in.
|
|
60
|
+
const pathBBox = (pts: Pt[]): BBox => {
|
|
61
|
+
const xs = pts.map((p) => p.x),
|
|
62
|
+
ys = pts.map((p) => p.y);
|
|
63
|
+
const x = Math.min(...xs),
|
|
64
|
+
y = Math.min(...ys);
|
|
65
|
+
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const events: CompEvent[] = log.events.map((c, i) => {
|
|
69
|
+
const kind = c.kind ?? "click";
|
|
70
|
+
const point = mapPt({ x: c.x, y: c.y });
|
|
71
|
+
const isFirst = i === 0;
|
|
72
|
+
const intent = c.zoom ?? "auto";
|
|
73
|
+
const durationMs = "durationMs" in c ? c.durationMs : 0;
|
|
74
|
+
|
|
75
|
+
// drag: cursor path + the bbox we fit-zoom is the path's bbox
|
|
76
|
+
const to = kind === "drag" ? mapPt((c as { to: Pt }).to) : undefined;
|
|
77
|
+
const rawPath =
|
|
78
|
+
kind === "drag"
|
|
79
|
+
? ((c as { path?: Pt[] }).path ?? [{ x: c.x, y: c.y }, (c as { to: Pt }).to])
|
|
80
|
+
: undefined;
|
|
81
|
+
const path = rawPath?.map(mapPt);
|
|
82
|
+
const ease = kind === "drag" ? (c as { ease?: "linear" | "smooth" }).ease : undefined;
|
|
83
|
+
|
|
84
|
+
// The region this action is "about" — what zoom should frame.
|
|
85
|
+
const bbox = kind === "drag" && path ? pathBBox(path) : c.box ? mapBox(c.box) : undefined;
|
|
86
|
+
|
|
87
|
+
let enabled = false;
|
|
88
|
+
let scale = rest;
|
|
89
|
+
let reason: string;
|
|
90
|
+
const center = bbox ? { x: bbox.x + bbox.w / 2, y: bbox.y + bbox.h / 2 } : point;
|
|
91
|
+
const fit = bbox ? bboxFitScale(bbox, oW, oH, fillFrac, maxScale, rest) : maxScale;
|
|
92
|
+
|
|
93
|
+
if (kind === "scroll") {
|
|
94
|
+
// A scroll is a pan beat: the content moves, the frame stays full-view.
|
|
95
|
+
// Zooming would fight the motion, so a scroll never zooms.
|
|
96
|
+
enabled = false;
|
|
97
|
+
reason = "scroll — full view (content pans, no zoom)";
|
|
98
|
+
} else if (intent === "never") {
|
|
99
|
+
enabled = false;
|
|
100
|
+
reason = "plan: zoom=never (global/navigation payoff — keep full view)";
|
|
101
|
+
} else if (intent === "always") {
|
|
102
|
+
enabled = true;
|
|
103
|
+
scale = fit;
|
|
104
|
+
reason = `plan: zoom=always → ${fit.toFixed(2)}x (capped ${maxScale}x)`;
|
|
105
|
+
} else if (!bbox) {
|
|
106
|
+
reason = "no bbox in event log — cannot bbox-fit, so no zoom (avoids framing dead space)";
|
|
107
|
+
} else {
|
|
108
|
+
scale = fit;
|
|
109
|
+
const meaningful = fit > rest * zoomRatio;
|
|
110
|
+
const region = kind === "drag" ? "drag path" : "element";
|
|
111
|
+
if (isFirst && !zoomFirst) {
|
|
112
|
+
enabled = false;
|
|
113
|
+
reason = `first/orienting action — skipped by default (fit ${fit.toFixed(2)}x available)`;
|
|
114
|
+
} else if (!meaningful) {
|
|
115
|
+
enabled = false;
|
|
116
|
+
reason = `${region} fills the frame already (fit ${fit.toFixed(2)}x ≈ rest ${rest.toFixed(2)}x) — gentle/no zoom`;
|
|
117
|
+
} else {
|
|
118
|
+
enabled = true;
|
|
119
|
+
reason = `bbox-fit ${fit.toFixed(2)}x (capped ${maxScale}x), ${region} framed with ${Math.round(fillFrac * 100)}% fill`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
kind,
|
|
125
|
+
tMs: c.tMs,
|
|
126
|
+
point,
|
|
127
|
+
bbox,
|
|
128
|
+
label: c.sel ?? c.note,
|
|
129
|
+
// zoom-in ramp starts zoomInMs before the action (decoupled from cursor
|
|
130
|
+
// travelMs so the zoom can be slower/gentler — a more cinematic feel).
|
|
131
|
+
zoom: { enabled, scale, center, inAtMs: Math.max(0, c.tMs - cursor.zoomInMs), reason },
|
|
132
|
+
...(durationMs ? { durationMs } : {}),
|
|
133
|
+
...(kind === "type" ? { text: (c as { text: string }).text } : {}),
|
|
134
|
+
...(kind === "press" ? { keys: (c as { keys: string }).keys } : {}),
|
|
135
|
+
...(to ? { to } : {}),
|
|
136
|
+
...(path ? { path } : {}),
|
|
137
|
+
...(ease ? { ease } : {}),
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const start = log.start ? mapPt(log.start) : { x: vW * 0.25, y: vH * 0.9 };
|
|
142
|
+
const last = log.events.length ? log.events[log.events.length - 1]! : undefined;
|
|
143
|
+
// the last action ends durationMs after its tMs (typing/drag plays out)
|
|
144
|
+
const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
|
|
145
|
+
const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
output: { width: oW, height: oH, fps },
|
|
149
|
+
source: { videoUrl: "/capture.mp4", videoWidth: vW, videoHeight: vH, viewport: log.viewport },
|
|
150
|
+
framing,
|
|
151
|
+
cursor,
|
|
152
|
+
motionBlur: DEFAULT_MOTION_BLUR,
|
|
153
|
+
start,
|
|
154
|
+
events,
|
|
155
|
+
durationMs,
|
|
156
|
+
};
|
|
157
|
+
}
|