feat(core): capture motion blur pose, discovery and compositor · heygen-com/hyperframes@c654769 · GitHub
Skip to content

Commit c654769

Browse files
vanceingallsclaude
andcommitted
feat(core): capture motion blur pose, discovery and compositor
Add the three pure modules capture motion blur is built from: pose (an element's box as an affine in composition space, and the poses between two times), discover (find the elements that move fast enough for a smear to show) and composite (redraw each mover along its path into one frame). Nothing calls them yet; the runtime that wires them up lands in the next change. The pose reads the individual translate, rotate and scale properties in CSS order, adds the offsetParent border back and subtracts ancestor scroll, so it matches getBoundingClientRect for a box centered with translate: -50% -50%. Folded review fixes: R3 (pose half; the scan's 3D skip for individual transforms lands with the mover scan). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 908f2d0 commit c654769

6 files changed

Lines changed: 976 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* The per-frame draw: a background, then each mover's own pixels redrawn along its path.
3+
*
4+
* Each mover is cropped from a capture of the scene and drawn at every tap pose into a float
5+
* buffer, additive at weight 1/n: the shutter integral. Its coverage fades at the path ends, so
6+
* the trail needs a background without the mover (a second capture, movers hidden); falling back
7+
* to the sharp frame leaves thin lines looking unblurred.
8+
*
9+
* Image-space approximation: a mover's box contents move with it; ink outside it is not redrawn.
10+
*/
11+
12+
import { type Affine, invert, sweep, thinTaps } from "./pose";
13+
14+
export interface MoverFrame {
15+
w: number;
16+
h: number;
17+
/** The pose the capture shows (frame time). */
18+
now: Affine;
19+
/** Poses across the shutter. */
20+
taps: Affine[];
21+
}
22+
23+
interface CompositeStats {
24+
blurred: number;
25+
taps: number;
26+
}
27+
28+
type Context2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
29+
interface Layer {
30+
canvas: OffscreenCanvas;
31+
ctx: OffscreenCanvasRenderingContext2D;
32+
}
33+
34+
// Float16 keeps n additive 1/n-weighted taps exact; 8-bit loses up to ~n/2 levels per channel.
35+
function makeLayer(w: number, h: number): Layer {
36+
const canvas = new OffscreenCanvas(w, h);
37+
let ctx: OffscreenCanvasRenderingContext2D | null = null;
38+
try {
39+
ctx = canvas.getContext("2d", { colorType: "float16" } as CanvasRenderingContext2DSettings);
40+
} catch {
41+
ctx = null;
42+
}
43+
ctx = ctx ?? canvas.getContext("2d");
44+
if (!ctx) throw new Error("capture motion blur: no 2D context for the accumulation buffer");
45+
return { canvas, ctx };
46+
}
47+
48+
/** Whether a mover travels far enough within this shutter to draw a trail at all. */
49+
function moves(mover: MoverFrame, frameW: number, frameH: number): boolean {
50+
const { travel, rect } = sweep(mover.taps, mover.w, mover.h, frameW, frameH);
51+
return rect !== null && travel >= 1;
52+
}
53+
54+
export function createCompositor(width: number, height: number) {
55+
const accum = makeLayer(width, height);
56+
const crops: Layer[] = [];
57+
// A mover box this many times the frame is too big to copy every frame; it is left sharp.
58+
const maxCropArea = width * height * 4;
59+
60+
const cropLayer = (i: number, w: number, h: number): Layer => {
61+
let layer = crops[i];
62+
if (!layer || layer.canvas.width < w || layer.canvas.height < h) {
63+
layer = makeLayer(
64+
Math.max(w, layer?.canvas.width ?? 1),
65+
Math.max(h, layer?.canvas.height ?? 1),
66+
);
67+
crops[i] = layer;
68+
}
69+
return layer;
70+
};
71+
72+
/** Copy each mover's frame-time pixels out of `src`; false marks a mover to leave sharp. */
73+
function crop(src: CanvasImageSource, movers: MoverFrame[]): boolean[] {
74+
return movers.map((mover, i) => {
75+
const { w, h } = mover;
76+
const back = invert(mover.now);
77+
if (!back || w * h > maxCropArea || !moves(mover, width, height)) return false;
78+
const c = cropLayer(i, w, h).ctx;
79+
c.setTransform(1, 0, 0, 1, 0, 0);
80+
c.globalCompositeOperation = "source-over";
81+
c.globalAlpha = 1;
82+
c.clearRect(0, 0, w, h);
83+
c.setTransform(back.a, back.b, back.c, back.d, back.e, back.f);
84+
c.drawImage(src, 0, 0);
85+
return true;
86+
});
87+
}
88+
89+
function trail(out: Context2D, mover: MoverFrame, layer: Layer): number {
90+
const { w, h } = mover;
91+
const { travel, rect } = sweep(mover.taps, w, h, width, height);
92+
if (!rect) return 0;
93+
const taps = thinTaps(mover.taps, travel);
94+
const a = accum.ctx;
95+
a.setTransform(1, 0, 0, 1, 0, 0);
96+
a.globalCompositeOperation = "source-over";
97+
a.globalAlpha = 1;
98+
a.clearRect(rect.x, rect.y, rect.w, rect.h);
99+
a.globalCompositeOperation = "lighter";
100+
a.globalAlpha = 1 / taps.length;
101+
for (const m of taps) {
102+
a.setTransform(m.a, m.b, m.c, m.d, m.e, m.f);
103+
a.drawImage(layer.canvas, 0, 0, w, h, 0, 0, w, h);
104+
}
105+
out.drawImage(accum.canvas, rect.x, rect.y, rect.w, rect.h, rect.x, rect.y, rect.w, rect.h);
106+
return taps.length;
107+
}
108+
109+
/** `background` into `out`, then the trail of every mover `crop` kept. */
110+
function draw(
111+
background: CanvasImageSource,
112+
out: Context2D,
113+
movers: MoverFrame[],
114+
kept: boolean[],
115+
): CompositeStats {
116+
out.setTransform(1, 0, 0, 1, 0, 0);
117+
out.globalAlpha = 1;
118+
out.globalCompositeOperation = "copy";
119+
out.drawImage(background, 0, 0);
120+
out.globalCompositeOperation = "source-over";
121+
const stats: CompositeStats = { blurred: 0, taps: 0 };
122+
movers.forEach((mover, i) => {
123+
const layer = crops[i];
124+
if (!kept[i] || !layer) return;
125+
const n = trail(out, mover, layer);
126+
if (n > 0) {
127+
stats.blurred++;
128+
stats.taps += n;
129+
}
130+
});
131+
return stats;
132+
}
133+
134+
return { crop, draw };
135+
}
Lines changed: 67 additions & 0 deletions

0 commit comments

Comments
 (0)