@weft-js/plugin-media 0.1.3 → 0.1.5-nightly.20261111.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 +14 -2
- package/package.json +3 -3
- package/src/build.mjs +2 -3
- package/src/index.d.ts +5 -2
- package/src/index.mjs +8 -1
- package/src/shared.mjs +16 -0
- package/src/upload/video.mjs +3 -2
- package/src/video.mjs +5 -3
package/README.md
CHANGED
|
@@ -105,7 +105,7 @@ import { media } from '@weft-js/plugin-media'
|
|
|
105
105
|
|
|
106
106
|
| Need | Why |
|
|
107
107
|
| --- | --- |
|
|
108
|
-
| Node
|
|
108
|
+
| Node 24 or later | `engines` in package.json |
|
|
109
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
110
|
| `ssimulacra2` on `PATH` (optional) | The quality search. It's the SSIMULACRA2 tool from libjxl. Without it, fixed qualities are used. |
|
|
111
111
|
| A disk that outlives deploys (for uploads) | The media route stores files in `.weft/media`, or `WEFT_MEDIA_DIR`. |
|
|
@@ -172,7 +172,7 @@ All options are optional. `media()` with none is the configuration the defaults
|
|
|
172
172
|
| `widths` | `number[]` | `[320, 640, 960, 1280, 1920, 2560]` | The width ladder, sorted for you. The largest also caps the output width. |
|
|
173
173
|
| `quality.target` | `number` (1–100) | `80` | The SSIMULACRA2 score each format is searched to. 80 is about visually lossless at normal viewing. |
|
|
174
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
|
|
175
|
+
| `placeholder` | `boolean` · `'inline'` · `'file'` | `true` | Adds `dominant` (a `#rrggbb` colour) and `placeholder`, a 16 px WebP. `true` or `'inline'`: a `data:` URL. `'file'`: its own hashed file, for a CSP without `data:` in `img-src`. `false`: neither. |
|
|
176
176
|
| `gifLimit` | `number` (bytes) | `262144` (256 KB) | A GIF over this is M004, at build and in `weft check`. |
|
|
177
177
|
| `encoder` | `'off' \| 'compatible' \| 'server'` | `'off'` | See [The `encoder` modes](#the-encoder-modes). |
|
|
178
178
|
| `swap` | `'intent' \| 'idle' \| 'never'` | `'intent'` | When the compatible copy swaps in. Only valid with `encoder: 'compatible'`. |
|
|
@@ -400,6 +400,17 @@ media({ formats: ['avif', 'webp'], widths: [480, 960, 1440] })
|
|
|
400
400
|
media({ quality: { scorer: 'none' } })
|
|
401
401
|
```
|
|
402
402
|
|
|
403
|
+
**A strict CSP: the placeholder as a file**
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
media({ placeholder: 'file' })
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The default placeholder is a `data:` URL, which `img-src 'self'` blocks.
|
|
410
|
+
With `'file'` each image and video poster gets a `name-placeholder-<hash>.webp`
|
|
411
|
+
of a few hundred bytes next to its ladder, and the record points at it.
|
|
412
|
+
Uploads through the media route still inline theirs.
|
|
413
|
+
|
|
403
414
|
**Uploads on a mounted volume**
|
|
404
415
|
|
|
405
416
|
```bash
|
|
@@ -448,6 +459,7 @@ export const Logo = () => (
|
|
|
448
459
|
| `M017` in the server log | No `media()` in `plugins`, or the app doesn't list `sharp` in its own dependencies. |
|
|
449
460
|
| `M018` on upload | Over `server.maxBytes` (20 MB by default), not an image sharp can read, or a video with no encoder installed. |
|
|
450
461
|
| `P101 … needs media.encode` | A video or audio `?media` import with no `mediaFfmpeg()` in `plugins`. |
|
|
462
|
+
| CSP reports a blocked `data:image/webp` | The inline placeholder. Set `placeholder: 'file'`, or add `data:` to `img-src`. |
|
|
451
463
|
| Uploaded images vanish after a deploy | The store isn't on persistent storage. Point `WEFT_MEDIA_DIR` at a volume. |
|
|
452
464
|
|
|
453
465
|
## Related packages
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/plugin-media",
|
|
3
|
-
"version": "0.1.3",
|
|
3
|
+
"version": "0.1.5-nightly.20261111.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
6
|
"repository": {
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"svgo": "4.1.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@weft-js/core": "^0.1.3"
|
|
29
|
+
"@weft-js/core": "^0.1.5-nightly.20261111.3"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
32
|
+
"node": ">=24"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
35
|
"weft"
|
package/src/build.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import sharp from 'sharp'
|
|
|
14
14
|
import { optimize } from 'svgo'
|
|
15
15
|
import { probe } from '@weft-js/core/media-probe'
|
|
16
16
|
import { createMediaServer } from './server.mjs'
|
|
17
|
-
import { DEFAULTS, FIXED, encoders, hex, isPhoto, MIME } from './shared.mjs'
|
|
17
|
+
import { DEFAULTS, FIXED, encoders, hex, isPhoto, MIME, withPlaceholder } from './shared.mjs'
|
|
18
18
|
import { unsafeSvg } from './svg.mjs'
|
|
19
19
|
import { AUDIO, audioFile, gif, VIDEO, video } from './video.mjs'
|
|
20
20
|
|
|
@@ -288,8 +288,7 @@ async function raster({ bytes, name, stem, audio }, o, encode) {
|
|
|
288
288
|
}
|
|
289
289
|
if (o.placeholder) {
|
|
290
290
|
asset.dominant = hex((await base().stats()).dominant)
|
|
291
|
-
|
|
292
|
-
asset.placeholder = `data:image/webp;base64,${tiny.toString('base64')}`
|
|
291
|
+
await withPlaceholder(asset, files, base(), stem, o)
|
|
293
292
|
}
|
|
294
293
|
return { asset, files, diagnostics }
|
|
295
294
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -8,8 +8,11 @@ export interface MediaOptions {
|
|
|
8
8
|
widths?: number[]
|
|
9
9
|
/** `target`: the SSIMULACRA2 score each format is searched to (default 80). `scorer`: the command, or 'none'. */
|
|
10
10
|
quality?: { target?: number; scorer?: string }
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Dominant colour and a 16 px WebP placeholder. `true` or 'inline' (the default) inlines it as a `data:` URL; 'file'
|
|
13
|
+
* writes it as its own file, for a CSP whose `img-src` has no `data:`; `false` leaves both out.
|
|
14
|
+
*/
|
|
15
|
+
placeholder?: boolean | 'inline' | 'file'
|
|
13
16
|
/** Bytes over which a GIF is M004. Default 256 KB. */
|
|
14
17
|
gifLimit?: number
|
|
15
18
|
/** 'server': `?media` imports are encoded on first request by the media route, not at build. 'compatible': the page shows the efficient file, and a compatible copy swaps in so saving gives a file that opens anywhere. Default 'off'. */
|
package/src/index.mjs
CHANGED
|
@@ -119,7 +119,14 @@ function options(input) {
|
|
|
119
119
|
fail('quality.scorer', "the SSIMULACRA2 command, or 'none' for fixed qualities")
|
|
120
120
|
out.quality = { target, scorer }
|
|
121
121
|
}
|
|
122
|
-
if (input.placeholder !== undefined)
|
|
122
|
+
if (input.placeholder !== undefined) {
|
|
123
|
+
if (![true, false, 'inline', 'file'].includes(input.placeholder))
|
|
124
|
+
fail(
|
|
125
|
+
'placeholder',
|
|
126
|
+
"true or 'inline' (a data: URL), 'file' (its own file, for a strict CSP), or false",
|
|
127
|
+
)
|
|
128
|
+
out.placeholder = input.placeholder === 'inline' ? true : input.placeholder
|
|
129
|
+
}
|
|
123
130
|
if (input.gifLimit !== undefined) out.gifLimit = input.gifLimit
|
|
124
131
|
return out
|
|
125
132
|
}
|
package/src/shared.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// What build.mjs and server.mjs both need: the defaults, the encoder calls and the MIME table. It lives apart so the build
|
|
2
2
|
// can call the server for `encoder: 'server'` without the two importing each other.
|
|
3
3
|
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
|
|
4
6
|
/** Every option's default: the width ladder, the quality search, the placeholder, the GIF limit, the video ladder. */
|
|
5
7
|
export const DEFAULTS = {
|
|
6
8
|
formats: ['avif'],
|
|
@@ -37,3 +39,17 @@ export const hex = ({ r, g, b }) =>
|
|
|
37
39
|
export const isPhoto = (meta) =>
|
|
38
40
|
['jpeg', 'jpg', 'avif', 'heif', 'tiff', 'webp'].includes(meta.format) &&
|
|
39
41
|
!(meta.format === 'webp' && meta.hasAlpha)
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Adds the 16 px placeholder from `img` (a sharp pipeline) to `asset`. Inline it's a `data:` URL; with
|
|
45
|
+
* `placeholder: 'file'` it's a file of its own pushed to `files`, for a CSP whose `img-src` has no `data:`.
|
|
46
|
+
*/
|
|
47
|
+
export async function withPlaceholder(asset, files, img, stem, o) {
|
|
48
|
+
const tiny = await img.resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()
|
|
49
|
+
if (o.placeholder !== 'file') {
|
|
50
|
+
asset.placeholder = `data:image/webp;base64,${tiny.toString('base64')}`
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
asset.placeholder = `${stem}-placeholder-${createHash('sha256').update(tiny).digest('hex').slice(0, 8)}.webp`
|
|
54
|
+
files.push({ name: asset.placeholder, bytes: new Uint8Array(tiny) })
|
|
55
|
+
}
|
package/src/upload/video.mjs
CHANGED
|
@@ -28,9 +28,10 @@ export function createVideo({
|
|
|
28
28
|
stem: 'poster',
|
|
29
29
|
width,
|
|
30
30
|
duration: info.duration,
|
|
31
|
-
|
|
31
|
+
// The route serves only the poster and rungs, so an upload's placeholder stays inline.
|
|
32
|
+
o: { ...o, placeholder: !!o.placeholder },
|
|
32
33
|
})
|
|
33
|
-
writeFileSync(join(dir, hash, 'poster.webp'), cover.
|
|
34
|
+
writeFileSync(join(dir, hash, 'poster.webp'), cover.files[0].bytes)
|
|
34
35
|
const top = url(hash, plan.rungs.at(-1)[0], 'mp4')
|
|
35
36
|
const asset = compatible({
|
|
36
37
|
kind: 'video',
|
package/src/video.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { join } from 'node:path'
|
|
|
12
12
|
import { promisify } from 'node:util'
|
|
13
13
|
import sharp from 'sharp'
|
|
14
14
|
import { errored, need, onDisk, short } from './video/common.mjs'
|
|
15
|
+
import { withPlaceholder } from './shared.mjs'
|
|
15
16
|
|
|
16
17
|
export { audioFile } from './video/audio.mjs'
|
|
17
18
|
|
|
@@ -104,12 +105,13 @@ export async function poster({ enc, source, stem, width, duration, o }) {
|
|
|
104
105
|
const bytes = await sharp(png).webp({ quality: 80 }).toBuffer()
|
|
105
106
|
const name = `${stem}-poster-${short(bytes)}.webp`
|
|
106
107
|
const asset = { poster: name }
|
|
108
|
+
const files = [{ name, bytes: new Uint8Array(bytes) }]
|
|
107
109
|
if (o.placeholder) {
|
|
108
110
|
const stats = await sharp(png).stats()
|
|
109
111
|
asset.dominant = `#${['r', 'g', 'b'].map((c) => stats.dominant[c].toString(16).padStart(2, '0')).join('')}`
|
|
110
|
-
asset
|
|
112
|
+
await withPlaceholder(asset, files, sharp(png), stem, o)
|
|
111
113
|
}
|
|
112
|
-
return { asset,
|
|
114
|
+
return { asset, files }
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export const ladderOf = (o, width) => {
|
|
@@ -170,7 +172,7 @@ function chosen(sets) {
|
|
|
170
172
|
|
|
171
173
|
function finish(asset, sets, cover, diagnostics) {
|
|
172
174
|
const files = sets.flatMap((s) => s.rungs.map((r) => ({ name: r.name, bytes: r.bytes })))
|
|
173
|
-
if (cover) files.push(cover.
|
|
175
|
+
if (cover) files.push(...cover.files)
|
|
174
176
|
const compatible = sets.at(-1)
|
|
175
177
|
const top = compatible.rungs.at(-1)
|
|
176
178
|
const out = {
|