@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 +470 -3
- package/package.json +12 -5
- package/src/build.mjs +143 -30
- package/src/check.mjs +216 -62
- package/src/index.mjs +123 -32
- package/src/server/ingest.mjs +118 -0
- package/src/server/serve.mjs +60 -0
- package/src/server.mjs +13 -91
- package/src/shared.mjs +15 -5
- package/src/upload/gif.mjs +46 -0
- package/src/upload/ingest.mjs +45 -0
- package/src/upload/record.mjs +10 -0
- package/src/upload/rung.mjs +37 -0
- package/src/upload/serve.mjs +41 -0
- package/src/upload/video.mjs +54 -0
- package/src/upload.mjs +79 -89
- package/src/video/audio.mjs +52 -0
- package/src/video/common.mjs +28 -0
- package/src/video.mjs +222 -92
package/README.md
CHANGED
|
@@ -1,9 +1,476 @@
|
|
|
1
|
-
|
|
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
|
-
>
|
|
7
|
+
<h1 align="center">@weft-js/plugin-media</h1>
|
|
4
8
|
|
|
5
|
-
|
|
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><img></code> and <code><video></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.
|
|
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.
|
|
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
|
+
}
|