@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.
- package/CHANGELOG.md +216 -1
- package/OPERATIONS.md +135 -44
- package/PROTOCOL.md +63 -4
- package/README.md +186 -34
- package/dist/app.d.ts +30 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +24 -5
- package/dist/app.js.map +1 -1
- package/dist/augment.d.ts +3 -0
- package/dist/augment.d.ts.map +1 -1
- package/dist/augment.js.map +1 -1
- package/dist/core.d.ts +107 -8
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +381 -47
- package/dist/core.js.map +1 -1
- package/dist/lib/authorize.d.ts +28 -6
- package/dist/lib/authorize.d.ts.map +1 -1
- package/dist/lib/capability-token.d.ts +26 -0
- package/dist/lib/capability-token.d.ts.map +1 -1
- package/dist/lib/capability-token.js +73 -5
- package/dist/lib/capability-token.js.map +1 -1
- package/dist/lib/completion.d.ts +138 -0
- package/dist/lib/completion.d.ts.map +1 -0
- package/dist/lib/completion.js +375 -0
- package/dist/lib/completion.js.map +1 -0
- package/dist/lib/deeplinks.d.ts +18 -2
- package/dist/lib/deeplinks.d.ts.map +1 -1
- package/dist/lib/deeplinks.js +13 -2
- package/dist/lib/deeplinks.js.map +1 -1
- package/dist/lib/magic.d.ts +41 -1
- package/dist/lib/magic.d.ts.map +1 -1
- package/dist/lib/magic.js +104 -0
- package/dist/lib/magic.js.map +1 -1
- package/dist/lib/options.d.ts +2 -0
- package/dist/lib/options.d.ts.map +1 -1
- package/dist/lib/options.js +8 -2
- package/dist/lib/options.js.map +1 -1
- package/dist/lib/protocol-version.gen.js +1 -1
- package/dist/lib/pulsevaultTus.d.ts +28 -26
- package/dist/lib/pulsevaultTus.d.ts.map +1 -1
- package/dist/lib/pulsevaultTus.js +149 -32
- package/dist/lib/pulsevaultTus.js.map +1 -1
- package/dist/lib/web-ready.d.ts +138 -18
- package/dist/lib/web-ready.d.ts.map +1 -1
- package/dist/lib/web-ready.js +453 -91
- package/dist/lib/web-ready.js.map +1 -1
- package/dist/routes/pulsevault.d.ts +12 -1
- package/dist/routes/pulsevault.d.ts.map +1 -1
- package/dist/routes/pulsevault.js +92 -1
- package/dist/routes/pulsevault.js.map +1 -1
- package/dist/storage/local.d.ts +7 -1
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +408 -52
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/s3.d.ts +7 -1
- package/dist/storage/s3.d.ts.map +1 -1
- package/dist/storage/s3.js +256 -14
- package/dist/storage/s3.js.map +1 -1
- package/dist/storage/types.d.ts +126 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js +7 -0
- package/dist/storage/types.js.map +1 -1
- package/package.json +2 -2
- package/protocol/openapi.json +270 -1
- package/protocol/schemas/artifact-status.schema.json +61 -0
- package/protocol/schemas/capability-token.schema.json +3 -0
- package/protocol/schemas/deep-link.schema.json +2 -2
- 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.
|
|
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 (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
219
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|