@weft-js/plugin-media 0.1.0 → 0.1.3

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/README.md CHANGED
@@ -1,9 +1,476 @@
1
- # @weft-js/plugin-media
1
+ <p align="center">
2
+ <a href="https://github.com/Ravonus/weft">
3
+ <img src="https://raw.githubusercontent.com/Ravonus/weft/main/site/public/logo.svg" width="88" height="88" alt="Weft">
4
+ </a>
5
+ </p>
2
6
 
3
- > **Early release (0.1.0).** APIs will change between minor versions.
7
+ <h1 align="center">@weft-js/plugin-media</h1>
4
8
 
5
- The media.build@1 provider (sharp + SVGO: AVIF plus a compatible fallback, an SSIMULACRA2 quality search, width ladders, placeholders), the media.server@1 route provider, and M001–M018.
9
+ <p align="center">
10
+ <strong>Images that arrive at their size: AVIF plus a fallback, a width ladder, a placeholder, and quality measured per image.</strong><br>
11
+ One plugin encodes <code>?media</code> imports at build time, serves uploads at run time, and checks every <code>&lt;img&gt;</code> and <code>&lt;video&gt;</code> you write.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@weft-js/plugin-media"><img src="https://img.shields.io/npm/v/@weft-js/plugin-media?color=145c3c&label=npm" alt="npm version"></a>
16
+ <a href="https://github.com/Ravonus/weft/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-145c3c" alt="MIT license"></a>
17
+ <a href="https://github.com/Ravonus/weft/actions"><img src="https://img.shields.io/github/actions/workflow/status/Ravonus/weft/quality.yml?branch=main&label=checks" alt="checks"></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#quick-start">Quick start</a> ·
22
+ <a href="https://github.com/Ravonus/weft/tree/main/docs">Docs</a> ·
23
+ <a href="https://github.com/Ravonus/weft/tree/main/examples">Examples</a> ·
24
+ <a href="https://github.com/Ravonus/weft/blob/main/CHANGELOG.md">Changelog</a> ·
25
+ <a href="https://github.com/Ravonus/weft">GitHub</a>
26
+ </p>
27
+
28
+ > [!IMPORTANT]
29
+ > **Early release (0.1.x).** APIs can change between minor versions. Pin the version and read the
30
+ > [changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md) before you upgrade.
31
+
32
+ Weft's `<Media>` component (from `@weft-js/core/media`) renders a record: intrinsic width and height, the files to pick
33
+ from, a placeholder. This plugin is what makes those records. Without it, a `?media` import reads the size from the
34
+ file header and serves the original. With it, the build resizes and re-encodes every image with
35
+ [sharp](https://sharp.pixelplumbing.com/), cleans SVGs with [SVGO](https://svgo.dev/), and the server does the same for
36
+ files your users upload. It also adds the `M` diagnostics to `weft check`, so a raw `<img>` without a size or an
37
+ `alt` is caught before it ships.
38
+
39
+ ## Highlights
40
+
41
+ - **AVIF first, a compatible fallback last.** Efficient formats (`avif` by default, `webp` on request) go in front of a
42
+ fallback that follows the source: JPEG for photos, lossless PNG for graphics and anything with alpha.
43
+ - **Quality measured, not guessed.** With the `ssimulacra2` tool on `PATH`, each format is binary-searched (at most 5
44
+ encodes) to the lowest quality that scores at or above the target, 80 by default. Without it, fixed qualities are
45
+ used and the record says `quality: 'fixed'`.
46
+ - **A width ladder.** 320, 640, 960, 1280, 1920 and 2560 px by default, capped at the source's own width, so the browser
47
+ picks the smallest file that is sharp enough.
48
+ - **Placeholders.** A dominant colour and a 16 px WebP data URL, so the box is filled before the image paints.
49
+ - **Uploads on the same path.** `ctx.media.ingest(file)` stores the original under a hash of its bytes and returns the
50
+ same record shape; each size is encoded once, on first request, and cached for a year.
51
+ - **Unsafe SVGs refused.** An SVG with `<script>`, `<foreignObject>`, `on*=` handlers, an external `href`, `url()` or
52
+ `@import` is neither optimised nor stored (M012).
53
+ - **Video, audio and GIFs** when [`@weft-js/plugin-media-ffmpeg`](https://www.npmjs.com/package/@weft-js/plugin-media-ffmpeg) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/plugin-media-ffmpeg)</sup>
54
+ is also installed: an AV1/H.264 ladder with a poster, Opus and AAC, and GIFs turned into video when that saves 15%.
55
+ - **Cached by content.** Build output is keyed by the bytes, the options, the encoder versions and a format version
56
+ under `.weft/cache/media`, so restarts and rebuilds don't re-encode.
57
+
58
+ ## Contents
59
+
60
+ - [Install](#install)
61
+ - [Quick start](#quick-start)
62
+ - [How it plugs into Weft](#how-it-plugs-into-weft)
63
+ - [Options](#options)
64
+ - [Images at build time](#images-at-build-time)
65
+ - [Uploads and the media route](#uploads-and-the-media-route)
66
+ - [Video, audio and animated GIFs](#video-audio-and-animated-gifs)
67
+ - [The `encoder` modes](#the-encoder-modes)
68
+ - [Diagnostics](#diagnostics)
69
+ - [API reference](#api-reference)
70
+ - [Recipes](#recipes)
71
+ - [Limits](#limits)
72
+ - [Troubleshooting](#troubleshooting)
73
+ - [Related packages](#related-packages)
74
+
75
+ ## Install
6
76
 
7
77
  ```bash
78
+ # npm
8
79
  npm install @weft-js/plugin-media
80
+
81
+ # pnpm
82
+ pnpm add @weft-js/plugin-media
83
+
84
+ # bun
85
+ bun add @weft-js/plugin-media
86
+ ```
87
+
88
+ Or let the `weft` CLI install it and add it to `weft.config.ts` in one step:
89
+
90
+ ```bash
91
+ npx weft add media
92
+ ```
93
+
94
+ The `landing` template (`--template landing`) already has it in its dependencies and its `weft.config.ts`; the other templates and a
95
+ plain new app don't.
96
+
97
+ `weft add media` installs the package and writes this into `weft.config.ts` (the package has no `/plugin` entry, so
98
+ the import is from the main entry, by its named export):
99
+
100
+ ```ts
101
+ import { media } from '@weft-js/plugin-media'
102
+ ```
103
+
104
+ **What it needs**
105
+
106
+ | Need | Why |
107
+ | --- | --- |
108
+ | Node 26 or later | `engines` in package.json |
109
+ | `sharp` (a dependency of this package) | Every raster encode. For the media route, the **app** needs `sharp` in its own dependencies so the built server can load it; a build without it warns with M017. |
110
+ | `ssimulacra2` on `PATH` (optional) | The quality search. It's the SSIMULACRA2 tool from libjxl. Without it, fixed qualities are used. |
111
+ | A disk that outlives deploys (for uploads) | The media route stores files in `.weft/media`, or `WEFT_MEDIA_DIR`. |
112
+ | `@weft-js/plugin-media-ffmpeg` and ffmpeg (optional) | Video, audio, and GIF-to-video. |
113
+
114
+ ## Quick start
115
+
116
+ ```ts
117
+ // weft.config.ts
118
+ import { defineConfig } from '@weft-js/core/config'
119
+ import { media } from '@weft-js/plugin-media'
120
+
121
+ export default defineConfig({
122
+ plugins: [media()],
123
+ })
124
+ ```
125
+
126
+ ```tsx
127
+ // src/routes/index.tsx
128
+ import { Media } from '@weft-js/core/media'
129
+ import hero from '../../assets/hero.jpg?media'
130
+
131
+ export default function Home() {
132
+ return (
133
+ <Media
134
+ src={hero}
135
+ alt="A lighthouse at dusk, its beam crossing low cloud"
136
+ priority
137
+ width="1/2"
138
+ />
139
+ )
140
+ }
9
141
  ```
142
+
143
+ `hero` is a record, not a URL: `width`, `height`, `sources` (an AVIF `srcset` and a JPEG one), `dominant` and
144
+ `placeholder`. `<Media>` renders it with its final box size, the placeholder behind it, and `srcset`/`sizes` from
145
+ `width`. `priority` loads the one hero image eagerly; everything else is lazy.
146
+
147
+ ## How it plugs into Weft
148
+
149
+ `media()` is a plugin (namespace `M`) that provides two slots and a check hook:
150
+
151
+ | Slot / hook | Side | Module | What it does |
152
+ | --- | --- | --- | --- |
153
+ | `media.build@1` | build | `src/build.mjs` → `createMediaBuild` | `@weft-js/core/vite` calls it for each `?media` import (and `?media&audio=…`). |
154
+ | `media.server@1` | server | `@weft-js/plugin-media/server` → `createMediaServer` | `@weft-js/start` uses it for `ctx.media.ingest` and the `/_weft/m/…` route. |
155
+ | `check` | `weft check` | `src/check.mjs` → `checkMedia` | Runs over `**/*.{tsx,jsx}` and reports M001–M013 and M016. |
156
+ | `media.encode@1` (consumer) | build, server | provided by `@weft-js/plugin-media-ffmpeg` | Video, audio and animated GIFs. Without a provider, a video import is P101. |
157
+
158
+ It declares these permissions: read media files (`png`, `jpg`, `gif`, `webp`, `avif`, `svg` and common video/audio
159
+ extensions), the `WEFT_MEDIA_DIR` environment variable, spawning `ssimulacra2`, and a native addon (sharp). It also
160
+ contributes agent notes and a catalog entry for `<Media>`.
161
+
162
+ Options are validated when the config loads. An unknown key or a bad value is **P115**, with the option's path (for
163
+ example `formats.0` or `quality.target`).
164
+
165
+ ## Options
166
+
167
+ All options are optional. `media()` with none is the configuration the defaults below describe.
168
+
169
+ | Option | Type | Default | What it does |
170
+ | --- | --- | --- | --- |
171
+ | `formats` | `('avif' \| 'webp')[]` | `['avif']` | Efficient formats placed before the fallback. The fallback follows the source and isn't listed here. |
172
+ | `widths` | `number[]` | `[320, 640, 960, 1280, 1920, 2560]` | The width ladder, sorted for you. The largest also caps the output width. |
173
+ | `quality.target` | `number` (1–100) | `80` | The SSIMULACRA2 score each format is searched to. 80 is about visually lossless at normal viewing. |
174
+ | `quality.scorer` | `string` | `'ssimulacra2'` | The scorer command, or `'none'` to use fixed qualities. |
175
+ | `placeholder` | `boolean` | `true` | Adds `dominant` (a `#rrggbb` colour) and `placeholder` (a 16 px WebP data URL). |
176
+ | `gifLimit` | `number` (bytes) | `262144` (256 KB) | A GIF over this is M004, at build and in `weft check`. |
177
+ | `encoder` | `'off' \| 'compatible' \| 'server'` | `'off'` | See [The `encoder` modes](#the-encoder-modes). |
178
+ | `swap` | `'intent' \| 'idle' \| 'never'` | `'intent'` | When the compatible copy swaps in. Only valid with `encoder: 'compatible'`. |
179
+ | `video.codecs` | `('av1' \| 'vp9' \| 'hevc')[]` | `['av1']` | Efficient video codecs next to H.264, which is always there. |
180
+ | `video.widths` | `number[]` | `[640, 960, 1280, 1920]` | The video ladder. |
181
+ | `gif.toVideo` | `'auto' \| 'never'` | `'auto'` | `'never'` keeps animated GIFs as GIFs. |
182
+ | `gif.keepUnder` | `number` (bytes) | `8192` | A GIF under this stays a GIF. |
183
+ | `server.dir` | `string` | `'.weft/media'` | The upload store, relative to the app. `WEFT_MEDIA_DIR` overrides it. |
184
+ | `server.maxBytes` | `number` | `20971520` (20 MB) | The largest upload `ingest` accepts (M018 above it). |
185
+
186
+ Fixed qualities, used when no scorer runs: AVIF 50, WebP 75, JPEG 78. PNG is lossless at compression level 9.
187
+
188
+ ## Images at build time
189
+
190
+ For each `?media` import the build provider:
191
+
192
+ 1. Reads the image with sharp and applies EXIF orientation, so a portrait shot stored sideways comes out upright with
193
+ its width and height swapped.
194
+ 2. Builds the ladder: every configured width below the source's width, plus the source width capped at the largest
195
+ rung. A 1000 px image with the default ladder gets 320, 640, 960 and 1000.
196
+ 3. Picks formats: the configured efficient formats, then the fallback. JPEG, AVIF, HEIF, TIFF and opaque WebP sources
197
+ count as photos (JPEG fallback); everything else, and WebP with alpha, is a graphic (PNG fallback).
198
+ 4. Searches quality once per format at the largest rung, between AVIF 20–90 or WebP/JPEG 40–95.
199
+ 5. Encodes every format at every width and names each file `<stem>-<width>-<hash>.<ext>`.
200
+ 6. Adds the placeholder and dominant colour.
201
+
202
+ The record looks like this:
203
+
204
+ ```ts
205
+ {
206
+ // 'graphic' for PNG-like sources, 'vector' for SVG
207
+ kind: 'photo',
208
+ src: 'hero-1280-3f9c0a1b.jpg', // the largest fallback file
209
+ width: 1280, height: 853,
210
+ type: 'image/jpeg',
211
+ sources: [
212
+ { type: 'image/avif', srcset: 'hero-320-….avif 320w, …' },
213
+ { type: 'image/jpeg', srcset: 'hero-320-….jpg 320w, …' },
214
+ ],
215
+ name: 'hero.jpg',
216
+ quality: 'ssimulacra2', // or 'fixed'
217
+ qualities: { avif: 52, jpeg: 81 },
218
+ dominant: '#6b7f5a',
219
+ placeholder: 'data:image/webp;base64,…',
220
+ }
221
+ ```
222
+
223
+ **SVG** is checked for unsafe content (M012), must have `width`/`height` or a `viewBox` (M001 otherwise), and goes
224
+ through SVGO with `multipass`. It ships as one file with `kind: 'vector'`.
225
+
226
+ **Animated GIFs** ship as they are unless a `media.encode` provider can turn them into something smaller (see below).
227
+ A GIF over `gifLimit` that stays a GIF is M004.
228
+
229
+ ## Uploads and the media route
230
+
231
+ `createMediaServer(options).at(root, encode)` is the store `@weft-js/start` gives your server functions as `ctx.media`:
232
+
233
+ ```ts
234
+ // src/server/photos.ts
235
+ import { mutation } from '@weft-js/start'
236
+ import { z } from '@weft-js/start/media'
237
+
238
+ export const uploadPhoto = mutation({
239
+ input: z.object({
240
+ photo: z
241
+ .file()
242
+ .max(8 * 1024 * 1024)
243
+ .mime(['image/jpeg', 'image/png', 'image/webp']),
244
+ alt: z.string().trim().min(1).max(200),
245
+ }),
246
+ auth: 'user',
247
+ run: async ({ input, ctx }) => {
248
+ const image = await ctx.media.ingest(input.photo)
249
+ // store `image` (a MediaAsset) in a JSON column, render it with
250
+ // <Media src={image} alt={…} />
251
+ return image
252
+ },
253
+ })
254
+ ```
255
+
256
+ What `ingest(bytes, { name })` does:
257
+
258
+ - Refuses files over `server.maxBytes` with **M018**.
259
+ - Hashes the bytes (SHA-256, first 32 hex digits). The same bytes are stored once; a second upload returns the stored
260
+ record with its own `name`.
261
+ - Sniffs video and audio from the first bytes, not the file name. Those need `@weft-js/plugin-media-ffmpeg`, or they are
262
+ M018 naming the fix.
263
+ - Refuses an unsafe SVG with **M012**, and anything sharp can't read with **M018**.
264
+ - Writes `source` and `meta.json` into `<dir>/<hash>/` (`meta.json` last, through a rename, so it only exists when the
265
+ original is whole) and returns a record whose URLs are `/_weft/m/<hash>/<width>.auto`.
266
+
267
+ Nothing is resized at upload. The route `/_weft/m/<hash>/<width>.<format>` encodes a size on its first request, writes
268
+ it next to the original, and reads it from disk afterwards. Concurrent requests for the same size share one encode.
269
+
270
+ | Rule | Detail |
271
+ | --- | --- |
272
+ | Formats | `auto` picks the first of `formats` the `Accept` header names with `q > 0`, in the config's order; a wildcard promises nothing, so it falls back to JPEG or PNG. `avif`, `webp`, `jpg` and `png` can be asked for directly when configured or the fallback. |
273
+ | Widths | Only widths on the record's ladder. Anything else, or a malformed hash, is a 404. |
274
+ | Headers | `cache-control: public, max-age=31536000, immutable`, an `ETag`, `x-content-type-options: nosniff`, `vary: Accept` when negotiated, and a `sandbox` content security policy for SVG. |
275
+ | Quality | Fixed qualities: no SSIMULACRA2 search at run time. |
276
+
277
+ ## Video, audio and animated GIFs
278
+
279
+ These need a `media.encode@1` provider. Add [`@weft-js/plugin-media-ffmpeg`](https://www.npmjs.com/package/@weft-js/plugin-media-ffmpeg) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/plugin-media-ffmpeg)</sup>
280
+ next to `media()`:
281
+
282
+ ```ts
283
+ import { defineConfig } from '@weft-js/core/config'
284
+ import { media } from '@weft-js/plugin-media'
285
+ import { mediaFfmpeg } from '@weft-js/plugin-media-ffmpeg'
286
+
287
+ export default defineConfig({
288
+ plugins: [
289
+ media({ video: { codecs: ['av1', 'vp9'] } }),
290
+ mediaFfmpeg(),
291
+ ],
292
+ })
293
+ ```
294
+
295
+ **Video** (`.mp4`, `.m4v`, `.mov`, `.webm`, `.mkv`, `.ogv`):
296
+
297
+ - An H.264 ladder is always built, plus each configured efficient codec. An efficient set ships only if it is at least
298
+ 15% smaller than the H.264 one; winners go first, H.264 last.
299
+ - With a scorer, a CRF search (at most 4 encodes) at the top width samples five frames: the mean SSIMULACRA2 score must
300
+ reach the target and the worst frame must reach the target minus 5.
301
+ - An H.264 `yuv420p` source already at the top width, with AAC or no audio, is remuxed at that rung instead of
302
+ re-encoded.
303
+ - A WebP poster frame is taken at `min(1 s, duration / 10)`, with the placeholder from the same frame.
304
+ - HDR or over-8-bit sources come out 8-bit SDR, with **M015**.
305
+
306
+ **Audio** (`.mp3`, `.wav`, `.flac`, `.ogg`, `.oga`, `.opus`, `.m4a`, `.aac`): Opus and AAC. A source already in that
307
+ codec and under its bitrate (96 kbps Opus, 128 kbps AAC) is remuxed, not re-encoded.
308
+
309
+ **Animated GIFs** become video when that is a real saving. A GIF stays a GIF when it is under `gif.keepUnder`, has
310
+ fewer than 4 frames, when nothing comes out at least 15% smaller, or with `gif.toVideo: 'never'`. A GIF with
311
+ transparency becomes an animated WebP instead (H.264 has no alpha), when that is 15% smaller.
312
+
313
+ **Muxing a sound into a loop:** `import loop from './loop.gif?media&audio=./beat.opus'` loops the GIF for the length of
314
+ the sound.
315
+
316
+ Uploaded video and audio use fixed CRFs and a queue: `ingest` returns the record at once, and the sizes encode one at a
317
+ time. Until a size exists, its URL serves the original with `cache-control: no-store` if browsers can play it, and
318
+ otherwise waits for that encode. A restart loses nothing: the first request for a missing size encodes it.
319
+
320
+ ## The `encoder` modes
321
+
322
+ | Mode | What happens |
323
+ | --- | --- |
324
+ | `'off'` (default) | Everything is encoded at build time; the files ship with the build. |
325
+ | `'compatible'` | The page shows the efficient file, and a compatible copy swaps in so a saved file opens anywhere. For images the copy is the fallback ladder that already exists, so it adds 0 KB. For video it is the H.264 top rung; for a GIF turned video, the original GIF. **M014** (info) says what each asset adds. `swap` picks when: `'intent'` (right-click, long-press or drag), `'idle'`, or `'never'`. |
326
+ | `'server'` | Image `?media` imports are not encoded at build time. The build stores each original in the media store and the record points at `/_weft/m/…`, so the server encodes sizes on first request. Video and audio are still built. Needs a running server and a store that reaches it. |
327
+
328
+ ## Diagnostics
329
+
330
+ `weft docs M001` (or any code) prints the full entry. The `by` column says who reports it.
331
+
332
+ | Code | Severity | Reported by | Catches |
333
+ | --- | --- | --- | --- |
334
+ | M001 | error | `weft check`, build | A raw `<img>`/`<video>` with no `width`/`height` or `aspect-ratio` outside a `<Boundary>`; an SVG with no size. The check inserts the file's size for an imported file. |
335
+ | M002 | error | `weft check` | An image rendered through `?url` or a string path to a project file. The fix rewrites the import to `?media`. |
336
+ | M003 | warning | `weft check` | A raw `<img>` whose file is wider than its `width` × 2 × 1.25. |
337
+ | M004 | warning | `weft check`, build | A GIF over `gifLimit` rendered raw, or shipped as a GIF. |
338
+ | M005 | error | `weft check` | `<video autoplay>` without `muted` (autofix). |
339
+ | M006 | warning | `weft check` | `<video>` without `poster`. |
340
+ | M007 | error | `weft check` | Missing `alt`, `<Media alt="">` without `decorative`, or `alt` equal to the file name. |
341
+ | M008 | warning | `weft check` | A route's first media is `loading="lazy"`, or `priority` media is lazy (autofix for the second). |
342
+ | M009 | warning | `weft check` | More than 2 `priority` media in one route (autofix). |
343
+ | M010 | warning | `weft check` | A `priority` `<Media>` with no `width` or `sizes`. |
344
+ | M011 | warning | `weft check` | `<video autoplay loop>` without `controls`, or a GIF in a raw `<img>` (WCAG 2.2.2). |
345
+ | M012 | error | `weft check`, build, `ingest` | An SVG with active content or external references. |
346
+ | M013 | warning | `weft check` | A `public/` file referenced by string. |
347
+ | M014 | info | build | What `encoder: 'compatible'` adds. |
348
+ | M015 | warning | build | An HDR or over-8-bit video that comes out 8-bit SDR. |
349
+ | M016 | warning | `weft check` | `<Media src>` given a string. |
350
+ | M017 | error | `@weft-js/start` | `ctx.media` or a `/_weft/m/` URL with no `media.server` provider, or `sharp` missing from the app's dependencies. |
351
+ | M018 | error | `ingest`, build | An upload that isn't a readable image or is over `maxBytes` (422); a video with no video track; a video upload with no encoder. |
352
+ | M019 | error/warning | `@weft-js/plugin-media-ffmpeg` | ffmpeg missing, not the pinned binary, or without an encoder. |
353
+
354
+ ## API reference
355
+
356
+ | Export | Entry | What it is |
357
+ | --- | --- | --- |
358
+ | `media(options?)` | `@weft-js/plugin-media` | The plugin for `weft.config.ts`. Typed by `MediaOptions`. |
359
+ | `MediaOptions` | `@weft-js/plugin-media` (type) | The options in the table above. |
360
+ | `createMediaBuild(options?)` | `@weft-js/plugin-media/build` | The `media.build@1` provider: `{ build(source, ctx) }` returns `{ asset, files, diagnostics? }`. |
361
+ | `findScorer(name)` | `@weft-js/plugin-media/build` | The scorer command if it runs, else `null`. Cached per process. |
362
+ | `DEFAULTS`, `FIXED`, `MIME`, `encoders`, `hex`, `isPhoto` | `@weft-js/plugin-media/build` | Shared defaults, fixed qualities, content types, the sharp encoder calls and helpers. |
363
+ | `createMediaServer(options?)` | `@weft-js/plugin-media/server` | The `media.server@1` provider: `.at(root, encode)` returns `{ ingest, serve, settled }`. |
364
+ | `negotiate(accept, formats, fallback)` | `@weft-js/plugin-media/server` | Picks the format for `auto` from an `Accept` header. |
365
+
366
+ <details>
367
+ <summary><code>createMediaBuild().build(source, ctx)</code></summary>
368
+
369
+ `source` is `{ file, bytes, audio? }`: the absolute path, the bytes, and an optional audio file to mux in. `ctx` is
370
+ `{ cacheDir, root, encode }`, where `encode` maps `media.encode` keys (`video/h264`, `audio/opus`, …) to providers.
371
+ Core's Vite plugin passes `.weft/cache/media` as `cacheDir`. The result's `files` are `{ name, bytes }` for the build to
372
+ emit; a diagnostic with `severity: 'error'` fails that import.
373
+
374
+ </details>
375
+
376
+ <details>
377
+ <summary><code>createMediaServer().at(root, encode)</code></summary>
378
+
379
+ - `ingest(bytes, { name? })`: stores an original and returns a `MediaAsset`. Throws an `Error` with `code` set to
380
+ `M012` or `M018` when it refuses.
381
+ - `serve({ hash, width, format, accept })`: returns `{ bytes, type, width, format, negotiated, temporary? }`, or `null`
382
+ for a 404.
383
+ - `settled()`: resolves when queued video and audio encodes are done (used by tests).
384
+
385
+ The store is `WEFT_MEDIA_DIR` when set, else `server.dir` resolved against `root`.
386
+
387
+ </details>
388
+
389
+ ## Recipes
390
+
391
+ **WebP as well as AVIF, and a smaller ladder**
392
+
393
+ ```ts
394
+ media({ formats: ['avif', 'webp'], widths: [480, 960, 1440] })
395
+ ```
396
+
397
+ **Fixed qualities on a CI machine without the scorer, on purpose**
398
+
399
+ ```ts
400
+ media({ quality: { scorer: 'none' } })
401
+ ```
402
+
403
+ **Uploads on a mounted volume**
404
+
405
+ ```bash
406
+ WEFT_MEDIA_DIR=/data/media node .output/server/index.mjs
407
+ ```
408
+
409
+ On `weft deploy` targets with a volume, uploads go to `.data/media` through `WEFT_MEDIA_DIR`.
410
+
411
+ **A raw `<img>` that the check flags, and the fix**
412
+
413
+ ```tsx
414
+ // M001, M002: no size, and the file bypasses the optimiser
415
+ import logo from './logo.png?url'
416
+ export const Logo = () => <img src={logo} alt="Acme" />
417
+ ```
418
+
419
+ ```tsx
420
+ import { Media } from '@weft-js/core/media'
421
+ import logo from './logo.png?media'
422
+ export const Logo = () => (
423
+ <Media
424
+ src={logo}
425
+ alt="Acme"
426
+ width="120px"
427
+ />
428
+ )
429
+ ```
430
+
431
+ ## Limits
432
+
433
+ - Image input is PNG, JPEG, GIF, WebP, AVIF and SVG (what `ingest` names when it refuses a file). Efficient output
434
+ formats are AVIF and WebP only.
435
+ - The quality search runs at build time only. Uploaded images use fixed qualities.
436
+ - The upload store is a directory on the server's disk. Function hosts without a persistent disk can't keep uploads
437
+ (`weft check` reports E003 for a deploy target). `cdn.images` (Cloudinary) is checked but not wired yet (E014).
438
+ - Video, audio and GIF-to-video need ffmpeg through `@weft-js/plugin-media-ffmpeg`. Without it a video `?media` import
439
+ fails with P101 and a video upload with M018.
440
+ - The build cache under `.weft/cache/media` grows with every distinct input; delete it to reclaim space.
441
+
442
+ ## Troubleshooting
443
+
444
+ | Symptom | Cause and fix |
445
+ | --- | --- |
446
+ | `quality: 'fixed'` on every record | `ssimulacra2` isn't on `PATH`. Install libjxl's tool, or set `quality.scorer` to its path. |
447
+ | `P115` when the config loads | A bad option. The message names the path, such as `formats.0` (`jxl` isn't supported) or `quality.target` (must be 1–100). `swap` without `encoder: 'compatible'` is also P115. |
448
+ | `M017` in the server log | No `media()` in `plugins`, or the app doesn't list `sharp` in its own dependencies. |
449
+ | `M018` on upload | Over `server.maxBytes` (20 MB by default), not an image sharp can read, or a video with no encoder installed. |
450
+ | `P101 … needs media.encode` | A video or audio `?media` import with no `mediaFfmpeg()` in `plugins`. |
451
+ | Uploaded images vanish after a deploy | The store isn't on persistent storage. Point `WEFT_MEDIA_DIR` at a volume. |
452
+
453
+ ## Related packages
454
+
455
+ | Package | What it is |
456
+ | --- | --- |
457
+ | [`@weft-js/core`](https://www.npmjs.com/package/@weft-js/core) | `<Media>`, `timeline()` and the `?media` import (`@weft-js/core/media`). |
458
+ | [`@weft-js/start`](https://www.npmjs.com/package/@weft-js/start) | `ctx.media`, `z.media()` and the `/_weft/m/` route. |
459
+ | [`@weft-js/plugin-media-ffmpeg`](https://www.npmjs.com/package/@weft-js/plugin-media-ffmpeg) | The `media.encode@1` provider: video and audio through ffmpeg. |
460
+ | [`@weft-js/cli`](https://www.npmjs.com/package/@weft-js/cli) | `weft add media`, `weft check` and `weft docs M001`. |
461
+ | [`@weft-js/text`](https://www.npmjs.com/package/@weft-js/text) | The other asset plugin: fonts and text measurement. |
462
+
463
+ ## Contributing
464
+
465
+ See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). From the repository root:
466
+
467
+ ```bash
468
+ pnpm --filter @weft-js/plugin-media test
469
+ ```
470
+
471
+ The video tests skip where ffmpeg isn't on `PATH`. `test/save.headful.mjs` is the `encoder: 'compatible'` save-as gate in headed
472
+ browsers; it needs Linux, Xvfb and `xdotool`, and its header says how to run it.
473
+
474
+ ## License
475
+
476
+ [MIT](https://github.com/Ravonus/weft/blob/main/LICENSE)
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@weft-js/plugin-media",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "@weft-js/plugin-media: the media.build@1 provider (sharp + SVGO: AVIF plus a compatible fallback, an SSIMULACRA2 quality search, width ladders, placeholders), the media.server@1 route provider, and M001–M018",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/plugin-media"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/plugin-media#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Ravonus/weft/issues"
14
+ },
6
15
  "type": "module",
7
16
  "exports": {
8
17
  ".": {
@@ -17,7 +26,7 @@
17
26
  "svgo": "4.1.0"
18
27
  },
19
28
  "peerDependencies": {
20
- "@weft-js/core": "^0.1.0"
29
+ "@weft-js/core": "^0.1.3"
21
30
  },
22
31
  "engines": {
23
32
  "node": ">=26"
@@ -27,12 +36,10 @@
27
36
  ],
28
37
  "files": [
29
38
  "src",
30
- "plugin.mjs",
31
- "plugin.d.mts",
32
39
  "README.md",
33
40
  "LICENSE"
34
41
  ],
35
42
  "publishConfig": {
36
43
  "access": "public"
37
44
  }
38
- }
45
+ }