@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 CHANGED
@@ -105,7 +105,7 @@ import { media } from '@weft-js/plugin-media'
105
105
 
106
106
  | Need | Why |
107
107
  | --- | --- |
108
- | Node 26 or later | `engines` in package.json |
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` (a 16 px WebP data URL). |
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": ">=26"
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
- const tiny = await base().resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()
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
- /** Dominant colour and a 16 px WebP placeholder. Default true. */
12
- placeholder?: boolean
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) out.placeholder = !!input.placeholder
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
+ }
@@ -28,9 +28,10 @@ export function createVideo({
28
28
  stem: 'poster',
29
29
  width,
30
30
  duration: info.duration,
31
- o,
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.file.bytes)
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.placeholder = `data:image/webp;base64,${(await sharp(png).resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()).toString('base64')}`
112
+ await withPlaceholder(asset, files, sharp(png), stem, o)
111
113
  }
112
- return { asset, file: { name, bytes: new Uint8Array(bytes) } }
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.file)
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 = {