@mieweb/pulsevault 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +216 -1
  2. package/OPERATIONS.md +135 -44
  3. package/PROTOCOL.md +63 -4
  4. package/README.md +186 -34
  5. package/dist/app.d.ts +30 -5
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +24 -5
  8. package/dist/app.js.map +1 -1
  9. package/dist/augment.d.ts +3 -0
  10. package/dist/augment.d.ts.map +1 -1
  11. package/dist/augment.js.map +1 -1
  12. package/dist/core.d.ts +107 -8
  13. package/dist/core.d.ts.map +1 -1
  14. package/dist/core.js +381 -47
  15. package/dist/core.js.map +1 -1
  16. package/dist/lib/authorize.d.ts +28 -6
  17. package/dist/lib/authorize.d.ts.map +1 -1
  18. package/dist/lib/capability-token.d.ts +26 -0
  19. package/dist/lib/capability-token.d.ts.map +1 -1
  20. package/dist/lib/capability-token.js +73 -5
  21. package/dist/lib/capability-token.js.map +1 -1
  22. package/dist/lib/completion.d.ts +138 -0
  23. package/dist/lib/completion.d.ts.map +1 -0
  24. package/dist/lib/completion.js +375 -0
  25. package/dist/lib/completion.js.map +1 -0
  26. package/dist/lib/deeplinks.d.ts +18 -2
  27. package/dist/lib/deeplinks.d.ts.map +1 -1
  28. package/dist/lib/deeplinks.js +13 -2
  29. package/dist/lib/deeplinks.js.map +1 -1
  30. package/dist/lib/magic.d.ts +41 -1
  31. package/dist/lib/magic.d.ts.map +1 -1
  32. package/dist/lib/magic.js +104 -0
  33. package/dist/lib/magic.js.map +1 -1
  34. package/dist/lib/options.d.ts +2 -0
  35. package/dist/lib/options.d.ts.map +1 -1
  36. package/dist/lib/options.js +8 -2
  37. package/dist/lib/options.js.map +1 -1
  38. package/dist/lib/protocol-version.gen.js +1 -1
  39. package/dist/lib/pulsevaultTus.d.ts +28 -26
  40. package/dist/lib/pulsevaultTus.d.ts.map +1 -1
  41. package/dist/lib/pulsevaultTus.js +149 -32
  42. package/dist/lib/pulsevaultTus.js.map +1 -1
  43. package/dist/lib/web-ready.d.ts +138 -18
  44. package/dist/lib/web-ready.d.ts.map +1 -1
  45. package/dist/lib/web-ready.js +453 -91
  46. package/dist/lib/web-ready.js.map +1 -1
  47. package/dist/routes/pulsevault.d.ts +12 -1
  48. package/dist/routes/pulsevault.d.ts.map +1 -1
  49. package/dist/routes/pulsevault.js +92 -1
  50. package/dist/routes/pulsevault.js.map +1 -1
  51. package/dist/storage/local.d.ts +7 -1
  52. package/dist/storage/local.d.ts.map +1 -1
  53. package/dist/storage/local.js +408 -52
  54. package/dist/storage/local.js.map +1 -1
  55. package/dist/storage/s3.d.ts +7 -1
  56. package/dist/storage/s3.d.ts.map +1 -1
  57. package/dist/storage/s3.js +256 -14
  58. package/dist/storage/s3.js.map +1 -1
  59. package/dist/storage/types.d.ts +126 -0
  60. package/dist/storage/types.d.ts.map +1 -1
  61. package/dist/storage/types.js +7 -0
  62. package/dist/storage/types.js.map +1 -1
  63. package/package.json +2 -2
  64. package/protocol/openapi.json +270 -1
  65. package/protocol/schemas/artifact-status.schema.json +61 -0
  66. package/protocol/schemas/capability-token.schema.json +3 -0
  67. package/protocol/schemas/deep-link.schema.json +2 -2
  68. package/scripts/web-ready-migrate.mjs +35 -11
