paperlab 0.7.1 → 0.8.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.
Files changed (37) hide show
  1. package/README.md +57 -28
  2. package/dist/{FxPostPass-M66QMVVC.js → FxPostPass-ACFSHY3S.js} +24 -6
  3. package/dist/FxPostPass-ACFSHY3S.js.map +1 -0
  4. package/dist/chunk-BIFUJCUA.js +34 -0
  5. package/dist/chunk-BIFUJCUA.js.map +1 -0
  6. package/dist/{chunk-NTDVOOBA.js → chunk-LUKNPQ3S.js} +385 -165
  7. package/dist/chunk-LUKNPQ3S.js.map +1 -0
  8. package/dist/{chunk-GB6BMHC3.js → chunk-S74YZAXE.js} +7 -19
  9. package/dist/chunk-S74YZAXE.js.map +1 -0
  10. package/dist/{damageContract-DPF1YFLZ.d.cts → damageContract-9RC4egJg.d.cts} +31 -3
  11. package/dist/{damageContract-DPF1YFLZ.d.ts → damageContract-9RC4egJg.d.ts} +31 -3
  12. package/dist/fx.cjs +336 -107
  13. package/dist/fx.cjs.map +1 -1
  14. package/dist/fx.d.cts +113 -89
  15. package/dist/fx.d.ts +113 -89
  16. package/dist/fx.js +293 -63
  17. package/dist/fx.js.map +1 -1
  18. package/dist/index.cjs +422 -193
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +13 -5
  21. package/dist/index.d.ts +13 -5
  22. package/dist/index.js +21 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/{slots-C5PqY0Q5.d.cts → slots-DbJQfwVd.d.cts} +13 -1
  25. package/dist/{slots-C5PqY0Q5.d.ts → slots-DbJQfwVd.d.ts} +13 -1
  26. package/dist/stage.cjs +417 -201
  27. package/dist/stage.cjs.map +1 -1
  28. package/dist/stage.d.cts +1 -1
  29. package/dist/stage.d.ts +1 -1
  30. package/dist/stage.js +12 -12
  31. package/dist/stage.js.map +1 -1
  32. package/package.json +5 -5
  33. package/dist/FxPostPass-M66QMVVC.js.map +0 -1
  34. package/dist/chunk-7TLIWPGM.js +0 -36
  35. package/dist/chunk-7TLIWPGM.js.map +0 -1
  36. package/dist/chunk-GB6BMHC3.js.map +0 -1
  37. package/dist/chunk-NTDVOOBA.js.map +0 -1
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
  [![CI](https://github.com/NourMtir0722/Paperlab/actions/workflows/ci.yml/badge.svg)](https://github.com/NourMtir0722/Paperlab/actions/workflows/ci.yml)
7
7
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/NourMtir0722/Paperlab/blob/main/LICENSE)
8
8
 
9
- A hero image that peels, a receipt that unrolls, a letter that folds, a poster rippling in wind, a gallery ring of prints. A sheet is real 3D geometry, not a CSS trick and not a video — content is a texture on a mesh that genuinely bends, so text and imagery curl with perfect continuity.
9
+ A hero image that peels, a receipt that unrolls, a letter that folds, a poster rippling in wind, a gallery ring of prints — and a sheet that burns. A sheet is real 3D geometry, not a CSS trick and not a video — content is a texture on a mesh that genuinely bends, so text and imagery curl with perfect continuity.
10
10
 
11
- **[Try it →](https://paperlab.nawwara.studio/)** · [the editor](https://paperlab.nawwara.studio/editor/) (desktop) · [the reference](https://paperlab.nawwara.studio/docs/) · [with your hands](https://paperlab.nawwara.studio/hands/) (webcam) · [for coding agents](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md)
11
+ **[Try it →](https://paperlab.nawwara.studio/)** · [the editor](https://paperlab.nawwara.studio/editor/) (desktop) · [the reference](https://paperlab.nawwara.studio/docs/) · [with your hands](https://paperlab.nawwara.studio/hands/) (webcam) · [the FX Lab](https://paperlab.nawwara.studio/fx-lab/) · [for coding agents](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md)
12
12
 
13
13
  | | |
14
14
  |---|---|
@@ -27,15 +27,21 @@ Requires React ≥ 19 and three ≥ 0.162. TypeScript types ship with the packag
27
27
 
28
28
  > **npm users:** `@react-three/fiber@9.7.0` caps React at `>=19 <19.3`, and npm installs React 19.3 by default, so the line above can fail with `ERESOLVE`. Ask for a React the renderer accepts — `npm i paperlab three @react-three/fiber gsap react@19.2 react-dom@19.2` — until fiber widens its range. pnpm and yarn resolve it without complaint. This is upstream's ceiling, not Paperlab's: the library itself supports any React ≥ 19.
29
29
 
30
- Stage mode lives at the `paperlab/stage` subpath and needs two more peers:
30
+ Paperlab has three entry points, and a bundle only resolves the ones it imports:
31
+
32
+ | | |
33
+ |---|---|
34
+ | `paperlab` | `<Paper>`, `<PaperMesh>` and `<PaperField>` — the sheet, and many sheets |
35
+ | `paperlab/stage` | `<PaperStage>` — paper as architecture |
36
+ | `paperlab/fx` | what happens **to** the paper — the fire, and everything it throws off |
37
+
38
+ Stage mode and the fire's glow need two more peers:
31
39
 
32
40
  ```sh
33
41
  npm i @react-three/postprocessing postprocessing
34
42
  ```
35
43
 
36
- They are genuinely optional, in both directions. `<Paper>` and `<PaperField>` never reach for them, so a bundle that does not import `paperlab/stage` contains none of it — the subpath keeps the import specifier itself out of your build graph. And `paperlab/stage` loads the print pass on demand rather than at module scope, so a stage renders without them too: you lose the tone curve, bloom, vignette and grain, and the console says so once.
37
-
38
- `paperlab/fx` is the third entry point: what happens **to** the paper. A sheet takes `<Paper damage={source}>` — char, water, heat and missing paper over its own UV — and draws it, cuts its shadow with it, and on a simulated sheet shrinks, curls, weighs down and breaks the paper it describes. What *causes* damage lives behind the subpath: a field that burns, soaks and erodes; the embers, smoke and ash a burn throws off; and a fire synthesised from the same numbers the picture is drawn from. A `<Paper>`-only bundle resolves none of it.
44
+ They are genuinely optional, in both directions. `<Paper>` and `<PaperField>` never reach for them, so a bundle that does not import `paperlab/stage` or `paperlab/fx` contains none of it — the subpath keeps the import specifier itself out of your build graph. And both load their print pass on demand rather than at module scope, so they render without the peers too: you lose the tone curve, bloom, vignette and grain, and the console says so once.
39
45
 
40
46
  ## Quick start
41
47
 
@@ -112,6 +118,21 @@ A fold that closes past 45° at a line that stays put leaves a crease behind at
112
118
  <Paper preset="letter-fold" memory={{ set: 0.6 }} onCrease={save} />
113
119
  ```
114
120
 
121
+ ### Fire — paper that burns
122
+
123
+ `<Paper damage={source}>` hands a sheet a grid of char, water, heat and missing paper over its own UV, and the sheet draws it without knowing what caused it — the same split `content` already has. So a live simulation, a baked texture and a recorded burn played back all work the same way.
124
+
125
+ A burnt edge is drawn in the layers a real one has, measured in millimetres from the cut: a pale ash lip, a beaded ember line (the only part of the sheet that gives off light), a black char band, and a scorch with a steep edge into clean paper. On a cloth sheet the same grid reaches the physics too — char shrinks the paper and curls it toward the flame, wet paper gets heavier, and paper that has burnt away leaves the simulation, so a piece the fire cuts loose falls away with its own burnt edge.
126
+
127
+ What *causes* the damage lives in `paperlab/fx`:
128
+
129
+ - **`DamageField`** — heat, water, char and presence, simulated on the CPU with the paper's grain and fibre direction in it. It is untiered, so the same seed burns the same way on any device, and it burns at the speed real paper does: a few millimetres a second.
130
+ - **`FxFireFluid`** — the flames: a small GPU fire simulation fed from the hot rim in uneven clusters. Fuel burns where air reaches it, so a tongue has a pale core, a yellow body and a torn tip, and every zone of it is an option.
131
+ - **`FxFireLight`** — the burn is a light source. It lights the sheet, glows through it, and through `DamageSource.firelight` the room it burns in dims around it.
132
+ - **The rest of a fire** — embers, smoke and ash (`FxParticles`), a match held in a hand (`FxMatchFlame`), the thread of smoke from a smouldering bead (`FxWisps`), the embers left after the flames (`Afterglow`), and the bloom and heat haze (`FxPost`).
133
+
134
+ Every default is a tune made in the [FX Lab](https://paperlab.nawwara.studio/fx-lab/), and every one of them is an option you can set. See [AGENTS.md](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md) for the wiring.
135
+
115
136
  ### Layouts — 12
116
137
 
117
138
  ![All twelve field layouts rendered side by side](https://paperlab.nawwara.studio/media/layouts.jpg)
@@ -156,19 +177,20 @@ And it is navigable rather than a video. It drifts on its own until you touch it
156
177
 
157
178
  - **Content** — `blank`, `image`, `text`, `card` and `receipt`, any of which can also sit on the reverse of the sheet via `content.back`. Text is measured and wrapped with real tracking applied *before* measurement, so the painted line matches the line it was broken to.
158
179
  - **Physics** — curated idle motion (`float`, `tumble`, `dangle`, `taped`, `breeze`) that composes with behaviors, and a verlet **cloth** mode: pin the top edge, add wind, grab the sheet and pull. Cloth and behaviors are mutually exclusive by schema — cloth owns the vertices.
180
+ - **A floor** — `scene.floor` puts dark, matte ground under the paper. The contact shadow, a simulated sheet and anything the paper lets go of all meet at the same height, so a falling piece lands on the thing casting its shadow instead of through it.
159
181
  - **Interaction states** — a preset can carry `states`: overrides-on-base diffs keyed `rest` / `hover` / `pressed` / `picked` / `placed`, with the triggers built in. Drag a stamp past its threshold and it tears off its sheet (the perforation edges facing its neighbours flip to torn), release it over a `<DropZone>` and it settles, release it anywhere else and it flutters home. The whole flow is reachable from the keyboard: focus a paper, Enter picks, arrows move between zones, Enter places, Escape returns it.
160
182
  - **Hardware that holds the paper up** — thread to the ceiling or a rod across the top edge, gripped by a clip or a peg. A hung thing that shows what holds it stops reading as a rectangle that happens to float.
161
183
  - **Presets** — 18 paper presets and 6 stage presets, and everything serializes to `.paper` JSON validated by a zod schema. Diffable, forkable, shareable.
162
- - **Agent-first export** — the editor's **Copy for AI** button produces a self-contained brief you paste into a coding agent: install line, inlined component, placement contract, and a verification step the agent can self-check. See [AGENTS.md](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md) and [docs/llms.txt](https://github.com/NourMtir0722/Paperlab/blob/main/docs/llms.txt).
163
- - **Accessible by default** — `prefers-reduced-motion` freezes behaviors at their pose and disables physics and entrances, a hidden DOM mirror carries the content for screen readers and find-in-page, and a flat DOM fallback renders when WebGL isn't available.
184
+ - **Agent-first export** — the editor's **Copy for AI** produces a self-contained brief you paste into a coding agent: install line, inlined component, placement contract, and a verification step the agent can self-check. See [AGENTS.md](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md) and [docs/llms.txt](https://github.com/NourMtir0722/Paperlab/blob/main/docs/llms.txt).
185
+ - **Accessible by default** — `prefers-reduced-motion` freezes behaviors at their pose and disables physics, entrances and the fire's simulations, a hidden DOM mirror carries the content for screen readers and find-in-page, and a flat DOM fallback renders when WebGL isn't available.
164
186
 
165
- The zod schema in `config/schema.ts` is the single source of truth: it validates the API, generates the editor's controls, defines the file format and feeds the docs. If a feature can't serialize into a preset, it doesn't ship.
187
+ The zod schema in `config/schema.ts` is the single source of truth: it validates the API, generates the editor's controls, defines the file format and feeds the docs. If a feature can't serialize into a preset, it doesn't ship. (Damage is the one deliberate exception: it is live state, so it is a prop and never part of a preset or a share link.)
166
188
 
167
189
  ## Papers are made to be passed around
168
190
 
169
191
  A paper is data — a `.paper` JSON object validated by a zod schema — so it travels without asking anyone's permission. **You do not need to fork this repo to share one.**
170
192
 
171
- **Sending one.** Sculpt a paper in the [editor](https://paperlab.nawwara.studio/editor/) and hit **Share**: you get a link with the whole paper packed into it. Anyone who opens that link lands in their own editor with your paper loaded and *editable* — a fork, not a read-only view. (Uploaded images are too big for a URL; use the ⬇ download and send the `.paper` file instead.)
193
+ **Sending one.** Sculpt a paper in the [editor](https://paperlab.nawwara.studio/editor/) and pick **Export → Copy share link**: you get a link with the whole paper packed into it. Anyone who opens that link lands in their own editor with your paper loaded and *editable* — a fork, not a read-only view. (Uploaded images are too big for a URL; use the ⬇ download and send the `.paper` file instead.)
172
194
 
173
195
  **Receiving one.** Open the link, or drag a `.paper` file onto the preset panel. Either way it lands in your library next to the built-ins.
174
196
 
@@ -193,11 +215,11 @@ That's the whole loop: **make → send → remix → ship.** If you'd rather you
193
215
 
194
216
  ## The apps
195
217
 
196
- Four surfaces ship alongside the library, all built on its public API only.
218
+ Three sites ship alongside the library, all built on its public API only.
197
219
 
198
220
  **[The playground](https://paperlab.nawwara.studio/playground/)** — one input, one scene, shareable by link. Type a sentence and it builds you a room out of it. Built for a phone.
199
221
 
200
- **[The editor](https://paperlab.nawwara.studio/editor/)** — a three-rail canvas tool: presets on the left, sculpt on canvas, inspector on the right, transport at the bottom (space = play/pause), undo and redo on ⌘Z.
222
+ **[The editor](https://paperlab.nawwara.studio/editor/)** — a three-rail canvas tool: presets on the left, sculpt on canvas, inspector on the right, transport at the bottom (space = play/pause), undo and redo on ⌘Z. One switch along the top moves between five surfaces that all wear that same frame — **Paper · Field · Stage · Hands · FX Lab** — and the top-right holds a single button, **Export**.
201
223
 
202
224
  ![The Paperlab editor in paper mode, showing a thermal receipt on the canvas with the inspector open](https://paperlab.nawwara.studio/media/editor.jpg)
203
225
 
@@ -205,13 +227,14 @@ The inspector is generated from the zod schema, so it can never drift from the A
205
227
 
206
228
  ![The editor in field mode, fourteen cards arranged in a ring with the layout panel open](https://paperlab.nawwara.studio/media/editor-field.jpg)
207
229
 
208
- Field mode composes galleries against the same panel — swap the layout, watch fourteen papers rearrange in one draw call. **Export code** ends the session in your codebase, and **Copy for AI** ends it in a coding agent's. It wants a real screen: under about 900px it says so and points you at the playground.
230
+ Field mode composes galleries against the same panel — swap the layout, watch fourteen papers rearrange in one draw call. **Export** ends the session wherever you are going next: a share link, an image framed for a post, a story or a slide, the component or its JSON for your codebase, or **Copy for AI** for a coding agent. It wants a real screen: under about 900px it says so and points you at the playground.
209
231
 
210
- **[The reference](https://paperlab.nawwara.studio/docs/)** — the whole catalogue with every behavior, deformer, layout, stock and surface rendering live. The catalogue is generated from the registries, so it cannot advertise something the library doesn't have.
232
+ - **[FX Lab](https://paperlab.nawwara.studio/fx-lab/)** — every knob behind the burn, around the sheet it is burning: the flame's zones, the fluid, the bloom, the light it throws, the ash lip and the char, how fast it eats and how much of the sheet it takes. The library's defaults are a tune made here. Scrub the burn, turn something, and **Export** copies the tune out as JSON to pass to `<Paper>`.
233
+ - **[Hands](https://paperlab.nawwara.studio/hands/)** — set fire to the paper with a real flame. Hold a lighter up to the camera and the sheet catches where the flame is; with no lighter, pinch and hold still and you are holding a match; with no camera, there is a button. The page draws your hand as it tracks it and boxes the flame when it finds one, and once a flame touches the paper the fire runs from there until the whole sheet has burnt. Blow to put it out. It burns the way the FX Lab burns, from the same defaults, and the page is a hundred percent public API — `packages/paperlab` doesn't know it exists.
211
234
 
212
- **[Your hands](https://paperlab.nawwara.studio/hands/)** the same paper, driven by a webcam instead of a mouse. Pinch to take hold and pull, point to score a line, make a fist to fold along it, turn your palm to change the stock, flick paint at it, blow at it to raise the wind, pull an edge to tear it. Every gesture lands on a feature the library already ships the page is a hundred percent public API, and `packages/paperlab` doesn't know it exists.
235
+ The flame detector is arithmetic over the camera's pixels, not a model: a flame is bright, warm in the order red-green-blue, and never still, and the last of those three is what keeps a desk lamp from lighting your paper. The hand tracking is [MediaPipe](https://ai.google.dev/edge/mediapipe) (`@mediapipe/tasks-vision`, Apache-2.0) and it runs entirely in your browser: the models download from Google once, and after that no video and no measurement taken from it leaves the device. There is no server to send it to, and a `connect-src` CSP on the page makes that enforceable rather than a promise including against MediaPipe's own usage telemetry, which the page blocks. Needs a camera, and asks before it takes one.
213
236
 
214
- The tracking is [MediaPipe](https://ai.google.dev/edge/mediapipe) (`@mediapipe/tasks-vision`, Apache-2.0) and it runs entirely in your browser: the models download from Google once, and after that no video and no measurement taken from it leaves the device. There is no server to send it to, and a `connect-src` CSP on the page makes that enforceable rather than a promise — including against MediaPipe's own usage telemetry, which the page blocks. Needs a camera, and asks before it takes one.
237
+ **[The reference](https://paperlab.nawwara.studio/docs/)** the whole catalogue with every behavior, deformer, layout, stock and surface rendering live. The catalogue is generated from the registries, so it cannot advertise something the library doesn't have.
215
238
 
216
239
  ## Development
217
240
 
@@ -219,36 +242,41 @@ pnpm + Turborepo, Node 22, [Biome](https://biomejs.dev) for lint and format.
219
242
 
220
243
  ```sh
221
244
  pnpm install
245
+ pnpm hands:setup # once — vendors the hand tracker's wasm and models (35 MB, not committed)
222
246
  pnpm dev # the editor at localhost:5173
223
247
  ```
224
248
 
225
249
  | | |
226
250
  |---|---|
227
251
  | [`packages/paperlab`](https://github.com/NourMtir0722/Paperlab/blob/main/packages/paperlab/) | the npm library — the only published artifact |
228
- | [`apps/editor`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/editor/) | the editor — every knob, and the export. Also the `/hands` page, built from the same app in a second pass |
252
+ | [`apps/editor`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/editor/) | the editor — Paper, Field and Stage, plus Hands (`/hands`) and the FX Lab (`/fx-lab`), each built from the same app in a pass of its own |
229
253
  | [`apps/playground`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/playground/) | the playground — one input, one scene, shareable by link |
230
254
  | [`apps/docs`](https://github.com/NourMtir0722/Paperlab/blob/main/apps/docs/) | the reference site, with every behavior running live |
231
- | [`tools/`](https://github.com/NourMtir0722/Paperlab/blob/main/tools/) | browser harnesses — parity, perf, screenshots, the README's motion |
255
+ | [`tools/`](https://github.com/NourMtir0722/Paperlab/blob/main/tools/) | browser harnesses — parity, perf, screenshots, the fire's checks, the README's motion |
232
256
  | [`AGENTS.md`](https://github.com/NourMtir0722/Paperlab/blob/main/AGENTS.md) · [`docs/llms.txt`](https://github.com/NourMtir0722/Paperlab/blob/main/docs/llms.txt) | the agent-readable API reference |
233
- | [`docs/design.md`](https://github.com/NourMtir0722/Paperlab/blob/main/docs/design.md) | the design language the three apps share, enforced by a test |
257
+ | [`docs/design.md`](https://github.com/NourMtir0722/Paperlab/blob/main/docs/design.md) | the design language the apps share, enforced by a test |
234
258
 
235
259
  ### Checks
236
260
 
237
261
  ```sh
238
- pnpm test # 700+ unit tests — deformer math, schema, cloth, layouts, exports
239
- pnpm test:parity # 37 golden-vector cases: every deformer's GLSL twin vs its JS twin
240
- pnpm test:drive # the stage really walks when you drag, wheel or arrow it
241
- pnpm test:share # sculpt → link → a browser that has never seen the paper
242
- pnpm test:dropdown # every dropdown option is reachable, including below the fold
243
- pnpm test:route # the site root routes by device, and links every route it deploys
244
- pnpm test:hands # scripted gestures really reach the paper (needs a camera-less Chromium)
262
+ pnpm test # 1,100+ unit tests — deformer math, schema, cloth, layouts, the fire, exports
263
+ pnpm test:parity # 37 golden-vector cases: every deformer's GLSL twin vs its JS twin
264
+ pnpm test:drive # the stage really walks when you drag, wheel or arrow it
265
+ pnpm test:share # sculpt → link → a browser that has never seen the paper
266
+ pnpm test:dropdown # every dropdown option is reachable, including below the fold
267
+ pnpm test:route # the site root routes by device, and links every route it deploys
268
+ pnpm test:consumer # installs the packed library into an empty project and imports it
269
+ pnpm test:damage # an untouched damage field changes not one pixel
270
+ pnpm test:fire-budget # what a burn must measure in the frame — the stage stays black, paper never blooms
271
+ pnpm test:hands # the ways to light the paper really light it (needs a camera-less Chromium)
272
+ pnpm test:fire-look # the burn, moment by moment, beside the stills it is judged against
245
273
  pnpm typecheck
246
274
  pnpm lint
247
- pnpm knip # dead code and unused exports
275
+ pnpm knip # dead code and unused exports
248
276
  pnpm build
249
277
  ```
250
278
 
251
- Anything that needs a real GPU, real pointer events or a second browser profile is a browser harness in `tools/` rather than a unit test. All of them but `test:hands` are CI gates, along with `publint` and `are-the-types-wrong` on the published package. `test:hands` runs in [its own workflow](https://github.com/NourMtir0722/Paperlab/blob/main/.github/workflows/hands.yml) instead — on the paths that can break it, and weekly — because it fetches its models from Google and a required gate would let someone else's CDN block every unrelated PR. A red X you have to read, rather than a veto.
279
+ Anything that needs a real GPU, real pointer events or a second browser profile is a browser harness in `tools/` rather than a unit test. All of them but `test:hands` and `test:fire-look` are CI gates, along with `publint` and `are-the-types-wrong` on the published package. `test:hands` runs in [its own workflow](https://github.com/NourMtir0722/Paperlab/blob/main/.github/workflows/hands.yml) instead — on the paths that can break it, and weekly — because it fetches its models from Google and a required gate would let someone else's CDN block every unrelated PR. A red X you have to read, rather than a veto. `test:fire-look` is run by hand: it lays each render beside a reference still, those stills are kept out of the repo, and passing it still takes someone looking.
252
280
 
253
281
  ### Measurement
254
282
 
@@ -260,6 +288,7 @@ pnpm shot:light # every lighting rig, one frame each
260
288
  pnpm shot:catalogue # sweep any axis — --vary=layout|stock|lighting --all
261
289
  pnpm sheet # compose those frames into a labelled contact sheet
262
290
  pnpm media # the README's GIFs and MP4s, stepped frame-exact (needs ffmpeg)
291
+ pnpm film # the burn playing, first contact to cold, one video per origin (needs ffmpeg)
263
292
  ```
264
293
 
265
294
  ## Contributing
@@ -4,11 +4,11 @@ import {
4
4
  PAPER_WHITE,
5
5
  flameAnchors,
6
6
  fxQualityFor
7
- } from "./chunk-GB6BMHC3.js";
8
- import "./chunk-7TLIWPGM.js";
7
+ } from "./chunk-S74YZAXE.js";
8
+ import "./chunk-BIFUJCUA.js";
9
9
 
10
10
  // src/fx/FxPostPass.tsx
11
- import { Bloom, EffectComposer, ToneMapping } from "@react-three/postprocessing";
11
+ import { Bloom, DepthOfField, EffectComposer, ToneMapping } from "@react-three/postprocessing";
12
12
  import { useFrame } from "@react-three/fiber";
13
13
  import { ToneMappingMode } from "postprocessing";
14
14
  import { useMemo, useRef } from "react";
@@ -61,7 +61,7 @@ void mainUv(inout vec2 uv) {
61
61
  }
62
62
 
63
63
  // The grade: the whole frame warms as the fire grows \u2014 a few percent, no
64
- // more (spec \xA77). A multiply, so black stays black.
64
+ // more. A multiply, so black stays black.
65
65
  void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
66
66
  outputColor = vec4(inputColor.rgb * mix(vec3(1.0), uWarm, uGrade), inputColor.a);
67
67
  }
@@ -117,12 +117,14 @@ function FxPostPass({
117
117
  threshold = FX_BLOOM_THRESHOLD,
118
118
  field,
119
119
  locate,
120
- haze: hazeOverride
120
+ haze: hazeOverride,
121
+ focus = 0
121
122
  }) {
122
123
  const { bloomScale, haze: tierHaze } = fxQualityFor(quality);
123
124
  const hazePx = hazeOverride ?? tierHaze;
124
125
  const haze = useMemo(() => new HazeGradeEffect(), []);
125
126
  const anchors = useRef([]);
127
+ const focusTarget = useRef(new THREE2.Vector3());
126
128
  useFrame(({ camera }) => {
127
129
  if (!field || !locate) {
128
130
  haze.count = 0;
@@ -137,6 +139,14 @@ function FxPostPass({
137
139
  top.set(a.x, a.y + a.height, a.z).project(camera);
138
140
  sources[i].set(root.x * 0.5 + 0.5, root.y * 0.5 + 0.5, Math.max(0, (top.y - root.y) * 0.5), a.heat);
139
141
  }
142
+ if (focus > 0 && n > 0) {
143
+ focusTarget.current.set(0, 0, 0);
144
+ for (let i = 0; i < n; i++) {
145
+ const a = anchors.current[i];
146
+ focusTarget.current.add(root.set(a.x, a.y, a.z));
147
+ }
148
+ focusTarget.current.multiplyScalar(1 / n);
149
+ }
140
150
  haze.count = n;
141
151
  haze.time = field.time;
142
152
  haze.amount = hazePx;
@@ -154,6 +164,14 @@ function FxPostPass({
154
164
  }
155
165
  ) : null,
156
166
  /* @__PURE__ */ jsx("primitive", { object: haze }),
167
+ focus > 0 ? /* @__PURE__ */ jsx(
168
+ DepthOfField,
169
+ {
170
+ target: focusTarget.current,
171
+ focalLength: 0.06 - 0.045 * focus,
172
+ bokehScale: 1 + 3 * focus
173
+ }
174
+ ) : null,
157
175
  /* @__PURE__ */ jsx(ToneMapping, { mode: modes[film] })
158
176
  ] });
159
177
  }
@@ -163,4 +181,4 @@ export {
163
181
  FxPostPass,
164
182
  PAPER_WHITE
165
183
  };
166
- //# sourceMappingURL=FxPostPass-M66QMVVC.js.map
184
+ //# sourceMappingURL=FxPostPass-ACFSHY3S.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/fx/FxPostPass.tsx","../src/fx/haze.ts"],"sourcesContent":["import { Bloom, DepthOfField, EffectComposer, ToneMapping } from '@react-three/postprocessing'\nimport { useFrame } from '@react-three/fiber'\nimport { ToneMappingMode } from 'postprocessing'\nimport { useMemo, useRef } from 'react'\nimport * as THREE from 'three'\nimport { FX_BLOOM, FX_BLOOM_THRESHOLD, PAPER_WHITE } from './emission'\nimport type { FxPostProps } from './FxPost'\nimport { flameAnchors, type FlameAnchor } from './flames'\nimport { HAZE_SOURCES, HazeGradeEffect } from './haze'\nimport { fxQualityFor } from './quality'\n\n/**\n * Where fire's glow comes from: bloom on the HDR frame, the heat haze and the\n * warm grade, then the tone curve.\n *\n * The ONLY file in `paperlab/fx` that imports `@react-three/postprocessing`\n * (with `haze.ts`, which only this imports), and nothing imports it\n * statically — `FxPost` reaches it through a dynamic import, so the two peers\n * stay optional the way stage's `Grade` keeps them. Importing this from\n * anywhere else would put the specifier back into fx's module graph and a\n * consumer without the peers could no longer load fx.\n *\n * Why it exists: the\n * first fire's glow was PAINT — a warm colour added to `csm_Emissive` over\n * paper that had not burnt — and red added to white makes pink. Real glow is\n * light too bright for the film: an ember is brighter than the whitest paper\n * in the frame, the lens spreads it, and the curve rolls it off to white.\n * Nothing on the sheet paints glow any more; anything that wants to glow\n * emits past paper white and this makes it bloom.\n *\n * Order is the whole argument: bloom reads the frame while it is still HDR —\n * which is what lets a threshold mean \"brighter than paper\" — the haze bends\n * the light above the flames and the grade warms the frame, and the curve\n * lands the result last.\n *\n * **The composer takes the tone curve off the renderer, so this gives it\n * back.** `<EffectComposer>` sets `gl.toneMapping = NoToneMapping` while it is\n * mounted; without the `<ToneMapping>` below, mounting this pass would\n * silently throw away the rig's film and the unburnt sheet would change. The\n * test for this pass is that it does not: with no fire in frame,\n * post on and post off are the same picture (`pnpm test:fire-look`).\n */\n\nconst modes = {\n agx: ToneMappingMode.AGX,\n neutral: ToneMappingMode.NEUTRAL,\n filmic: ToneMappingMode.ACES_FILMIC,\n} as const\n\nconst root = new THREE.Vector3()\nconst top = new THREE.Vector3()\n\nexport function FxPostPass({\n film = 'neutral',\n quality = 'medium',\n bloom = FX_BLOOM,\n threshold = FX_BLOOM_THRESHOLD,\n field,\n locate,\n haze: hazeOverride,\n focus = 0,\n}: FxPostProps) {\n const { bloomScale, haze: tierHaze } = fxQualityFor(quality)\n const hazePx = hazeOverride ?? tierHaze\n const haze = useMemo(() => new HazeGradeEffect(), [])\n const anchors = useRef<FlameAnchor[]>([])\n /** Where the fire is, for the focus to sit on it. */\n const focusTarget = useRef(new THREE.Vector3())\n\n // The flames, in screen space, for the haze; the front, for the grade.\n useFrame(({ camera }) => {\n if (!field || !locate) {\n haze.count = 0\n haze.grade = 0\n return\n }\n const n = hazePx > 0 ? flameAnchors(field, locate, HAZE_SOURCES, anchors.current) : 0\n const sources = haze.sources\n for (let i = 0; i < n; i++) {\n const a = anchors.current[i]!\n root.set(a.x, a.y, a.z).project(camera)\n top.set(a.x, a.y + a.height, a.z).project(camera)\n sources[i]!.set(root.x * 0.5 + 0.5, root.y * 0.5 + 0.5, Math.max(0, (top.y - root.y) * 0.5), a.heat)\n }\n // Focus on the fire itself, as a point in the room rather than a\n // fraction of the far plane: the far plane is a thousand times the size\n // of a sheet, and a focus expressed against it lands the plane a metre\n // away and blurs everything, the rim included.\n if (focus > 0 && n > 0) {\n focusTarget.current.set(0, 0, 0)\n for (let i = 0; i < n; i++) {\n const a = anchors.current[i]!\n focusTarget.current.add(root.set(a.x, a.y, a.z))\n }\n focusTarget.current.multiplyScalar(1 / n)\n }\n haze.count = n\n haze.time = field.time\n haze.amount = hazePx\n // A few percent at most, and only as the fire grows.\n haze.grade = Math.min(1, field.lastStats.front / 0.03)\n })\n\n return (\n <EffectComposer>\n {bloom > 0 ? (\n <Bloom\n intensity={bloom}\n luminanceThreshold={threshold}\n // Nearly a hard knee. A wide one reaches BELOW the threshold, and\n // below it is paper: the whole point of the threshold is that paper\n // never blooms, and a soft knee would bloom it a little.\n luminanceSmoothing={0.02}\n mipmapBlur\n resolutionScale={bloomScale}\n />\n ) : null}\n <primitive object={haze} />\n {focus > 0 ? (\n <DepthOfField\n target={focusTarget.current}\n // A hand's width of sheet in focus at the gentlest, a few\n // centimetres at the strongest — the macro look of the reference\n // crops, without losing the rim the shot is about.\n focalLength={0.06 - 0.045 * focus}\n bokehScale={1 + 3 * focus}\n />\n ) : null}\n <ToneMapping mode={modes[film]} />\n </EffectComposer>\n )\n}\n\n/**\n * Re-exported, not defined here.\n *\n * It moved to `emission.ts` because it is half of a unit — \"brighter than\n * paper\" is only meaningful beside the paper white it is measured against,\n * and every emitter now authors itself in multiples of that same number. It\n * also could not be public from here: this file is the only one in\n * `paperlab/fx` allowed to import `postprocessing` (see `boundary.test.ts`),\n * so anything exported from it is unreachable for a consumer who has not\n * installed the optional peers.\n */\nexport { FX_BLOOM, FX_BLOOM_THRESHOLD, PAPER_WHITE }\n","import * as THREE from 'three'\nimport { BlendFunction, Effect } from 'postprocessing'\n\n/** How many flames the haze can shimmer above at once. */\nexport const HAZE_SOURCES = 16\n\nconst FRAGMENT = /* glsl */ `\nuniform vec4 uSources[${HAZE_SOURCES}];\nuniform int uCount;\nuniform float uTime;\nuniform float uAmount;\nuniform vec3 uWarm;\nuniform float uGrade;\n\nfloat hzHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }\nfloat hzNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hzHash(i), hzHash(i + vec2(1.0, 0.0)), u.x), mix(hzHash(i + vec2(0.0, 1.0)), hzHash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\n// Heat haze: the air above a flame is hotter than the air beside it, and\n// light bends through the difference. A few pixels of upward-scrolling\n// displacement, only in the column above each flame, fading with height.\nvoid mainUv(inout vec2 uv) {\n vec2 offset = vec2(0.0);\n for (int i = 0; i < ${HAZE_SOURCES}; i++) {\n if (i >= uCount) break;\n vec4 s = uSources[i];\n vec2 d = uv - s.xy;\n float h = max(s.z, 1e-4);\n float above = d.y / h;\n if (above < -0.1 || above > 3.2) continue;\n float across = abs(d.x) / (h * 0.55 + 1e-4);\n // Around the flame's own boundary as well as above it: gentle inside the\n // body, strongest in the hot column over it, gone by three flame heights.\n float column = smoothstep(0.3, 0.9, above) * (1.0 - smoothstep(1.4, 3.2, above));\n float around = smoothstep(-0.1, 0.15, above) * (1.0 - smoothstep(0.8, 1.2, above)) * 0.45;\n float m = (1.0 - smoothstep(0.45, 1.15, across)) * max(column, around) * s.w;\n vec2 q = vec2(uv.x * 140.0, uv.y * 90.0 - uTime * 3.0);\n offset += (vec2(hzNoise(q), hzNoise(q + 31.7)) - 0.5) * m;\n }\n uv += offset * uAmount;\n}\n\n// The grade: the whole frame warms as the fire grows — a few percent, no\n// more. A multiply, so black stays black.\nvoid mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {\n outputColor = vec4(inputColor.rgb * mix(vec3(1.0), uWarm, uGrade), inputColor.a);\n}\n`\n\n/**\n * Heat haze above the flames, and the warm grade of a frame with a fire in\n * it, as one pass because both are cheap and both\n * run on the HDR frame before the tone curve.\n *\n * Fed each frame by `FxPostPass`: `sources` holds up to {@link HAZE_SOURCES}\n * flames as (screen u, screen v, flame height in screen v, strength).\n */\nexport class HazeGradeEffect extends Effect {\n constructor() {\n super('HazeGradeEffect', FRAGMENT, {\n blendFunction: BlendFunction.NORMAL,\n uniforms: new Map<string, THREE.Uniform>([\n ['uSources', new THREE.Uniform(Array.from({ length: HAZE_SOURCES }, () => new THREE.Vector4()))],\n ['uCount', new THREE.Uniform(0)],\n ['uTime', new THREE.Uniform(0)],\n // 1–3 px at 1080p, as a fraction of the frame.\n ['uAmount', new THREE.Uniform(2.2 / 1080)],\n ['uWarm', new THREE.Uniform(new THREE.Vector3(1.04, 1.0, 0.94))],\n ['uGrade', new THREE.Uniform(0)],\n ]),\n })\n }\n\n get sources(): THREE.Vector4[] {\n return this.uniforms.get('uSources')!.value as THREE.Vector4[]\n }\n\n set count(n: number) {\n this.uniforms.get('uCount')!.value = n\n }\n\n set time(t: number) {\n this.uniforms.get('uTime')!.value = t\n }\n\n /** 0 turns the shimmer off (the low tier) and keeps the grade. */\n set amount(px1080: number) {\n this.uniforms.get('uAmount')!.value = px1080 / 1080\n }\n\n /** 0..1 — how much of the warm grade to apply. */\n set grade(g: number) {\n this.uniforms.get('uGrade')!.value = g\n }\n}\n"],"mappings":"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","names":["THREE"]}
@@ -0,0 +1,34 @@
1
+ // src/surface/damageContract.ts
2
+ function roomLight(source) {
3
+ const room = source?.firelight?.room;
4
+ if (room === void 0 || !(room >= 0)) return 1;
5
+ return Math.min(1, room);
6
+ }
7
+ var DAMAGE_LOOK_DEFAULTS = {
8
+ emberWidth: 1.25,
9
+ emberIntensity: 1.05,
10
+ emberCoverage: 0.6,
11
+ emberFlicker: 1.65,
12
+ emberGlow: 1.25,
13
+ sparkle: 0.5,
14
+ // As wide as the char band: the lip has to be seen. At
15
+ // 0.95 mm it was a hairline tracing the edge.
16
+ lipWidth: 3.5,
17
+ charWidth: 3.5,
18
+ lipBrightness: 0.76,
19
+ charWarmth: 0.32,
20
+ charCracks: 0.45,
21
+ scorchReach: 9,
22
+ scorchDarkness: 0.86,
23
+ fingers: 1.95,
24
+ edgeWave: 13,
25
+ edgeBite: 6
26
+ };
27
+ var DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 };
28
+
29
+ export {
30
+ roomLight,
31
+ DAMAGE_LOOK_DEFAULTS,
32
+ DAMAGE_CHANNELS
33
+ };
34
+ //# sourceMappingURL=chunk-BIFUJCUA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/surface/damageContract.ts"],"sourcesContent":["/**\n * What damage IS, as far as a sheet is concerned. The whole seam.\n *\n * The split is by responsibility. What has happened to the paper belongs to\n * the sheet — it is shading, alpha and eventually stiffness, and only the\n * sheet can draw or simulate those. What CAUSES it belongs to `paperlab/fx`:\n * a flame, a cup of water, a pair of scissors. The main entry draws whatever\n * damage it is handed and has no idea where it came from, which is exactly\n * how `content` already works — `<Paper>` renders a texture without knowing\n * who painted it.\n *\n * The alternative was a public plugin API for the surface composer and the\n * cloth solver, so that fx could reach in. More flexible, and it would have\n * had to be supported for years before a single effect existed to justify its\n * shape. This is one interface and four constants.\n *\n * **This file imports nothing, on purpose.** `paperlab/fx` depends on it —\n * the field is a `DamageSource` and its channel offsets are these — and the\n * boundary test allows fx to reach this one file and nothing else in the\n * library. The moment it imports three, React or the config schema, fx starts\n * dragging the library in behind it.\n */\n\n/**\n * A grid of damage over the sheet's UV, as the sheet reads it.\n *\n * Satisfied structurally by `DamageField` in `paperlab/fx`, and by anything\n * else that can produce four bytes per texel — a baked texture, a recorded\n * burn played back, a test fixture.\n */\nexport interface DamageSource {\n /** Texels along each edge. The grid is square and covers the whole sheet. */\n readonly size: number\n /**\n * RGBA per texel, 0..255, row-major from v = 0 — the channels are\n * {@link DAMAGE_CHANNELS}. Read in place, never copied: the source mutates\n * it and bumps `version`.\n *\n * Eight bits, deliberately. The simulation behind it can run in float; what\n * reaches the GPU is 16 KB per changed frame at 64², against 256 KB for a\n * 128² float texture, and it does not depend on float-texture filtering,\n * which not every phone GPU guarantees.\n */\n readonly pixels: Uint8Array\n /** Bumped whenever `pixels` changes. The sheet uploads on a change and never otherwise. */\n readonly version: number\n /**\n * How ragged a burnt or cut edge is DRAWN, 0..1; omitted means 1.\n *\n * Presentation only: per-fragment noise that moves the edge within the\n * grid's soft band, finer than the grid itself can carry. The physics reads\n * the grid and never this. It is the one part of drawing damage that costs\n * per pixel, which is why it is a number a source can turn down — `paperlab/fx`\n * sets it from its quality tier.\n */\n readonly detail?: number\n /**\n * The burn's own clock, in seconds; omitted means the frame clock.\n *\n * Presentation only. The ember line on a burning edge is beaded and alive —\n * its beads flicker and crawl — and a source that can be replayed wants\n * that motion to replay too: the same moment of the same burn should draw\n * the same beads, which the frame clock cannot promise. `DamageField`\n * hands over its simulated time.\n */\n readonly time?: number\n /**\n * How a burn is DRAWN — widths, intensities and shapes the sheet's damage\n * shading reads each frame. Presentation only; the physics never sees it.\n * Anything left out takes {@link DAMAGE_LOOK_DEFAULTS}.\n */\n readonly look?: DamageLook\n /**\n * The light the damage gives off, as the room around the sheet should feel\n * it. Presentation only; the physics never sees it. Omitted, the room is\n * lit exactly as its lighting says.\n */\n readonly firelight?: DamageFirelight\n}\n\n/**\n * What a burning sheet does to the light around it.\n *\n * A fire big enough to see by is the key light while it burns, and a room's\n * own light yields to it — a sheet burning under an unchanged studio key\n * looks like a flame pasted onto a photograph. The source says by how much,\n * because only the source knows how big its fire is; the lighting does the\n * dimming, because only the lighting knows what its lights are.\n */\nexport interface DamageFirelight {\n /**\n * How much of the room's own light is left, 0..1; omitted means 1. The key,\n * the ambient fill and the studio light are all scaled by it, every frame,\n * without rebuilding anything.\n */\n readonly room?: number\n}\n\n/**\n * How much of the room's light `source` leaves, 0..1 — the one reading of\n * {@link DamageFirelight.room}, so every lighting rig reads it alike. Anything\n * that is not a number in range is the room untouched: a firelight is a\n * dimmer, and the worst a bad one may do is nothing.\n */\nexport function roomLight(source: DamageSource | null | undefined): number {\n const room = source?.firelight?.room\n if (room === undefined || !(room >= 0)) return 1\n return Math.min(1, room)\n}\n\n/**\n * The knobs on what a burn looks like, in the units a person tunes by —\n * millimetres of A4 and plain multipliers. Every one is optional.\n */\nexport interface DamageLook {\n /** The ember line's widest bead, mm. */\n emberWidth?: number\n /** How bright the beads burn, × the default. */\n emberIntensity?: number\n /** How much of the edge is lit at once, 0..1. */\n emberCoverage?: number\n /** How fast the beads flicker and crawl, × the default. */\n emberFlicker?: number\n /** The dim crimson glow beside the beads, reaching into the char, 0..2. */\n emberGlow?: number\n /** Specks of glowing fibre along the edge, 0..2. */\n sparkle?: number\n /** How wide the pale ash lip is, from the cut out to the ember line, mm. */\n lipWidth?: number\n /** How wide the black char band is, past the ember line, mm. */\n charWidth?: number\n /** How pale the ash lip is, × the sampled grey. */\n lipBrightness?: number\n /** 0 is grey char, 1 is dark orange to deep brown. */\n charWarmth?: number\n /** How visible the crack network in the char is, 0..1. */\n charCracks?: number\n /** How far the scorch reaches UP past the burn, mm. */\n scorchReach?: number\n /** How dark the scorch browns go, × the sampled ramp. */\n scorchDarkness?: number\n /** How strongly the scorch front breaks into fingers, × the default. */\n fingers?: number\n /** The burnt edge's long waves, ±mm. */\n edgeWave?: number\n /** The burnt edge's small bites in and out, ±mm. */\n edgeBite?: number\n}\n\n/**\n * What every burn is drawn with unless told otherwise — the tune made in the\n * lab's sidebar, and the source of truth for how a burn looks:\n * the lab starts from it, `/hands` inherits it, and a user changes it through\n * `look`. The values it replaced, and why each of those had moved, are in the\n * history of this file.\n */\nexport const DAMAGE_LOOK_DEFAULTS: Required<DamageLook> = {\n emberWidth: 1.25,\n emberIntensity: 1.05,\n emberCoverage: 0.6,\n emberFlicker: 1.65,\n emberGlow: 1.25,\n sparkle: 0.5,\n // As wide as the char band: the lip has to be seen. At\n // 0.95 mm it was a hairline tracing the edge.\n lipWidth: 3.5,\n charWidth: 3.5,\n lipBrightness: 0.76,\n charWarmth: 0.32,\n charCracks: 0.45,\n scorchReach: 9,\n scorchDarkness: 0.86,\n fingers: 1.95,\n edgeWave: 13,\n edgeBite: 6,\n}\n\n/**\n * Which byte of a texel means what.\n *\n * char — scorch colour, the brown halo; on cloth, shrinkage and a curl toward the front.\n * saturation — wet darkening and smoothing; on cloth, added mass.\n * heat — how hot the paper is. Drawn only where it burns: the ember line, not the sheet.\n * presence — how much paper is there at all; below half, none is drawn.\n */\nexport const DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 } as const\n"],"mappings":";AAwGO,SAAS,UAAU,QAAiD;AACzE,QAAM,OAAO,QAAQ,WAAW;AAChC,MAAI,SAAS,UAAa,EAAE,QAAQ,GAAI,QAAO;AAC/C,SAAO,KAAK,IAAI,GAAG,IAAI;AACzB;AAgDO,IAAM,uBAA6C;AAAA,EACxD,YAAY;AAAA,EACZ,gBAAgB;AAAA,EAChB,eAAe;AAAA,EACf,cAAc;AAAA,EACd,WAAW;AAAA,EACX,SAAS;AAAA;AAAA;AAAA,EAGT,UAAU;AAAA,EACV,WAAW;AAAA,EACX,eAAe;AAAA,EACf,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,gBAAgB;AAAA,EAChB,SAAS;AAAA,EACT,UAAU;AAAA,EACV,UAAU;AACZ;AAUO,IAAM,kBAAkB,EAAE,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,UAAU,EAAE;","names":[]}