@open-take/compositor 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +155 -33
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +413 -171
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/camera.ts +343 -0
- package/src/index.ts +1 -0
- package/src/math.ts +156 -124
- package/src/plan.ts +114 -69
- package/src/presets.ts +10 -5
- package/src/scene/scene.tsx +8 -16
- package/src/types.ts +90 -23
- package/src/validate.ts +18 -0
package/src/math.ts
CHANGED
|
@@ -43,44 +43,45 @@ export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (x:
|
|
|
43
43
|
// Spring-shaped easing y(p) over [0,1]: the unit step response of a damped
|
|
44
44
|
// spring, time-normalised so it rises (and, for bounce>0, slightly overshoots)
|
|
45
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.
|
|
47
|
-
//
|
|
48
|
-
// zoom
|
|
46
|
+
// (no overshoot — a soft, physical ease-out); higher = more overshoot/snap.
|
|
47
|
+
//
|
|
48
|
+
// bounce 0 over the default zoom durations IS the measured reference zoom
|
|
49
|
+
// feel: frame-tracking a reference export gave a critically-damped spring on the
|
|
50
|
+
// camera rect, ω≈9.4 rad/s in / ω≈5.2 out (which also matches the spring
|
|
51
|
+
// preset numbers from the reference teardown) —
|
|
52
|
+
// i.e. this exact curve over ~730ms in / ~1340ms out.
|
|
49
53
|
//
|
|
50
54
|
// Under time-normalisation the curve depends ONLY on the damping ratio ζ=1−bounce
|
|
51
55
|
// (the natural frequency cancels), so a single `bounce` knob is the whole shape —
|
|
52
56
|
// the segment DURATION stays whatever the keyframes say (zoomInMs/zoomOutMs).
|
|
57
|
+
// The response is normalised by its end value so the segment lands on exactly 1
|
|
58
|
+
// (no end-of-segment snap from the finite settle band).
|
|
53
59
|
export function springEase(bounce: number): (p: number) => number {
|
|
54
60
|
const zeta = Math.max(0.4, Math.min(1, 1 - bounce));
|
|
55
61
|
const Ts = -Math.log(1e-3) / zeta; // settling time to the 0.1% band (ω0 = 1)
|
|
56
|
-
|
|
57
|
-
if (p <= 0) return 0;
|
|
58
|
-
if (p >= 1) return 1;
|
|
59
|
-
const t = p * Ts;
|
|
62
|
+
const step = (t: number): number => {
|
|
60
63
|
if (zeta >= 1) return 1 - Math.exp(-t) * (1 + t); // critically damped
|
|
61
64
|
const wd = Math.sqrt(1 - zeta * zeta); // damped natural frequency (ω0 = 1)
|
|
62
65
|
return 1 - Math.exp(-zeta * t) * (Math.cos(wd * t) + (zeta / wd) * Math.sin(wd * t));
|
|
63
66
|
};
|
|
67
|
+
const end = step(Ts);
|
|
68
|
+
return (p: number) => {
|
|
69
|
+
if (p <= 0) return 0;
|
|
70
|
+
if (p >= 1) return 1;
|
|
71
|
+
return step(p * Ts) / end;
|
|
72
|
+
};
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
// The stage (
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
75
|
+
// The stage (camera rect) easing, selected from the cursor config. ONE curve
|
|
76
|
+
// eases the whole rect — centre and size together (see stageCamera). The single
|
|
77
|
+
// source the revideo scene (scene.tsx) AND the editor preview consume — any
|
|
78
|
+
// other renderer must use it identically so renderers can never drift.
|
|
79
|
+
// Precedence: spring (zoomSpring) → cubic-bezier (zoomEase) → the default
|
|
80
|
+
// critically-damped spring (the measured reference curve).
|
|
70
81
|
export function stageEasing(cursor: TakeComposition["cursor"]): (u: number) => number {
|
|
71
82
|
if (cursor.zoomSpring != null) return springEase(cursor.zoomSpring);
|
|
72
83
|
if (cursor.zoomEase) return cubicBezier(...cursor.zoomEase);
|
|
73
|
-
return
|
|
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
|
+
return springEase(0);
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
// Backdrop gradient endpoints in TOP-LEFT origin (0..oW, 0..oH), shared by the
|
|
@@ -110,15 +111,10 @@ export function gradientEndpoints(
|
|
|
110
111
|
|
|
111
112
|
type KF<T> = [number, T];
|
|
112
113
|
|
|
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
114
|
export function keyvalN(
|
|
118
115
|
t: number,
|
|
119
116
|
kfs: KF<number>[],
|
|
120
117
|
ease: (u: number) => number = smoother,
|
|
121
|
-
easeDown?: (u: number) => number,
|
|
122
118
|
): number {
|
|
123
119
|
if (t <= kfs[0]![0]) return kfs[0]![1];
|
|
124
120
|
if (t >= kfs[kfs.length - 1]![0]) return kfs[kfs.length - 1]![1];
|
|
@@ -126,22 +122,7 @@ export function keyvalN(
|
|
|
126
122
|
const [t0, v0] = kfs[i]!;
|
|
127
123
|
const [t1, v1] = kfs[i + 1]!;
|
|
128
124
|
if (t0 <= t && t <= t1) {
|
|
129
|
-
|
|
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 };
|
|
125
|
+
return v0 + (v1 - v0) * ease((t - t0) / (t1 - t0));
|
|
145
126
|
}
|
|
146
127
|
}
|
|
147
128
|
return kfs[kfs.length - 1]![1];
|
|
@@ -206,21 +187,73 @@ export function clampCenter(
|
|
|
206
187
|
return { x: cx, y: cy };
|
|
207
188
|
}
|
|
208
189
|
|
|
209
|
-
// --- stage (
|
|
190
|
+
// --- stage (camera rect) keyframes from the composition ----------------
|
|
191
|
+
|
|
192
|
+
/** The camera as a viewport RECT in video-px: centre + full viewport width
|
|
193
|
+
* (height is implied by the output aspect: h = w·oH/oW). scale = oW / w. */
|
|
194
|
+
export type CamRect = { cx: number; cy: number; w: number };
|
|
210
195
|
|
|
211
196
|
export type StageKeyframes = {
|
|
212
|
-
|
|
213
|
-
c: KF<Pt>[]; // raw video-px center over time (clamp at eval)
|
|
197
|
+
r: KF<CamRect>[]; // viewport rect over time (seconds)
|
|
214
198
|
T: number; // total duration (s)
|
|
215
199
|
};
|
|
216
200
|
|
|
201
|
+
/** Interpolate the rect track: centre AND size move under the SAME eased
|
|
202
|
+
* parameter. This is the whole trick (verified
|
|
203
|
+
* by frame-tracking a reference export: its pan curve overlays its viewport-WIDTH
|
|
204
|
+
* curve exactly, not its scale curve): lerping the rect keeps every corner on
|
|
205
|
+
* a straight line, so the screen-space path of the zoom target is strictly
|
|
206
|
+
* monotone toward frame centre — no wrong-way "bounce" for ANY scale pair,
|
|
207
|
+
* which scale+centre lerp can't guarantee (it hooks when scale > 2×rest). */
|
|
208
|
+
export function keyvalR(t: number, kfs: KF<CamRect>[], ease: (u: number) => number): CamRect {
|
|
209
|
+
if (t <= kfs[0]![0]) return kfs[0]![1];
|
|
210
|
+
if (t >= kfs[kfs.length - 1]![0]) return kfs[kfs.length - 1]![1];
|
|
211
|
+
for (let i = 0; i < kfs.length - 1; i++) {
|
|
212
|
+
const [t0, v0] = kfs[i]!;
|
|
213
|
+
const [t1, v1] = kfs[i + 1]!;
|
|
214
|
+
if (t0 <= t && t <= t1) {
|
|
215
|
+
const p = ease((t - t0) / (t1 - t0));
|
|
216
|
+
// A spring ease (zoomSpring > 0) overshoots p past 1, extrapolating the
|
|
217
|
+
// rect beyond its target — that's the wanted "snap", but on a deep punch
|
|
218
|
+
// with a large bounce the extrapolated width could collapse through 0
|
|
219
|
+
// (scale sign-flip = garbage frames). Floor the width against the
|
|
220
|
+
// segment's own endpoints so overshoot can tighten at most 2× past the
|
|
221
|
+
// tighter one; monotone eases (p ∈ [0,1]) never hit the floor.
|
|
222
|
+
return {
|
|
223
|
+
cx: v0.cx + (v1.cx - v0.cx) * p,
|
|
224
|
+
cy: v0.cy + (v1.cy - v0.cy) * p,
|
|
225
|
+
w: Math.max(v0.w + (v1.w - v0.w) * p, Math.min(v0.w, v1.w) * 0.5),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return kfs[kfs.length - 1]![1];
|
|
230
|
+
}
|
|
231
|
+
|
|
217
232
|
export function buildStageKeyframes(comp: TakeComposition): StageKeyframes {
|
|
218
233
|
const { videoWidth: vW, videoHeight: vH } = comp.source;
|
|
219
234
|
const { width: oW, height: oH } = comp.output;
|
|
220
235
|
const rest = restStageScale(vW, vH, oW, oH, comp.framing.insetFrac);
|
|
221
236
|
const restC: Pt = { x: vW / 2, y: vH / 2 };
|
|
222
237
|
const HOLD = comp.cursor.holdMs / 1000;
|
|
223
|
-
const
|
|
238
|
+
const ZOUT_MS = comp.cursor.zoomOutMs;
|
|
239
|
+
|
|
240
|
+
// A framing target is clamped ONCE, here at build time (the viewport crop
|
|
241
|
+
// stays inside the recording; an axis it doesn't cover centres). Every
|
|
242
|
+
// keyframe rect is therefore valid — and for a monotone ease (p ∈ [0,1];
|
|
243
|
+
// every default) lerped rect corners stay between the endpoints' corners, so
|
|
244
|
+
// every IN-BETWEEN rect is valid too. No per-frame clamp, so the clamp can
|
|
245
|
+
// never bend a path mid-flight (the old model's per-frame clamp force-
|
|
246
|
+
// centred the pan while the video under-covered the frame, then released it
|
|
247
|
+
// mid-zoom — a visible lurch; and its zoom-out "land the centre early"
|
|
248
|
+
// repair made pull-outs pan-then-zoom two-phase). A zoomSpring bounce > 0
|
|
249
|
+
// deliberately overshoots PAST the target rect (edge-flush targets can
|
|
250
|
+
// flash a sliver of backdrop during the settle — the physical cost of
|
|
251
|
+
// bounce; keyvalR floors only the width collapse).
|
|
252
|
+
const rectFor = (center: Pt, scale: number): CamRect => {
|
|
253
|
+
const c = clampCenter(center, scale, vW, vH, oW, oH);
|
|
254
|
+
return { cx: c.x, cy: c.y, w: oW / scale };
|
|
255
|
+
};
|
|
256
|
+
const restR = rectFor(restC, rest);
|
|
224
257
|
|
|
225
258
|
// Framing anchors over time. A zoom-enabled beat ramps to its target; a
|
|
226
259
|
// scroll (content pans) and a zoom-less press (Escape/Enter whose effect is
|
|
@@ -245,101 +278,100 @@ export function buildStageKeyframes(comp: TakeComposition): StageKeyframes {
|
|
|
245
278
|
}
|
|
246
279
|
}
|
|
247
280
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
281
|
+
const targets = anchors.map((e) => rectFor(e.center, e.scale));
|
|
282
|
+
|
|
283
|
+
// Effective ramp start per anchor. A PULL-OUT (wider viewport than the rect
|
|
284
|
+
// it leaves) paces with zoomOutMs — measured on the reference export,
|
|
285
|
+
// the pull-out is ~1.8× slower than the punch-in. The stored inAtMs keeps
|
|
286
|
+
// governing punch-ins/same-size travels, and a HAND-SET inAtMs (≠ the
|
|
287
|
+
// planner default tMs − zoomInMs) wins even for a pull-out, so the per-beat
|
|
288
|
+
// timing knob stays live. When the zoomOutMs window would start before the
|
|
289
|
+
// previous action's end, shorten it toward the punch-in window rather than
|
|
290
|
+
// cut the payoff — but NEVER squeeze below min(zoomOutMs, zoomInMs) of real
|
|
291
|
+
// ramp: a pull-out with no window is a jump cut, which is strictly worse
|
|
292
|
+
// than leaving a payoff a little early.
|
|
293
|
+
const ZIN_MS = comp.cursor.zoomInMs;
|
|
294
|
+
const rampStartS = anchors.map((e, i) => {
|
|
295
|
+
const from = i > 0 ? targets[i - 1]! : restR;
|
|
296
|
+
const pullOut = targets[i]!.w > from.w + 1e-6;
|
|
297
|
+
const isDefaultInAt = Math.abs(e.inAtMs - Math.max(0, e.tMs - ZIN_MS)) < 1;
|
|
298
|
+
if (!pullOut || !isDefaultInAt) return e.inAtMs / 1000;
|
|
299
|
+
const desired = e.tMs - ZOUT_MS;
|
|
300
|
+
const prevEndMs = i > 0 ? anchors[i - 1]!.tMs + anchors[i - 1]!.durationMs : 0;
|
|
301
|
+
const start =
|
|
302
|
+
desired >= prevEndMs ? desired : Math.min(prevEndMs, e.tMs - Math.min(ZOUT_MS, ZIN_MS));
|
|
303
|
+
return Math.max(start, 0) / 1000;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const rf: { t: number; r: CamRect }[] = [{ t: 0, r: restR }];
|
|
307
|
+
const push = (t: number, r: CamRect) => {
|
|
308
|
+
rf.push({ t: Math.max(t, rf[rf.length - 1]!.t + 1e-3), r });
|
|
275
309
|
};
|
|
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
310
|
|
|
281
|
-
let cur =
|
|
311
|
+
let cur = restR;
|
|
282
312
|
anchors.forEach((e, i) => {
|
|
283
|
-
const rampStart = e.inAtMs / 1000;
|
|
284
313
|
const clickT = e.tMs / 1000;
|
|
285
314
|
// the action plays out (typing/drawing/scrolling) for durationMs after tMs
|
|
286
315
|
// — hold the target framing through it (a point click has duration 0).
|
|
287
316
|
const actionEnd = (e.tMs + e.durationMs) / 1000;
|
|
288
317
|
const next = anchors[i + 1];
|
|
289
|
-
const holdEndT = next ?
|
|
318
|
+
const holdEndT = next ? rampStartS[i + 1]! : actionEnd + HOLD;
|
|
319
|
+
push(rampStartS[i]!, cur); // hold current until ramp begins
|
|
320
|
+
push(clickT, targets[i]!); // ONE eased rect segment lands at the action
|
|
321
|
+
cur = targets[i]!;
|
|
290
322
|
// glide: drift the held centre across the hold window (velocity px/s ·
|
|
291
323
|
// holdSeconds), so a held zoom slowly pans instead of sitting dead-static.
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
let holdC = e.center;
|
|
324
|
+
// Clamped like any target so the drift can't leave the recording.
|
|
325
|
+
let holdR = cur;
|
|
295
326
|
if (e.glide && (e.glide.x !== 0 || e.glide.y !== 0)) {
|
|
296
327
|
const holdDur = Math.max(0, holdEndT - clickT);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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);
|
|
328
|
+
holdR = rectFor(
|
|
329
|
+
{ x: e.center.x + e.glide.x * holdDur, y: e.center.y + e.glide.y * holdDur },
|
|
330
|
+
e.scale,
|
|
331
|
+
);
|
|
314
332
|
}
|
|
315
|
-
push(
|
|
316
|
-
cur =
|
|
317
|
-
if (next) {
|
|
318
|
-
|
|
319
|
-
|
|
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 };
|
|
333
|
+
push(holdEndT, holdR); // hold (or glide) until the next ramp / the tail
|
|
334
|
+
cur = holdR;
|
|
335
|
+
if (!next) {
|
|
336
|
+
push(holdEndT + ZOUT_MS / 1000, restR); // final zoom-out, one rect segment
|
|
337
|
+
cur = restR;
|
|
335
338
|
}
|
|
336
339
|
});
|
|
337
340
|
|
|
338
|
-
const lastT =
|
|
341
|
+
const lastT = rf[rf.length - 1]!.t;
|
|
339
342
|
const T = Math.max(comp.durationMs / 1000, lastT) + 0.3;
|
|
340
|
-
push(T,
|
|
343
|
+
push(T, restR);
|
|
341
344
|
|
|
342
|
-
return {
|
|
345
|
+
return { r: rf.map((f) => [f.t, f.r]), T };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The one camera evaluator — scene.tsx (render) and the editor preview both
|
|
349
|
+
* consume THIS, so preview and export can never drift. */
|
|
350
|
+
export function stageCamera(comp: TakeComposition): {
|
|
351
|
+
T: number;
|
|
352
|
+
rest: number;
|
|
353
|
+
peakScale: number;
|
|
354
|
+
at: (t: number) => { scale: number; center: Pt };
|
|
355
|
+
} {
|
|
356
|
+
const stage = buildStageKeyframes(comp);
|
|
357
|
+
const ease = stageEasing(comp.cursor);
|
|
358
|
+
const oW = comp.output.width;
|
|
359
|
+
const rest = restStageScale(
|
|
360
|
+
comp.source.videoWidth,
|
|
361
|
+
comp.source.videoHeight,
|
|
362
|
+
oW,
|
|
363
|
+
comp.output.height,
|
|
364
|
+
comp.framing.insetFrac,
|
|
365
|
+
);
|
|
366
|
+
const at = (t: number) => {
|
|
367
|
+
const r = keyvalR(t, stage.r, ease);
|
|
368
|
+
return { scale: oW / r.w, center: { x: r.cx, y: r.cy } };
|
|
369
|
+
};
|
|
370
|
+
// Peak by sampling (not just keyframe extremes): a spring ease overshoots
|
|
371
|
+
// mid-segment, so the true peak can sit above every keyframe.
|
|
372
|
+
let peakScale = stage.r.reduce((m, [, r]) => Math.max(m, oW / r.w), rest);
|
|
373
|
+
for (let i = 0; i <= 720; i++) peakScale = Math.max(peakScale, at((i / 720) * stage.T).scale);
|
|
374
|
+
return { T: stage.T, rest, peakScale, at };
|
|
343
375
|
}
|
|
344
376
|
|
|
345
377
|
// --- cursor path (eased, with gentle arc) ------------------------------
|
package/src/plan.ts
CHANGED
|
@@ -1,52 +1,62 @@
|
|
|
1
1
|
// planComposition: capture event log -> default editable TakeComposition.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
//
|
|
3
|
+
// Coordinate mapping (capture viewport px -> video px) and the per-beat
|
|
4
|
+
// scaffolding live here; the ZOOM decision is delegated to the camera DIRECTOR
|
|
5
|
+
// (camera.ts), which runs over the whole beat sequence with the real bbox /
|
|
6
|
+
// timing / changed-area from the ground-truth log. Every decision it makes is
|
|
7
|
+
// still written per-beat into the composition (enabled/scale/center + reason)
|
|
8
|
+
// so a human/agent can read and tune it.
|
|
4
9
|
|
|
5
|
-
import {
|
|
10
|
+
import { type Beat, directCamera } from "./camera";
|
|
11
|
+
import { restStageScale } from "./math";
|
|
6
12
|
import {
|
|
7
13
|
type BBox,
|
|
14
|
+
type CameraConfig,
|
|
8
15
|
type CaptureLog,
|
|
9
16
|
type CompEvent,
|
|
10
17
|
type CursorConfig,
|
|
18
|
+
DEFAULT_CAMERA,
|
|
11
19
|
DEFAULT_CURSOR,
|
|
12
20
|
DEFAULT_FRAMING,
|
|
13
21
|
DEFAULT_MOTION_BLUR,
|
|
14
22
|
type FramingConfig,
|
|
15
23
|
type Pt,
|
|
16
24
|
type TakeComposition,
|
|
25
|
+
type ZoomDecision,
|
|
17
26
|
} from "./types";
|
|
18
27
|
|
|
19
28
|
export type PlanOpts = {
|
|
20
29
|
output?: { width?: number; height?: number; fps?: number };
|
|
21
30
|
framing?: Partial<FramingConfig>;
|
|
22
31
|
cursor?: Partial<CursorConfig>;
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
|
|
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;
|
|
32
|
+
/** auto-camera director tuning (default DEFAULT_CAMERA — ON). The director
|
|
33
|
+
* decides zoom from the ground-truth log; these are its feel knobs. Set
|
|
34
|
+
* `camera.enabled = false` for the manual escape hatch (only explicit
|
|
35
|
+
* event `zoom: "always"/"never"` produce zoom then). */
|
|
36
|
+
camera?: Partial<CameraConfig>;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeComposition {
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
// Stage space = the capture VIEWPORT (CSS px), NOT the video file's pixel
|
|
41
|
+
// size. A Retina capture (captureScale 2) records a 2×-dense bitmap of the
|
|
42
|
+
// same viewport; the scene draws it at viewport size, so the denser bitmap
|
|
43
|
+
// only sharpens sampling under zoom. Keying the stage to the viewport keeps
|
|
44
|
+
// every calibrated quantity — camera scales (rest/minZoomScale/maxScale),
|
|
45
|
+
// cursor/ripple sizes, framing radius/shadow — meaning the same thing at
|
|
46
|
+
// any capture density. (Pre-Retina logs have video == viewport, so this is
|
|
47
|
+
// byte-identical for them.)
|
|
48
|
+
const vW = log.viewport.w,
|
|
49
|
+
vH = log.viewport.h;
|
|
38
50
|
const oW = opts.output?.width ?? vW;
|
|
39
51
|
const oH = opts.output?.height ?? vH;
|
|
40
52
|
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
53
|
|
|
46
54
|
const framing: FramingConfig = { ...DEFAULT_FRAMING, ...opts.framing };
|
|
47
55
|
const cursor: CursorConfig = { ...DEFAULT_CURSOR, ...opts.cursor };
|
|
56
|
+
const camera: CameraConfig = { ...DEFAULT_CAMERA, ...opts.camera };
|
|
48
57
|
|
|
49
|
-
// viewport CSS px ->
|
|
58
|
+
// log coords (viewport CSS px) -> stage px. Identity today (stage IS the
|
|
59
|
+
// viewport); kept as an explicit mapping seam.
|
|
50
60
|
const sx = vW / log.viewport.w;
|
|
51
61
|
const sy = vH / log.viewport.h;
|
|
52
62
|
const mapPt = (p: Pt): Pt => ({ x: p.x * sx, y: p.y * sy });
|
|
@@ -54,9 +64,8 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
|
|
|
54
64
|
|
|
55
65
|
const rest = restStageScale(vW, vH, oW, oH, framing.insetFrac);
|
|
56
66
|
|
|
57
|
-
// Axis-aligned bbox of a polyline (video-px)
|
|
58
|
-
//
|
|
59
|
-
// → no zoom (correct, global); small localised drags zoom in.
|
|
67
|
+
// Axis-aligned bbox of a polyline (video-px), so a drag frames the WHOLE
|
|
68
|
+
// stroke (a path, not a point): a big cross-canvas drag fits ≈ rest → no zoom.
|
|
60
69
|
const pathBBox = (pts: Pt[]): BBox => {
|
|
61
70
|
const xs = pts.map((p) => p.x),
|
|
62
71
|
ys = pts.map((p) => p.y);
|
|
@@ -65,14 +74,19 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
|
|
|
65
74
|
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
|
66
75
|
};
|
|
67
76
|
|
|
68
|
-
|
|
77
|
+
// --- map every event into video-px scaffolding (kind-agnostic zoom yet) -----
|
|
78
|
+
type Scaffold = {
|
|
79
|
+
beat: Beat;
|
|
80
|
+
ev: Omit<CompEvent, "zoom">;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const scaffolds: Scaffold[] = log.events.map((c) => {
|
|
69
84
|
const kind = c.kind ?? "click";
|
|
70
85
|
const point = mapPt({ x: c.x, y: c.y });
|
|
71
|
-
const isFirst = i === 0;
|
|
72
86
|
const intent = c.zoom ?? "auto";
|
|
73
87
|
const durationMs = "durationMs" in c ? c.durationMs : 0;
|
|
74
88
|
|
|
75
|
-
// drag: cursor path + the
|
|
89
|
+
// drag: cursor path + the region we frame is the path's bbox
|
|
76
90
|
const to = kind === "drag" ? mapPt((c as { to: Pt }).to) : undefined;
|
|
77
91
|
const rawPath =
|
|
78
92
|
kind === "drag"
|
|
@@ -81,54 +95,30 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
|
|
|
81
95
|
const path = rawPath?.map(mapPt);
|
|
82
96
|
const ease = kind === "drag" ? (c as { ease?: "linear" | "smooth" }).ease : undefined;
|
|
83
97
|
|
|
84
|
-
//
|
|
98
|
+
// the element bbox (ground truth): drag → path bbox, else the captured box
|
|
85
99
|
const bbox = kind === "drag" && path ? pathBBox(path) : c.box ? mapBox(c.box) : undefined;
|
|
100
|
+
const effectBox = c.effectBox ? mapBox(c.effectBox) : undefined;
|
|
101
|
+
const label = c.sel ?? c.note;
|
|
86
102
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
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
|
-
}
|
|
103
|
+
const beat: Beat = {
|
|
104
|
+
kind,
|
|
105
|
+
tMs: c.tMs,
|
|
106
|
+
durationMs,
|
|
107
|
+
box: bbox,
|
|
108
|
+
effectBox,
|
|
109
|
+
changeCoverage: c.changeCoverage,
|
|
110
|
+
point,
|
|
111
|
+
intent,
|
|
112
|
+
label,
|
|
113
|
+
...(kind === "press" ? { keys: (c as { keys?: string }).keys } : {}),
|
|
114
|
+
};
|
|
122
115
|
|
|
123
|
-
|
|
116
|
+
const ev: Omit<CompEvent, "zoom"> = {
|
|
124
117
|
kind,
|
|
125
118
|
tMs: c.tMs,
|
|
126
119
|
point,
|
|
127
120
|
bbox,
|
|
128
|
-
label
|
|
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 },
|
|
121
|
+
label,
|
|
132
122
|
...(durationMs ? { durationMs } : {}),
|
|
133
123
|
...(kind === "type" ? { text: (c as { text: string }).text } : {}),
|
|
134
124
|
...(kind === "press" ? { keys: (c as { keys: string }).keys } : {}),
|
|
@@ -136,12 +126,38 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
|
|
|
136
126
|
...(path ? { path } : {}),
|
|
137
127
|
...(ease ? { ease } : {}),
|
|
138
128
|
};
|
|
129
|
+
return { beat, ev };
|
|
139
130
|
});
|
|
140
131
|
|
|
141
|
-
const
|
|
132
|
+
const beats = scaffolds.map((s) => s.beat);
|
|
133
|
+
|
|
134
|
+
// horizon for the min-hold check on the final segment
|
|
142
135
|
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
136
|
const lastEnd = last ? last.tMs + ("durationMs" in last ? last.durationMs : 0) : 0;
|
|
137
|
+
const endMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs);
|
|
138
|
+
|
|
139
|
+
// --- decide framing --------------------------------------------------------
|
|
140
|
+
// director ON (default): decide from the log. OFF: the manual escape hatch —
|
|
141
|
+
// only explicit always/never produce zoom, auto/absent hold full view.
|
|
142
|
+
const framings = camera.enabled
|
|
143
|
+
? directCamera(beats, { w: vW, h: vH }, { w: oW, h: oH }, camera, rest, endMs)
|
|
144
|
+
: beats.map((b) => manualFraming(b, rest));
|
|
145
|
+
|
|
146
|
+
const events: CompEvent[] = scaffolds.map((s, i) => {
|
|
147
|
+
const f = framings[i]!;
|
|
148
|
+
const zoom: ZoomDecision = {
|
|
149
|
+
enabled: f.enabled,
|
|
150
|
+
scale: f.scale,
|
|
151
|
+
center: f.center,
|
|
152
|
+
// zoom-in ramp starts zoomInMs before the action (decoupled from cursor
|
|
153
|
+
// travelMs so the zoom can be slower/gentler — a more cinematic feel).
|
|
154
|
+
inAtMs: Math.max(0, s.beat.tMs - cursor.zoomInMs),
|
|
155
|
+
reason: f.reason,
|
|
156
|
+
};
|
|
157
|
+
return { ...s.ev, zoom };
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const start = log.start ? mapPt(log.start) : { x: vW * 0.25, y: vH * 0.9 };
|
|
145
161
|
const durationMs = Math.max(log.tEndMs ?? 0, lastEnd + cursor.holdMs + cursor.zoomOutMs + 400);
|
|
146
162
|
|
|
147
163
|
return {
|
|
@@ -155,3 +171,32 @@ export function planComposition(log: CaptureLog, opts: PlanOpts = {}): TakeCompo
|
|
|
155
171
|
durationMs,
|
|
156
172
|
};
|
|
157
173
|
}
|
|
174
|
+
|
|
175
|
+
// Manual mode (camera.enabled === false): the director does not run. Only an
|
|
176
|
+
// explicit plan intent produces zoom — "always" frames the element bbox, every-
|
|
177
|
+
// thing else holds full view. Deliberately dumb: this is the fully-hand-driven
|
|
178
|
+
// escape hatch, not a second heuristic.
|
|
179
|
+
function manualFraming(
|
|
180
|
+
b: Beat,
|
|
181
|
+
rest: number,
|
|
182
|
+
): {
|
|
183
|
+
enabled: boolean;
|
|
184
|
+
scale: number;
|
|
185
|
+
center: Pt;
|
|
186
|
+
reason: string;
|
|
187
|
+
} {
|
|
188
|
+
if (b.intent === "always" && b.box) {
|
|
189
|
+
return {
|
|
190
|
+
enabled: true,
|
|
191
|
+
scale: rest, // manual mode leaves scale for the human to raise; frame the bbox
|
|
192
|
+
center: { x: b.box.x + b.box.w / 2, y: b.box.y + b.box.h / 2 },
|
|
193
|
+
reason: "camera off · plan: zoom=always (raise scale by hand)",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
enabled: false,
|
|
198
|
+
scale: rest,
|
|
199
|
+
center: b.point,
|
|
200
|
+
reason: "camera off · full view (no explicit zoom)",
|
|
201
|
+
};
|
|
202
|
+
}
|