package/CHANGELOG.md CHANGED
@@ -7,6 +7,219 @@ breaking changes, called out explicitly below.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-12
11
+
12
+ Protocol 2.5 (`PROTOCOL.md` §7.4): 2.4 adds the https pairing link form (#83), 2.5 the conform
13
+ step's `webReady` result. `webReady` becomes a **conform** step: whatever video a
14
+ person uploads, PulseVault serves it in the one format the Pulse app records, and a Pulse upload
15
+ is left untouched (#84).
16
+
17
+ ### Added
18
+
19
+ - **https pairing links** (#83): `buildUploadLink({ …, form: 'https' })`
20
+ returns `https://mieweb.github.io/pulse/open#v=1&artifactId=…&server=…&token=…`,
21
+ the same parameters as the `pulsecam://` form, in the fragment. It is a
22
+ Universal Link / App Link: a phone camera opens it, Pulse opens straight
23
+ from it when installed, and the page sends the person to the store when it
24
+ isn't. Browsers never send the fragment, so the link host never receives the
25
+ token. `PULSE_OPEN_URL` is exported. Without `form` the output is
26
+ unchanged, `pulsecam://`, because Pulse releases from before the link host
27
+ was claimed open the https form only through the page's button.
28
+ - **Conform target** (`CONFORM_TARGET`): faststart MP4, H.264 8-bit `yuv420p` (limited range)
29
+ with even dimensions, SDR (HDR tone-mapped to BT.709; SDR colour tags kept), the longest edge at most `webReady.maxEdge` (default 1920; aspect ratio and
30
+ orientation kept), rotation applied, AAC or no audio. `ensureWebReady` leaves a file already
31
+ in it byte-for-byte (`none`), remuxes losslessly when only the container or `moov` position is
32
+ off, and otherwise runs ffmpeg once, re-encoding only the off-target streams: HEVC and other
33
+ codecs to H.264, 10-bit HDR (PQ/HLG) tone-mapped to SDR (FFmpeg 8's `scale`, or `zscale` on
34
+ older builds), full-range video (iPhone screen recordings) to limited range, oversized video
35
+ scaled down, Opus/PCM and other audio to AAC.
36
+ - **Container change**: a `.mov`, `.m4v`, `.webm`, `.mkv`, `.3gp` or `.avi` becomes a new
37
+ `<id>.mp4` (`WebReadyAction` `conformed`). The artifact's stored file, `ext` and served
38
+ `Content-Type` follow it; the artifact id and its URLs don't change. The sidecar switches
39
+ first, then the original is deleted (`sourceExt` remembers it so `remove` cleans up after a
40
+ crash). New patch fields `ext` and `webReady` (local adapter; the S3 adapter refuses them).
41
+ `describeArtifact` reports the uploaded extension as `sourceExt` once it differs, and
42
+ `uploadIdOf(meta)` names tus's upload by it, so a replayed hook's `ext` and `uploadId` still
43
+ match `filename`.
44
+ - `webReady.timeoutSeconds` (default `60 + 10 ×` the duration in 1080p30 seconds, so 4K or high
45
+ frame rates get proportionally longer, at most an hour since it comes from the upload's own
46
+ metadata; set it explicitly for longer runs): a longer run is killed, the original
47
+ kept, and `skipped` recorded with the reason.
48
+ - The conversion's result is recorded on the artifact: `GET /artifacts/:id/status` and
49
+ `getStatus` report `webReady: { action, reason }` (protocol 2.5), and `describeArtifact`
50
+ returns it.
51
+ - `core.conformAvailable()` (also on `fastify.pulseVaultCore`): `false` when `webReady` is off or
52
+ ffmpeg/ffprobe are missing, for a host's health check.
53
+ - `createVideoValidator({ maxDurationSeconds? })`: a `validatePayload` hook that checks a video
54
+ by its content with ffprobe (a video stream with a duration above zero, including a browser
55
+ WebM without one in its header; a picture — PNG, JPEG, GIF, HEIC, a one-frame clip, counted
56
+ from the first two packets when the container has no frame count — is not a video), refusing with `422 That file isn't a video.` or `That video is
57
+ longer than the limit of …` (the configured limit exactly: "1 minute 1 second"); falls back to `sniffVideo` (`ftyp`, EBML or RIFF AVI) without
58
+ ffprobe. Also exported: `probeVideo`, `webReadyAvailable`, `CONFORM_VIDEO_EXTENSIONS`.
59
+ - An upload over `maxUploadSize` is refused with `413 That file is larger than 500 MB.` instead
60
+ of tus's "Maximum size exceeded".
61
+ - `webReady.probeTimeoutSeconds` and `createVideoValidator({ probeTimeoutSeconds })` (default 60):
62
+ every ffprobe run over an upload is bounded; the validator refuses with `422 That video
63
+ couldn't be checked in time.`
64
+ - Content types for `.webm`, `.mkv`, `.3gp` and `.avi`.
65
+ - While a video is being converted, `GET /artifacts/:id` is served with `max-age=0` instead of
66
+ the configured `cache` (which may be `immutable`), so no client keeps the original bytes once
67
+ the converted file replaces them.
68
+
69
+ ### Changed
70
+
71
+ - The default video `allowedExtensions` are `.mp4`, `.mov`, `.m4v`, `.webm`, `.mkv`, `.3gp` and
72
+ `.avi` (was `.mp4`). A host's own `allowedExtensions` still overrides them.
73
+ - A failed conversion is now recorded (`webReady: { action: "skipped", reason }`) as well as
74
+ logged, and still not retried; a conversion that never recorded a result is resumed by the
75
+ replay, as before.
76
+ - The local adapter's `remove` reads the sidecar itself (not its cache) and deletes the
77
+ converted `<id>.mp4` and the uploaded file as well as the stored one. A converted video's bytes
78
+ are always at `<id>.mp4`, and `resolve`/`getLocalPath` fall back to it when the sidecar's `ext`
79
+ file is gone: across instances sharing a workspace, where the last sidecar write wins (as
80
+ before; there is no cross-instance lock), a write from a copy older than a conversion still
81
+ serves the converted file.
82
+ - A client that re-sends the final `PATCH` of a finished upload whose file a conversion replaced
83
+ (a `.webm` conformed to `<id>.mp4`) gets `204`, as without a conversion: the local adapter's tus
84
+ store reads such an upload as complete and takes its empty tail without a write.
85
+ - A `webReady` conversion needs the adapter's `generation`: an adapter with `getLocalPath` but no
86
+ generation has its videos recorded `skipped` (`the storage adapter keeps no generation for this
87
+ upload`). The local adapter gives one at `markReady` to an upload reserved before this release.
88
+ - Reservations run under the in-process sidecar lock, and `remove` deletes the sidecar last, so a new upload
89
+ of a removed id never has its bytes deleted by the old removal. Each reservation gets a
90
+ `generation` (`describeArtifact`), new every time the id is reserved.
91
+ - A conversion is installed in one patch under the sidecar lock: the runner converts into a temporary
92
+ `.mp4` (`ensureWebReady(…, { install: false })`), then `patchArtifact` with `file` renames it
93
+ onto the stored file, switches `ext`, records the result and deletes the original — only for
94
+ the reservation the conversion began on (`generation`) and only while no other pass has
95
+ recorded one (`unlessConverted`). When a condition doesn't hold, `patchArtifact` resolves
96
+ `false`, changing nothing, and the runner drops its file.
97
+ - Completion work belongs to one reservation: a queued conversion checks, when its turn comes,
98
+ that the id still names the upload it was queued for (and that it's finished); the
99
+ acknowledgement is recorded only on that reservation; and a new upload of a removed id is never
100
+ joined to the old one's work in progress.
101
+ - `GET /artifacts/:id` opens the file before sending headers, checks it's the file `send`
102
+ described (inode, size, mtime), and resolves once more when it's gone or another file was
103
+ renamed onto the path (a conversion just replaced it), instead of a 404, a cut-off response or
104
+ headers that don't match the bytes; bytes this
105
+ instance resolved under an extension storage no longer names are served with `max-age=0`.
106
+ - A failed run's recorded reason is `ffmpeg exited with code N`, never ffmpeg's output (which
107
+ names server paths); an HDR video on an ffmpeg without tone mapping is left as uploaded
108
+ (`skipped`, saying what's missing) rather than converted with wrong colours.
109
+ - The local adapter's `remove` decides from the sidecar alone, never its cache: an artifact
110
+ another reader had cached is reported removed once, not twice.
111
+ - `webReady` needs an adapter with `describeArtifact` too (both built-in adapters have it).
112
+ - The local adapter's `getLocalPath` reads the sidecar, not its cache, so it names the current
113
+ file after another instance removed the id and reserved it again.
114
+ - ffmpeg's capability queries (`-version`, `-h filter=scale`, `-filters`) are bounded (30 s).
115
+ - An `onArtifactEvent` observer that throws on `processed` is logged as its own failure; the
116
+ recorded result still reaches `completeAfter`'s hook as `ctx.webReady`.
117
+ - A replayed `onUploadComplete` (the hook threw, or the process stopped) gets the recorded
118
+ conversion result as `ctx.webReady` too, so a host naming the file from it (an `.mp4` once a
119
+ WebM was conformed) gets it right on the retry.
120
+ - `web-ready-migrate.mjs` rerun after an interruption deletes an original the sidecar no longer
121
+ names.
122
+ - `scripts/web-ready-migrate.mjs` conforms existing artifacts the same way, recording the result
123
+ on each sidecar and switching a changed container to its `.mp4`. Run it with the server
124
+ stopped.
125
+ - `transcode: false` records a file it leaves off-target (HEVC, oversized, non-AAC audio) as
126
+ `skipped`, not `none`: `none` now means only that the file already conforms. (Was `none`.)
127
+ - A browser WebM's missing duration is found by streaming ffprobe's packet list and keeping
128
+ only the latest end, never buffering it.
129
+ - An upload of deferred length that grows past `maxUploadSize` (a chunk with a length, or a
130
+ chunked body cut off mid-stream) gets the same `413 That file is larger than …` message.
131
+ - An MP4-family `.mov`/`.m4v` that already conformed used to be served as is (`video/quicktime`,
132
+ a type not every browser takes); it is now remuxed into an `.mp4`.
133
+
134
+ ## [0.5.0] - 2026-10-05
135
+
136
+ Protocol 2.3 (`PROTOCOL.md` §7.4). What every host rebuilt around PulseVault
137
+ (#80), in the core: the shape of a pulse, a completion that survives a throw
138
+ or a restart, a lock on finished pulses, the token's own context, a status
139
+ route, background web-ready, a poster route and `getPulse`, and the content
140
+ types phones send. Two defaults change; both have an escape hatch.
141
+
142
+ ### Changed (breaking)
143
+
144
+ - **A create must have the shape of a pulse** (`pulseShape`, on by default):
145
+ a video with no `relatedTo`; a thumbnail, beat manifest or captions under
146
+ its own id, `relatedTo` the video. With `createCapabilityAuthorize` the ids
147
+ are also tied to the token: the video only under the token's own
148
+ `artifactId`, the rest only `relatedTo` it. Before, one token could create
149
+ any number of artifacts under ids the client picked, or take the video's
150
+ own id with a thumbnail so the real video answered `409` for good. The
151
+ Pulse app has always uploaded in this shape. For uploads that aren't
152
+ pulses, pass `pulseShape: false` to the plugin or core *and* to
153
+ `createCapabilityAuthorize`.
154
+ - **A create reclaims an idle unfinished upload of the same artifactId**
155
+ (`reclaim`, `{ idleSeconds: 300 }` by default): an app killed mid-upload
156
+ sends no TUS `DELETE`, so re-scanning the same link answered `409` until
157
+ `retention` ran. Only an upload of the same kind and `relatedTo` is taken
158
+ over (the same token authorized both), and only once the local adapter has
159
+ seen no bytes for `idleSeconds`. `reclaim: false` keeps the old `409`.
160
+ - **`onUploadComplete` is at-least-once.** PulseVault records that the hook
161
+ returned (`acknowledged` in the sidecar) and fires it again, with
162
+ `ctx.replay: true`, for a finished artifact it never recorded: the hook
163
+ threw, or the process stopped first. Every 300 seconds, with the first pass
164
+ shortly after start (`replayCompletions`; `false` turns it off, and
165
+ `core.replayCompletions()` / `fastify.pulseVaultCore.replayCompletions()`
166
+ runs a pass on demand). Hosts must check their own record before writing
167
+ it again. Finished sidecars written before this release read as acknowledged, so an
168
+ upgrade replays nothing; one still uploading at the upgrade completes
169
+ normally. A retried final `PATCH` no longer fires the hook a
170
+ second time.
171
+ - **`authorize` gains the `status` phase** (`GET /artifacts/:id/status`).
172
+ An exhaustive `switch` on the phase needs a case for it.
173
+ - **Content types**: the `.pulse` beat manifest is served as
174
+ `application/json`, and `.mov`, `.m4v` and `.srt` — when a host allows them
175
+ in `allowedExtensions`; the defaults stay what the Pulse app sends — as
176
+ `video/quicktime`, `video/x-m4v` and `application/x-subrip`, instead of
177
+ `application/octet-stream`, which `<video>` downloads instead of playing.
178
+
179
+ ### Added
180
+
181
+ - **Hook context**: `authorize` and `onUploadComplete` receive `relatedTo`,
182
+ `name`, `appVersion`, `filename`, `ext` and the token's `context`, so a
183
+ host maps a finished file to its own record without reading storage back.
184
+ - **`context` in the capability token** (`issueCapabilityToken(id, secret,
185
+ { context })`, the `ctx` claim, at most 1 KiB of JSON): an owner, a
186
+ destination — signed, stored with the artifact at create (`authorize` may
187
+ return `{ context }`), and handed to every hook, `getStatus` and `getPulse`.
188
+ Most hosts need no table keyed by artifactId any more.
189
+ - **`GET /artifacts/:id/status`** and `core.getStatus(id)`: `unknown`,
190
+ `uploading` (with `bytesReceived` against `size`), `processing` or
191
+ `ready`, plus `acknowledged` and whatever the host recorded with
192
+ `recordOutcome(id, outcome)`. Authorized as `status`;
193
+ `createCapabilityAuthorize` grants it to the pairing token and a view token.
194
+ - **`webReady`**: the conversion `ensureWebReady` does, run by the core in
195
+ the background after the final `PATCH` is answered, `concurrency` at a
196
+ time, with a `processing` status meanwhile, a `processed` event when done,
197
+ and `completeAfter: true` to hold `onUploadComplete` until the conversion
198
+ has finished (`ctx.webReady` says what it did). Whether it finished is
199
+ recorded on the sidecar (`converted`), so a conversion a restart
200
+ interrupted is redone by the replay. Local storage only.
201
+ - **`lockWhenReady`**: a finished artifact, and anything `relatedTo` a
202
+ finished video, can't be deleted through the routes (`403`); the host
203
+ removes through `storage.remove` on its own terms.
204
+ - **`core.getPulse(videoId)`** → `{ video, thumbnail, captions, manifest }`
205
+ and **`GET /artifacts/:videoId/poster`**, both from a relation index the
206
+ adapters keep at reserve time (`storage.listRelated`), so neither scans
207
+ storage and nothing is backfilled.
208
+ - **Storage adapter contract**: optional `describeArtifact`, `patchArtifact`
209
+ and `listRelated`, and `context` in `reserveUpload` (both built-in
210
+ adapters implement them). The local adapter's relation index lives at
211
+ `.pulsevault/related/<videoId>/<artifactId>`; the S3 adapter's under the
212
+ same key prefix.
213
+ - **`onArtifactEvent`**: `phase: "processed"` (with `webReady`), and
214
+ `reason: "reclaimed"` on `remove`.
215
+ - **Fastify**: `fastify.pulseVaultCore` (`coreDecoratorName`) exposes
216
+ `getStatus`, `getPulse`, `recordOutcome` and `replayCompletions`.
217
+ - **`examples/meteor-demo`** is now the reference host integration, shaped
218
+ like a team app: people, destinations, a reserve that signs the owner and
219
+ destination into the token, delivery from `onUploadComplete`, status
220
+ polling, and video cards with posters — with no table of uploads, no shape
221
+ check, no stale-upload cleanup and no content-type fix-up of its own.
222
+
10
223
  ## [0.4.2] - 2026-10-02
11
224
 
12
225
  ### Added
@@ -411,7 +624,9 @@ if you've evaluated against an intermediate build.
411
624
  lock, so this is now at least surfaced: a one-time `console.warn` fires
412
625
  per process the first time a deployment falls into this degraded mode.
413
626
 
414
- [Unreleased]: https://github.com/mieweb/pulsevault/compare/v0.4.2...HEAD
627
+ [Unreleased]: https://github.com/mieweb/pulsevault/compare/v0.6.0...HEAD
628
+ [0.6.0]: https://github.com/mieweb/pulsevault/compare/v0.5.0...v0.6.0
629
+ [0.5.0]: https://github.com/mieweb/pulsevault/compare/v0.4.2...v0.5.0
415
630
  [0.4.2]: https://github.com/mieweb/pulsevault/compare/v0.4.1...v0.4.2
416
631
  [0.4.1]: https://github.com/mieweb/pulsevault/compare/v0.4.0...v0.4.1
417
632
  [0.4.0]: https://github.com/mieweb/pulsevault/compare/v0.3.0...v0.4.0
package/OPERATIONS.md CHANGED
@@ -54,7 +54,15 @@ Two ways to actually support multiple instances:
54
54
  2. **Shared filesystem**: point every instance's `workspaceDir` at the same
55
55
  NFS/EFS mount. Verify your mount's durability/consistency guarantees
56
56
  under concurrent writes from multiple instances before relying on this in
57
- production.
57
+ production. Each instance changes an artifact's metadata (`.pulsevault/
58
+ <id>.json`) one write at a time, but no lock spans instances: when two
59
+ write the same artifact's metadata at once, the last write wins. The
60
+ completion bookkeeping recovers: the replay redoes a lost acknowledgement
61
+ or conversion record, and a converted video's bytes are always at
62
+ `<id>.mp4`, which is served even when the metadata that won still names
63
+ the uploaded file. A host's `recordOutcome` lost that way is not redone. A
64
+ removal racing another instance's write to the same artifact can leave its
65
+ metadata behind; for strict guarantees across instances, use S3.
58
66
 
59
67
  **The S3/R2 adapter (`createS3Storage`) has no such requirement** — every
60
68
  instance talks to the same bucket, so it scales horizontally with zero
@@ -160,52 +168,124 @@ backup time will resume correctly via the normal TUS `HEAD`-then-resume path
160
168
  once the client retries, or will sit as abandoned partial uploads until
161
169
  `retention` removes them (see "Retention" below) if the client never retries.
162
170
 
163
- ## Web-ready playback (faststart remux + H.264 transcode)
164
-
165
- Browsers streaming an MP4 over progressive HTTP stall (or fail outright) on
166
- two things mobile capture pipelines routinely upload:
167
-
168
- - **moov atom at the end of the file** — the player must fetch the file's tail
169
- before rendering frame one: seconds of startup delay behind Range requests,
170
- or a full download in naive players.
171
- - **HEVC video** — Firefox never decodes it; Chrome usually can't without
172
- hardware support.
173
-
174
- The `ensureWebReady` helper (exported from the package root) fixes both, in
175
- place and atomically (tmp file + rename): a lossless sub-second
176
- `-c copy -movflags +faststart` remux when only the moov position is wrong, and
177
- a one-time `libx264` transcode (audio stream-copied) when the codec is
178
- hostile. It is fail-open by design — without `ffmpeg`/`ffprobe` on `PATH` it
179
- logs one warning and the original bytes keep serving exactly as before.
180
-
181
- **Prerequisite:** install ffmpeg on the serving host — `apt install ffmpeg`
182
- (Debian/Ubuntu), `dnf install ffmpeg` (Fedora/EL + RPM Fusion), or
183
- `brew install ffmpeg` (macOS). Nothing else changes; the hook detects it at
184
- first use.
185
-
186
- **New uploads** — wire it into `onUploadComplete` (the fastify-demo ships with
187
- this enabled):
171
+ ## Web-ready playback (conform to one format)
172
+
173
+ Videos reach PulseVault from the Pulse app, which already uploads the format
174
+ below, and from a host's own file picker: a screen recording, an iPhone HEVC
175
+ `.mov` (often 10-bit HDR), a WebM from a browser recorder, a 4K clip. Browsers
176
+ streaming over progressive HTTP stall on a `moov` atom at the end of the file,
177
+ Firefox never decodes HEVC, Chrome usually can't without hardware support,
178
+ 10-bit HDR shown as SDR looks washed out, and a 4K file is far more than a
179
+ phone needs.
180
+
181
+ With the `webReady` option on, every finished video is **conformed** to one
182
+ target (`CONFORM_TARGET`, exported from the package root):
183
+
184
+ | Property | Target |
185
+ |---|---|
186
+ | Container | MP4, `moov` at the front (faststart) |
187
+ | Video | H.264, 8-bit `yuv420p` (limited range), even width and height |
188
+ | Colour | SDR: HDR (PQ or HLG) is tone-mapped to BT.709; an SDR source keeps its own colour tags (BT.709 or BT.601), which browsers honour |
189
+ | Size | longest edge at most 1920 (`maxEdge`); aspect ratio and orientation kept — no crop, pad or stretch |
190
+ | Rotation | applied to the pixels, so every player shows it the way it was held |
191
+ | Audio | AAC; a video with no audio stays silent |
192
+
193
+ `ensureWebReady` does the least work that gets a file there:
194
+
195
+ | The upload | What happens | `action` |
196
+ |---|---|---|
197
+ | Already in the target (a Pulse upload) | nothing; bytes untouched | `none` |
198
+ | MP4 with the `moov` at the end | lossless `-c copy` faststart remux | `remuxed` |
199
+ | MP4 with an off-target stream | one ffmpeg run, in place | `transcoded` |
200
+ | Another container (`.mov`, `.m4v`, `.webm`, `.mkv`, `.3gp`, `.avi`) | one ffmpeg run into a new `<id>.mp4`: a stream copy when the streams already conform, otherwise only the off-target streams re-encoded | `conformed` |
201
+ | No ffmpeg, unreadable, failed or timed out | nothing; the original serves | `skipped` |
202
+
203
+ A re-encode scales by the longest edge, applies the rotation, tone-maps HDR
204
+ (PQ or HLG) to SDR, and converts non-AAC audio (Opus, PCM, …) to AAC. After a
205
+ container change the artifact's stored file, extension and served
206
+ `Content-Type` (`video/mp4`) follow the new file; the artifact id, and so every
207
+ URL a host stored, stay the same. The new file is written beside the original,
208
+ the artifact's sidecar switches to it, and only then is the original deleted:
209
+ a crash at any point leaves either the original serving (the conversion is
210
+ redone by the replay) or an unused original that is deleted with the
211
+ artifact.
212
+
213
+ While a video is being converted, its URL serves the original bytes with
214
+ `max-age=0` instead of the configured `cache`, so a browser that fetched it
215
+ early revalidates and gets the converted file (with a new ETag) afterwards.
216
+
217
+ A failed run is recorded as `conversion failed: ffmpeg exited with code N`;
218
+ ffmpeg's own output, which names server paths, goes only to the server log.
219
+
220
+ The result is recorded on the artifact: the status route reports it as
221
+ `webReady: { action, reason }` (a host can say "Your video is ready", or "We
222
+ couldn't convert this video, so it may not play in every browser"),
223
+ `onArtifactEvent` fires `processed` with it, and `completeAfter` hands it to
224
+ `onUploadComplete` as `ctx.webReady`.
225
+
226
+ **Prerequisite:** install ffmpeg (which includes ffprobe) on the serving host
227
+ — `apt install ffmpeg` (Debian/Ubuntu), `dnf install ffmpeg` (Fedora/EL + RPM
228
+ Fusion), or `brew install ffmpeg` (macOS). It is detected at first use; without
229
+ it every upload is still accepted and served exactly as uploaded, one warning
230
+ is logged, and `core.conformAvailable()` (also `fastify.pulseVaultCore`)
231
+ resolves `false` for a health check. HDR tone mapping uses the `scale` filter
232
+ on FFmpeg 8 and later, or `zscale` (libzimg, included in the Debian and Ubuntu
233
+ packages) on older builds. A build with neither leaves an HDR video as
234
+ uploaded and records `skipped` with what's missing: converting it without tone
235
+ mapping would give wrong colours.
188
236
 
189
237
  ```js
190
- const storage = createLocalStorage({ workspaceDir: dataDir });
191
238
  await app.register(pulseVault, {
192
- storage,
193
- onUploadComplete: async (_request, { artifactId, kind }) => {
194
- if (kind !== "video") return;
195
- const localPath = await storage.getLocalPath(artifactId);
196
- if (localPath) await ensureWebReady(localPath, { logger: app.log });
197
- },
239
+ storage: createLocalStorage({ workspaceDir: dataDir }),
240
+ validatePayload: createVideoValidator({ maxDurationSeconds: 600 }),
241
+ webReady: { concurrency: 1, completeAfter: true },
198
242
  });
199
243
  ```
200
244
 
201
- Options: `{ transcode: false }` restricts it to the lossless remux (no CPU
202
- cost beyond a file rewrite); `crf`/`preset` tune the transcode
203
- (defaults `23`/`veryfast`); `ffmpegPath`/`ffprobePath` point at binaries off
204
- `PATH`.
205
-
206
- **Existing artifacts** — uploads that landed before the hook existed are fixed
207
- once with the bundled migration script (idempotent; interrupt and rerun
208
- freely, already-fixed files are skipped for free):
245
+ Settings:
246
+
247
+ - `concurrency` (default `1`): conversions run one at a time per process; the
248
+ rest wait in a queue. A conversion a restart interrupted is resumed by the
249
+ completion replay (`replayCompletions`): an artifact isn't marked converted
250
+ until its result is recorded.
251
+ - `timeoutSeconds` (default `60 + 10 ×` the video's duration counted in
252
+ 1080p30 seconds — a second of 4K at 120 fps counts 16 — at most an hour,
253
+ and an hour when the duration is unknown; the default comes from the
254
+ upload's own metadata, so set this for longer runs): a run past it is
255
+ killed, the original kept, and
256
+ `skipped` recorded with the reason.
257
+ - `probeTimeoutSeconds` (default `60`): the same guard for each ffprobe run
258
+ over the upload, so a malformed file can't hold the queue.
259
+ - `maxEdge` (default `1920`): the longest edge of the served video.
260
+ - `transcode: false`: never re-encode; only lossless remuxes run (a `moov`
261
+ moved to the front, or another container whose streams already conform copied
262
+ into an `.mp4`), so no CPU cost beyond a file rewrite. A file that would need
263
+ a re-encode is left as uploaded and recorded as `skipped` (`none` means only
264
+ "already conforms").
265
+ - `crf`/`preset` (defaults `23`/`veryfast`) tune the H.264 encode;
266
+ `ffmpegPath`/`ffprobePath` point at binaries off `PATH`.
267
+
268
+ **Accepted containers.** The default video `allowedExtensions` are `.mp4`,
269
+ `.mov`, `.m4v`, `.webm`, `.mkv`, `.3gp` and `.avi` (`CONFORM_VIDEO_EXTENSIONS`);
270
+ a host's own `allowedExtensions` still overrides them. The extension only
271
+ decides what's accepted at `create`; `createVideoValidator()` checks the
272
+ received bytes by what they are: ffprobe must find a video stream with a
273
+ duration above zero that isn't a picture (a PNG, JPEG, GIF, HEIC photo or
274
+ one-frame clip is refused), and, with `maxDurationSeconds`, not longer, or the
275
+ upload is refused with `422 That file isn't a video.` / `That video is longer
276
+ than the limit of 10 minutes.` Each ffprobe run is bounded by
277
+ `probeTimeoutSeconds` (default `60`), so a malformed file can't hold the final
278
+ `PATCH` open: past it the upload is refused with `422 That video couldn't be
279
+ checked in time.` Without ffprobe it falls back to sniffing the
280
+ container's first bytes (`ftyp`, EBML or RIFF AVI). An upload over
281
+ `maxUploadSize` is refused at `create` with `413 That file is larger than
282
+ 500 MB.` Without `webReady`, a WebM or MKV is served as uploaded, with its own
283
+ content type.
284
+
285
+ **Existing artifacts** — uploads that landed before `webReady` was on are
286
+ conformed once with the bundled migration script. Stop the server while it
287
+ runs (the server caches each artifact's extension, which a container change
288
+ rewrites). It is idempotent; interrupt and rerun freely:
209
289
 
210
290
  ```sh
211
291
  node node_modules/@mieweb/pulsevault/scripts/web-ready-migrate.mjs /path/to/workspaceDir
@@ -215,11 +295,12 @@ node node_modules/@mieweb/pulsevault/scripts/web-ready-migrate.mjs /path/to/work
215
295
  node node_modules/@mieweb/pulsevault/scripts/web-ready-migrate.mjs /path/to/workspaceDir --no-transcode
216
296
  ```
217
297
 
218
- A transcode re-encodes the video stream (one-time, quality-preserving at the
219
- default CRF but not bit-identical). Take a backup first if that matters to
298
+ A re-encode is one-time, quality-preserving at the default CRF but not
299
+ bit-identical, and a conformed file replaces the upload (the original is not
300
+ kept). Take a backup first if that matters to
220
301
  your deployment (see "Backup and restore" above).
221
302
 
222
- Note on checksums: a remux or transcode changes the artifact's bytes, so an
303
+ Note on checksums: a conversion changes the artifact's bytes, so an
223
304
  upload-time checksum recorded for it (the sidecar `checksum` from
224
305
  `Upload-Metadata`) describes the original upload, not the rewritten file.
225
306
  Treat `getChecksum` as upload-time provenance rather than current-file
@@ -231,6 +312,16 @@ integrity for artifacts this feature has touched.
231
312
  captions, manifest or thumbnail of a video that never finished — are cleaned
232
313
  up by the opt-in `retention` option (or `sweepAbandonedUploads` from your own
233
314
  scheduler); see the README's `retention` option. Both adapters support it.
315
+ Separately, a create that arrives for an unfinished upload of the same id
316
+ (the person scanned the same link again after the app died) takes it over
317
+ once it has been idle for `reclaim.idleSeconds` (300 by default), so nobody
318
+ waits for the sweep.
319
+
320
+ **Completions the host never recorded** — an `onUploadComplete` that threw,
321
+ or a restart between the final byte and the hook — are fired again by the
322
+ completion replay (`replayCompletions`, every 300 seconds by default, and
323
+ `pulseVaultCore.replayCompletions()` on demand). Nothing to schedule; make the
324
+ hook idempotent.
234
325
 
235
326
  How long to keep **finished** content is a policy decision `pulsevault`
236
327
  leaves to the operator (see `PROTOCOL.md` §1 on lifecycle ownership). A
package/PROTOCOL.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <!-- BEGIN GENERATED: protocol-version (from package.json pulseProtocol — edit that, then run `npm run protocol`) -->
4
4
  | | |
5
5
  |---|---|
6
- | Spec revision | `2.2` |
6
+ | Spec revision | `2.5` |
7
7
  | Protocol majors accepted | 2 |
8
8
  <!-- END GENERATED: protocol-version -->
9
9
 
@@ -69,12 +69,30 @@ the integer from `protocolVersion`.
69
69
 
70
70
  ## 3. Pairing (deep link)
71
71
 
72
- A server presents a pairing link or QR code of the form:
72
+ A server presents a pairing link or QR code in one of two equivalent forms
73
+ (since 2.4), carrying the same parameters:
73
74
 
74
75
  ```
75
76
  pulsecam://?v=1&artifactId=<uuid>&server=<origin>&token=<opaque>
77
+ https://mieweb.github.io/pulse/open#v=1&artifactId=<uuid>&server=<origin>&token=<opaque>
76
78
  ```
77
79
 
80
+ - **`pulsecam://`**: the parameters are the query string. Every Pulse release
81
+ opens it, but phone cameras and browsers don't open custom schemes reliably.
82
+ - **`https://`**: a Universal Link (iOS) / App Link (Android) on the link host
83
+ the Pulse app claims. When Pulse is installed the OS opens it straight from
84
+ the link; when it isn't, the link opens a static page that sends the person
85
+ to the store and offers a `pulsecam://` fallback. The parameters are the
86
+ **fragment**, which is never sent in an HTTP request, so the link host never
87
+ receives the token. The link host is fixed (an app can only claim domains it
88
+ was built with); `server` still points at the deployment's own server, so
89
+ uploads never touch the link host.
90
+
91
+ A client that accepts the `https://` form MUST treat it exactly like the
92
+ `pulsecam://` form with the same parameters. A server that wants every Pulse
93
+ release to open its links directly keeps the `pulsecam://` form until the
94
+ releases that claim the link host are installed.
95
+
78
96
  - `v` (REQUIRED): the deep-link schema version. A client MUST refuse and
79
97
  explain (not silently misparse) a `v` it doesn't recognize.
80
98
  - `artifactId` (REQUIRED): a UUID minted by the server. The client uses this
@@ -95,7 +113,7 @@ pulsecam://?v=1&artifactId=<uuid>&server=<origin>&token=<opaque>
95
113
  credential — see §5.4 for the recommended (but not required) capability-
96
114
  token shape.
97
115
 
98
- A client SHOULD ignore query params it does not recognize, so links minted by
116
+ A client SHOULD ignore parameters it does not recognize, so links minted by
99
117
  an older or newer server stay valid. The parameters are defined by
100
118
  [`protocol/schemas/deep-link.schema.json`](protocol/schemas/deep-link.schema.json).
101
119
 
@@ -246,6 +264,18 @@ the token's `artifactId` as its `relatedTo` (§8) — this lets one token cover
246
264
  an entire upload session (a pulse's video plus its captions, beat manifest and
247
265
  thumbnail) rather than requiring one token per artifact.
248
266
 
267
+ A token MAY also carry an opaque `ctx` claim (protocol 2.3): host data signed
268
+ with the rest — who the upload is for, where it goes — that the server stores
269
+ with the artifact the token creates and hands back to its own hooks, so the
270
+ host needs no table keyed by artifactId. A client never reads it.
271
+
272
+ When a token names a pulse's video, a server SHOULD hold the token to the
273
+ shape of a pulse (§8): the video only under the token's own `artifactId`, with
274
+ no `relatedTo`, and a thumbnail, beat manifest or captions only under another
275
+ id, `relatedTo` the token's. Otherwise one token can create any number of
276
+ artifacts under ids of the client's choosing, or take the video's own id with a
277
+ thumbnail so the video itself can never land.
278
+
249
279
  This shape is exactly what `@mieweb/pulsevault`'s `issueCapabilityToken`/
250
280
  `verifyCapabilityToken`/`createCapabilityAuthorize` implement, but any server
251
281
  is free to use its own scheme entirely — only §5.1–§5.3 are normative.
@@ -312,6 +342,31 @@ right after the upload finishes, while its pairing token is still valid; a
312
342
  server without `viewLinks` offers no shareable link, and the client MUST NOT
313
343
  share the pairing token in its place.
314
344
 
345
+ ### 6.5 Status and poster (optional, protocol 2.3)
346
+
347
+ A server MAY expose `GET {prefix}/artifacts/<artifactId>/status`, authorized
348
+ like opening the artifact (§5.1; a view token, §5.5, is enough), reporting
349
+ where an upload is so a page waiting on it needs nothing from the host. Its
350
+ body is defined by
351
+ [`protocol/schemas/artifact-status.schema.json`](protocol/schemas/artifact-status.schema.json):
352
+ `state` is `unknown`, `uploading`, `processing` (the server is converting the
353
+ bytes for the web) or `ready`, with the bytes received against the declared
354
+ length while uploading, and whatever outcome the host recorded once finished.
355
+ A server that converts videos for the web also reports what the conversion did
356
+ (`webReady`, protocol 2.5): nothing, a remux, a transcode, a new container, or
357
+ why it was skipped (the original then serves). The response MUST NOT be cached.
358
+
359
+ A server MAY also expose `GET {prefix}/artifacts/<videoId>/poster`: the finished
360
+ thumbnail `relatedTo` the video (§8), served exactly as §6.2 serves an artifact
361
+ and authorized as opening the video, so whoever may watch it may see its poster.
362
+ It returns `404` until the thumbnail has landed.
363
+
364
+ An unfinished upload whose client died (an app killed mid-upload sends no
365
+ TUS `DELETE`) would otherwise answer `409` to every later create of the same
366
+ `artifactId`. A server SHOULD let a create authorized by the same token take
367
+ over such an upload once it has been idle for a while, instead of refusing it
368
+ until cleanup runs.
369
+
315
370
  ## 7. Versioning
316
371
 
317
372
  The protocol has a version `major.minor`, written once in `@mieweb/pulsevault`'s
@@ -371,10 +426,11 @@ a server it paired with earlier: a server can be upgraded in between.
371
426
  | File | Defines |
372
427
  |---|---|
373
428
  | [`openapi.json`](protocol/openapi.json) | Every HTTP route, generated from the plugin |
429
+ | [`artifact-status.schema.json`](protocol/schemas/artifact-status.schema.json) | GET /artifacts/<id>/status response |
374
430
  | [`beat-manifest.schema.json`](protocol/schemas/beat-manifest.schema.json) | Beat manifest |
375
431
  | [`capabilities.schema.json`](protocol/schemas/capabilities.schema.json) | GET /capabilities response |
376
432
  | [`capability-token.schema.json`](protocol/schemas/capability-token.schema.json) | Capability token claims |
377
- | [`deep-link.schema.json`](protocol/schemas/deep-link.schema.json) | Pairing link query parameters |
433
+ | [`deep-link.schema.json`](protocol/schemas/deep-link.schema.json) | Pairing link parameters |
378
434
  | [`pulse-client.schema.json`](protocol/schemas/pulse-client.schema.json) | Pulse-Client request header |
379
435
  | [`upload-metadata.schema.json`](protocol/schemas/upload-metadata.schema.json) | TUS Upload-Metadata keys |
380
436
  | [`view-link.schema.json`](protocol/schemas/view-link.schema.json) | POST /artifacts/<id>/view-link response |
@@ -388,6 +444,9 @@ a server it paired with earlier: a server can be upgraded in between.
388
444
  | 2.0 | **Breaking:** `uploadUnit` removed from `/capabilities` and pairing links — a pulse always uploads as one video (§8). Clients built for 1.0 required it. |
389
445
  | 2.1 | Added `protocolRevision` to `/capabilities`, the `Pulse-Client` header with `426 Upgrade Required` (§7.2), and `Upload-Metadata.appVersion` (§4.1). |
390
446
  | 2.2 | Added read-only view links: `viewLinks` in `/capabilities`, `POST {prefix}/artifacts/<id>/view-link` (§6.4), and the view token's `use` claim (§5.5). |
447
+ | 2.3 | Added `GET {prefix}/artifacts/<id>/status` and `GET {prefix}/artifacts/<id>/poster` (§6.5), the capability token's `ctx` claim (§5.4), and the pulse-shape rule for creates (§5.4). |
448
+ | 2.4 | Added the `https://mieweb.github.io/pulse/open#…` pairing link form (§3), with the parameters in the fragment. |
449
+ | 2.5 | Added `webReady` to the status response (§6.5): what the server's conversion did. |
391
450
 
392
451
  ## 8. Artifact relationships (`relatedTo`)
393
452