Skip to content

Commit a7eebc4

Browse files
pearminiCopilot
andauthored
Add air text (#410)
* Add air text * Fix install * Update example/vite.config.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 4cd5b9a commit a7eebc4

9 files changed

Lines changed: 2666 additions & 35 deletions

File tree

example/air-text/content.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/** Short headline for the title block (layout measures this string). */
2+
export const TITLE_TEXT = "What if air were text.";
3+
4+
/**
5+
* Body copy for the filled region: laid along paths by layoutTextInPath.
6+
*/
7+
export const TEXT = `
8+
What if air were text — not metaphorically, but on the page: a field of language so dense it becomes atmosphere.
9+
We breathe letters we never read; we move through sentences that hang like humidity. Now the screen is black; the type is a
10+
pale film on the glass, like breath on a window. The room is still filled with phrases about breath, wind, oxygen, and the
11+
thin film that wraps the planet. To stand in front of this page is to displace the words: your outline becomes a quiet
12+
island where no characters land, as if your body were the only place the air refuses to be written. Step aside and the
13+
text floods back; step forward and you carve a human-shaped silence from the prose. This is a small experiment in negative
14+
space: machine vision finds the pose, geometry cuts the path, and type fills what remains — the page always full, except
15+
where you are.
16+
17+
Every font is a weather system: serifs like ridges of pressure, commas like droplets, paragraphs like fronts that never
18+
quite arrive. You are not reading so much as standing in a storm of meaning that has forgotten how to land. The camera
19+
guesses where you end and the room begins; the mask is a stencil cut from probability. What remains is not emptiness but
20+
refusal — a pocket where the sentence cannot stick, a small sovereignty of skin and heat.
21+
22+
If language is a medium, then we are always swimming. Sometimes the water is news; sometimes it is a novel; sometimes it
23+
is the same paragraph copied until it becomes texture. Repetition is not boredom here: it is thickness, a way to make the
24+
margin feel infinite. Scroll, resize, lean closer: the words do not care. They only know how to obey the shape they are
25+
given — and the shape, today, is you.
26+
27+
So call it atmosphere, call it interface, call it a joke about breath and bytes. The joke still holds: you are the one
28+
place the poem cannot go without becoming something else. When you leave, the letters close over the wake, seamless as
29+
water, and the page pretends it was never broken at all.
30+
31+
Dark mode is not decoration: it is a kind of night inside the machine. Stars are pixels; constellations are kerning;
32+
the Milky Way is a long line that broke and kept going. On black, the eye stops hunting for a margin and starts hunting
33+
for contrast — and contrast, here, is you: warmer than the glass, slower than refresh.
34+
35+
Latency is a form of patience. The model loads; the stream stutters; the mask catches up to your shoulder a frame late,
36+
and for a moment you are two people — one made of light, one made of guesswork. That doubled self is also text: a caption
37+
nobody wrote, a subtitle to being alive in front of a lens.
38+
39+
We used to think the page was a rectangle of paper. Now it is a rectangle of policy: permissions, codecs, cooling fans,
40+
the soft politics of who gets to be foreground. The prose does not judge; it only fills. But you can feel, in the empty
41+
channels where your silhouette passes, that someone once decided what “foreground” means — and that decision is older
42+
than this paragraph, and will outlive it.
43+
44+
Listen: if you hold still long enough, the text forgets you are there and treats you like architecture — a column, an
45+
arch, a doorway. Move, and the language remembers you are weather. That oscillation between object and storm is the real
46+
subject of the work. Everything else is atmosphere — which is to say, everything else is air, pretending to be words.
47+
`.trim();

example/air-text/helpers.js

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import * as cm from "charmingjs";
2+
import * as d3 from "d3";
3+
import ClipperLib from "clipper-lib";
4+
5+
export const CLIPPER_SCALE = 256;
6+
export const SCREEN_PATH_PADDING = 20;
7+
export const SCREEN_PATH_PADDING_TOP = 30;
8+
export const MASK_CONTOUR_MAX_DIM = 140;
9+
export const BODYPIX_NUM_PARTS = 24;
10+
export const VIDEO_MAX_LONG_EDGE = 480;
11+
12+
export function videoBufferDimensions(layerW, layerH) {
13+
const lw = Math.max(2, Math.floor(layerW));
14+
const lh = Math.max(2, Math.floor(layerH));
15+
const ar = lw / lh;
16+
let vw;
17+
let vh;
18+
if (lw >= lh) {
19+
vw = Math.min(VIDEO_MAX_LONG_EDGE, lw);
20+
vh = Math.max(2, Math.round(vw / ar));
21+
} else {
22+
vh = Math.min(VIDEO_MAX_LONG_EDGE, lh);
23+
vw = Math.max(2, Math.round(vh * ar));
24+
}
25+
return {vw, vh};
26+
}
27+
28+
export function rectToRing(x, y, w, h) {
29+
return [
30+
[x, y],
31+
[x + w, y],
32+
[x + w, y + h],
33+
[x, y + h],
34+
];
35+
}
36+
37+
export function unionRingsToHolePathStrings(rings) {
38+
const valid = rings.filter((ring) => ring.length >= 3);
39+
if (valid.length === 0) {
40+
return [];
41+
}
42+
const s = CLIPPER_SCALE;
43+
const paths = valid.map((ring) =>
44+
ring.map(([x, y]) => ({
45+
X: Math.round(x * s),
46+
Y: Math.round(y * s),
47+
})),
48+
);
49+
const c = new ClipperLib.Clipper();
50+
c.AddPaths(paths, ClipperLib.PolyType.ptSubject, true);
51+
const solution = new ClipperLib.Paths();
52+
const ok = c.Execute(
53+
ClipperLib.ClipType.ctUnion,
54+
solution,
55+
ClipperLib.PolyFillType.pftNonZero,
56+
ClipperLib.PolyFillType.pftNonZero,
57+
);
58+
const inv = 1 / s;
59+
if (!ok || !solution.length) {
60+
return [cm.pathPolygon(valid[0])];
61+
}
62+
const out = solution
63+
.filter((path) => path.length >= 3)
64+
.map((path) => cm.pathPolygon(path.map(({X, Y}) => [X * inv, Y * inv])));
65+
return out.length ? out : [cm.pathPolygon(valid[0])];
66+
}
67+
68+
export function getScreenInsetBounds(width, height) {
69+
const maxPad = Math.max(0, (Math.min(width, height) - 8) / 2);
70+
const p = Math.min(SCREEN_PATH_PADDING, maxPad);
71+
const pt = Math.min(SCREEN_PATH_PADDING_TOP, maxPad);
72+
const innerW = Math.max(1, width - 2 * p);
73+
const innerH = Math.max(1, height - pt - p);
74+
return {
75+
p,
76+
pt,
77+
innerW,
78+
innerH,
79+
left: p,
80+
top: pt,
81+
right: p + innerW,
82+
bottom: pt + innerH,
83+
};
84+
}
85+
86+
function clipPolygonRingToInset(ring, left, top, right, bottom) {
87+
const s = CLIPPER_SCALE;
88+
if (ring.length < 3) {
89+
return [];
90+
}
91+
const subj = [
92+
ring.map(([x, y]) => ({
93+
X: Math.round(x * s),
94+
Y: Math.round(y * s),
95+
})),
96+
];
97+
const clip = [
98+
[
99+
{X: Math.round(left * s), Y: Math.round(top * s)},
100+
{X: Math.round(right * s), Y: Math.round(top * s)},
101+
{X: Math.round(right * s), Y: Math.round(bottom * s)},
102+
{X: Math.round(left * s), Y: Math.round(bottom * s)},
103+
],
104+
];
105+
const c = new ClipperLib.Clipper();
106+
c.AddPaths(subj, ClipperLib.PolyType.ptSubject, true);
107+
c.AddPaths(clip, ClipperLib.PolyType.ptClip, true);
108+
const solution = new ClipperLib.Paths();
109+
const ok = c.Execute(
110+
ClipperLib.ClipType.ctIntersection,
111+
solution,
112+
ClipperLib.PolyFillType.pftNonZero,
113+
ClipperLib.PolyFillType.pftNonZero,
114+
);
115+
if (!ok || !solution.length) {
116+
return [];
117+
}
118+
const inv = 1 / s;
119+
const out = [];
120+
for (const path of solution) {
121+
if (path.length < 3) {
122+
continue;
123+
}
124+
out.push(path.map(({X, Y}) => [X * inv, Y * inv]));
125+
}
126+
return out;
127+
}
128+
129+
function samplePartIdGrid(imageData, mw, mh) {
130+
const iw = imageData.width;
131+
const ih = imageData.height;
132+
const ids = new Int32Array(mw * mh);
133+
const alpha = new Uint8Array(mw * mh);
134+
for (let j = 0; j < mh; j++) {
135+
for (let i = 0; i < mw; i++) {
136+
const sx = Math.min(iw - 1, Math.floor((i + 0.5) * (iw / mw)));
137+
const sy = Math.min(ih - 1, Math.floor((j + 0.5) * (ih / mh)));
138+
const o = (sy * iw + sx) * 4;
139+
const ix = j * mw + i;
140+
ids[ix] = imageData.data[o];
141+
alpha[ix] = imageData.data[o + 3];
142+
}
143+
}
144+
return {ids, alpha};
145+
}
146+
147+
function contourBinaryMaskToRings(values, mw, mh, layerW, layerH, minAreaGrid, inset) {
148+
let sum = 0;
149+
for (let i = 0; i < values.length; i++) {
150+
sum += values[i];
151+
}
152+
if (sum < minAreaGrid) {
153+
return [];
154+
}
155+
156+
const contourGen = d3.contours().size([mw, mh]).smooth(true).thresholds([0.5]);
157+
const layers = contourGen(values);
158+
if (!layers.length) {
159+
return [];
160+
}
161+
162+
const multi = layers[0];
163+
if (!multi.coordinates?.length) {
164+
return [];
165+
}
166+
167+
const sx = layerW / mw;
168+
const sy = layerH / mh;
169+
const maxHoleArea = layerW * layerH * 0.45;
170+
const out = [];
171+
172+
for (const poly of multi.coordinates) {
173+
if (!poly?.length) {
174+
continue;
175+
}
176+
const outer = poly[0];
177+
const aGrid = Math.abs(d3.polygonArea(outer));
178+
if (aGrid < minAreaGrid || outer.length < 4) {
179+
continue;
180+
}
181+
const aLayer = aGrid * sx * sy;
182+
if (aLayer > maxHoleArea) {
183+
continue;
184+
}
185+
const scaled = outer.map(([x, y]) => [x * sx, y * sy]);
186+
const clipped = clipPolygonRingToInset(scaled, inset.left, inset.top, inset.right, inset.bottom);
187+
for (const ring of clipped) {
188+
out.push(ring);
189+
}
190+
}
191+
192+
return out;
193+
}
194+
195+
export function maskImageDataToPartHoleRings(imageData, layerW, layerH) {
196+
if (!imageData?.data || imageData.width < 2 || imageData.height < 2) {
197+
return [];
198+
}
199+
200+
const inset = getScreenInsetBounds(layerW, layerH);
201+
const iw = imageData.width;
202+
const ih = imageData.height;
203+
const scale = Math.min(MASK_CONTOUR_MAX_DIM / iw, MASK_CONTOUR_MAX_DIM / ih, 1);
204+
const mw = Math.max(8, Math.floor(iw * scale));
205+
const mh = Math.max(8, Math.floor(ih * scale));
206+
207+
const {ids, alpha} = samplePartIdGrid(imageData, mw, mh);
208+
const seen = new Set();
209+
for (let i = 0; i < ids.length; i++) {
210+
if (alpha[i] <= 40) {
211+
continue;
212+
}
213+
const pid = ids[i];
214+
if (pid >= 0 && pid < BODYPIX_NUM_PARTS) {
215+
seen.add(pid);
216+
}
217+
}
218+
219+
const sortedParts = Array.from(seen).sort((a, b) => a - b);
220+
const values = new Float64Array(mw * mh);
221+
const minAreaGrid = 6;
222+
const holeRings = [];
223+
224+
for (const pid of sortedParts) {
225+
for (let i = 0; i < values.length; i++) {
226+
values[i] = ids[i] === pid && alpha[i] > 40 ? 1 : 0;
227+
}
228+
holeRings.push(...contourBinaryMaskToRings(values, mw, mh, layerW, layerH, minAreaGrid, inset));
229+
}
230+
231+
return holeRings;
232+
}
233+
234+
export function buildTextPath(width, height, holes) {
235+
const {p, pt, innerW, innerH} = getScreenInsetBounds(width, height);
236+
const outer = cm.pathRect(p, pt, innerW, innerH);
237+
const list = Array.isArray(holes) ? holes.filter(Boolean) : holes ? [holes] : [];
238+
if (list.length === 0) {
239+
return outer;
240+
}
241+
return `${outer} ${list.join(" ")}`;
242+
}

example/air-text/index.css

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
* {
2+
margin: 0;
3+
padding: 0;
4+
box-sizing: border-box;
5+
}
6+
7+
:root {
8+
--air-bg: #0c0c0e;
9+
--air-text: #b8b8c2;
10+
--air-title: #eaeaf0;
11+
}
12+
13+
html,
14+
body {
15+
width: 100%;
16+
height: 100%;
17+
overflow: hidden;
18+
background: var(--air-bg);
19+
color-scheme: dark;
20+
}
21+
22+
.stage {
23+
position: relative;
24+
width: 100vw;
25+
width: 100dvw;
26+
height: 100vh;
27+
height: 100dvh;
28+
min-height: 100%;
29+
}
30+
31+
.stage__inner {
32+
position: relative;
33+
width: 100%;
34+
height: 100%;
35+
background: var(--air-bg);
36+
overflow: hidden;
37+
}
38+
39+
/* Fills the stage; object-fit fill matches ml5 scaling to the element box. */
40+
.stage__webcam {
41+
position: absolute;
42+
inset: 0;
43+
z-index: 0;
44+
width: 100%;
45+
height: 100%;
46+
object-fit: fill;
47+
opacity: 0;
48+
pointer-events: none;
49+
}
50+
51+
.stage__inner .line {
52+
z-index: 1;
53+
}
54+
55+
.stage__inner .title-line {
56+
z-index: 2;
57+
}
58+
59+
.title-line {
60+
position: absolute;
61+
transform-origin: left center;
62+
white-space: pre;
63+
line-height: 1;
64+
font: inherit;
65+
user-select: text;
66+
color: var(--air-title);
67+
cursor: text;
68+
}
69+
70+
.line {
71+
position: absolute;
72+
transform-origin: center;
73+
white-space: pre;
74+
line-height: 1;
75+
font: inherit;
76+
user-select: text;
77+
color: var(--air-text);
78+
cursor: text;
79+
}

example/air-text/index.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Air text</title>
7+
<link rel="stylesheet" href="index.css" />
8+
</head>
9+
<body>
10+
<div id="stage" class="stage">
11+
<div class="stage__inner" id="layer">
12+
<video id="webcam" class="stage__webcam" playsinline muted autoplay></video>
13+
</div>
14+
</div>
15+
<script src="index.js" type="module"></script>
16+
</body>
17+
</html>

0 commit comments

Comments
 (0)