@weft-js/plugin-media 0.1.1 → 0.1.4

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/src/index.mjs CHANGED
@@ -1,12 +1,22 @@
1
- // media() for weft.config.ts (research 15 §7.4, research 26 §2, research 29 §6): provides media.build@1 (src/build.mjs,
1
+ // media() for weft.config.ts: provides media.build@1 (src/build.mjs,
2
2
  // run by @weft-js/core/vite for `?media` imports) and media.server@1 (src/server.mjs, the route for uploads and `encoder:
3
3
  // 'server'`), and reports M001–M013 and M016 through its `check` hook (src/check.mjs).
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
6
6
  import { checkMedia } from './check.mjs'
7
7
 
8
- const mediaBuild = defineSlot({ id: 'media.build', version: 1, cardinality: 'one', sides: ['build'] })
9
- const mediaServer = defineSlot({ id: 'media.server', version: 1, cardinality: 'one', sides: ['server'] })
8
+ const mediaBuild = defineSlot({
9
+ id: 'media.build',
10
+ version: 1,
11
+ cardinality: 'one',
12
+ sides: ['build'],
13
+ })
14
+ const mediaServer = defineSlot({
15
+ id: 'media.server',
16
+ version: 1,
17
+ cardinality: 'one',
18
+ sides: ['server'],
19
+ })
10
20
  const FORMATS = ['avif', 'webp']
11
21
  const CODECS = ['av1', 'vp9', 'hevc']
12
22
 
@@ -14,55 +24,109 @@ const fail = (path, message) => {
14
24
  throw Object.assign(new Error(message), { path })
15
25
  }
16
26
 
17
- function options(input) {
18
- for (const key of Object.keys(input)) if (!['formats', 'widths', 'quality', 'placeholder', 'gifLimit', 'encoder', 'swap', 'video', 'gif', 'server'].includes(key)) fail(key, 'unknown option')
19
- const out = {}
20
- if (input.encoder !== undefined) {
21
- if (!['off', 'compatible', 'server'].includes(input.encoder)) fail('encoder', `\`${input.encoder}\`: 'off', 'compatible' or 'server'`)
22
- out.encoder = input.encoder
23
- }
24
- if (input.swap !== undefined) {
25
- if (!['intent', 'idle', 'never'].includes(input.swap)) fail('swap', `\`${input.swap}\`: 'intent', 'idle' or 'never'`)
26
- if (input.encoder !== 'compatible') fail('swap', "only means something with `encoder: 'compatible'`")
27
- out.swap = input.swap
28
- }
27
+ function playbackOptions(input, out) {
29
28
  if (input.video !== undefined) {
30
29
  const { codecs, widths } = input.video
31
30
  if (codecs !== undefined) {
32
31
  if (!Array.isArray(codecs)) fail('video.codecs', 'expected an array')
33
- for (const [i, c] of codecs.entries()) if (!CODECS.includes(c)) fail(`video.codecs.${i}`, `\`${c}\`: ${CODECS.join(', ')} (H.264 is always there)`)
32
+ for (const [i, c] of codecs.entries())
33
+ if (!CODECS.includes(c))
34
+ fail(`video.codecs.${i}`, `\`${c}\`: ${CODECS.join(', ')} (H.264 is always there)`)
34
35
  }
35
- if (widths !== undefined && !(Array.isArray(widths) && widths.length && widths.every(w => Number.isInteger(w) && w > 0))) fail('video.widths', 'expected positive integers')
36
+ if (
37
+ widths !== undefined &&
38
+ !(Array.isArray(widths) && widths.length && widths.every((w) => Number.isInteger(w) && w > 0))
39
+ )
40
+ fail('video.widths', 'expected positive integers')
36
41
  out.video = { ...input.video, ...(widths && { widths: [...widths].sort((a, b) => a - b) }) }
37
42
  }
38
43
  if (input.gif !== undefined) {
39
44
  const { toVideo, keepUnder } = input.gif
40
- if (toVideo !== undefined && !['auto', 'never'].includes(toVideo)) fail('gif.toVideo', "'auto' or 'never'")
41
- if (keepUnder !== undefined && !(Number.isInteger(keepUnder) && keepUnder >= 0)) fail('gif.keepUnder', 'a number of bytes')
45
+ if (toVideo !== undefined && !['auto', 'never'].includes(toVideo))
46
+ fail('gif.toVideo', "'auto' or 'never'")
47
+ if (keepUnder !== undefined && !(Number.isInteger(keepUnder) && keepUnder >= 0))
48
+ fail('gif.keepUnder', 'a number of bytes')
42
49
  out.gif = input.gif
43
50
  }
44
51
  if (input.server !== undefined) {
45
52
  const { dir, maxBytes } = input.server
46
- if (dir !== undefined && typeof dir !== 'string') fail('server.dir', 'a directory, relative to the app')
47
- if (maxBytes !== undefined && !(Number.isInteger(maxBytes) && maxBytes > 0)) fail('server.maxBytes', 'a positive number of bytes')
53
+ if (dir !== undefined && typeof dir !== 'string')
54
+ fail('server.dir', 'a directory, relative to the app')
55
+ if (maxBytes !== undefined && !(Number.isInteger(maxBytes) && maxBytes > 0))
56
+ fail('server.maxBytes', 'a positive number of bytes')
48
57
  out.server = input.server
49
58
  }
59
+ }
60
+
61
+ function options(input) {
62
+ for (const key of Object.keys(input))
63
+ if (
64
+ ![
65
+ 'formats',
66
+ 'widths',
67
+ 'quality',
68
+ 'placeholder',
69
+ 'gifLimit',
70
+ 'encoder',
71
+ 'swap',
72
+ 'video',
73
+ 'gif',
74
+ 'server',
75
+ ].includes(key)
76
+ )
77
+ fail(key, 'unknown option')
78
+ const out = {}
79
+ if (input.encoder !== undefined) {
80
+ if (!['off', 'compatible', 'server'].includes(input.encoder))
81
+ fail('encoder', `\`${input.encoder}\`: 'off', 'compatible' or 'server'`)
82
+ out.encoder = input.encoder
83
+ }
84
+ if (input.swap !== undefined) {
85
+ if (!['intent', 'idle', 'never'].includes(input.swap))
86
+ fail('swap', `\`${input.swap}\`: 'intent', 'idle' or 'never'`)
87
+ if (input.encoder !== 'compatible')
88
+ fail('swap', "only means something with `encoder: 'compatible'`")
89
+ out.swap = input.swap
90
+ }
91
+ playbackOptions(input, out)
50
92
  if (input.formats !== undefined) {
51
93
  if (!Array.isArray(input.formats)) fail('formats', 'expected an array')
52
- for (const [i, f] of input.formats.entries()) if (!FORMATS.includes(f)) fail(`formats.${i}`, `\`${f}\`: the efficient formats are ${FORMATS.join(', ')} (the fallback follows the source)`)
94
+ for (const [i, f] of input.formats.entries())
95
+ if (!FORMATS.includes(f))
96
+ fail(
97
+ `formats.${i}`,
98
+ `\`${f}\`: the efficient formats are ${FORMATS.join(', ')} (the fallback follows the source)`,
99
+ )
53
100
  out.formats = input.formats
54
101
  }
55
102
  if (input.widths !== undefined) {
56
- if (!Array.isArray(input.widths) || !input.widths.length || !input.widths.every(w => Number.isInteger(w) && w > 0)) fail('widths', 'expected positive integers')
103
+ if (
104
+ !Array.isArray(input.widths) ||
105
+ !input.widths.length ||
106
+ !input.widths.every((w) => Number.isInteger(w) && w > 0)
107
+ )
108
+ fail('widths', 'expected positive integers')
57
109
  out.widths = [...input.widths].sort((a, b) => a - b)
58
110
  }
59
111
  if (input.quality !== undefined) {
60
112
  const { target = 80, scorer = 'ssimulacra2' } = input.quality
61
- if (!(target > 0 && target <= 100)) fail('quality.target', 'an SSIMULACRA2 score, 1–100 (80 ≈ visually lossless at normal viewing)')
62
- if (typeof scorer !== 'string') fail('quality.scorer', "the SSIMULACRA2 command, or 'none' for fixed qualities")
113
+ if (!(target > 0 && target <= 100))
114
+ fail(
115
+ 'quality.target',
116
+ 'an SSIMULACRA2 score, 1–100 (80 ≈ visually lossless at normal viewing)',
117
+ )
118
+ if (typeof scorer !== 'string')
119
+ fail('quality.scorer', "the SSIMULACRA2 command, or 'none' for fixed qualities")
63
120
  out.quality = { target, scorer }
64
121
  }
65
- 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
+ }
66
130
  if (input.gifLimit !== undefined) out.gifLimit = input.gifLimit
67
131
  return out
68
132
  }
@@ -95,21 +159,55 @@ export const media = definePlugin({
95
159
  weft: '*',
96
160
  namespace: 'M',
97
161
  options,
98
- permissions: { fs: { read: ['**/*.{png,jpg,jpeg,gif,webp,avif,svg,mp4,m4v,mov,webm,mkv,ogv,mp3,wav,flac,ogg,oga,opus,m4a,aac}'] }, env: ['WEFT_MEDIA_DIR'], spawn: ['ssimulacra2'], native: true },
99
- contribute: o => ({
100
- provides: [provide(mediaBuild, { sides: { build: { module: fileURLToPath(new URL('./build.mjs', import.meta.url)), export: 'createMediaBuild', options: o } } }), provide(mediaServer, { sides: { server: { module: '@weft-js/plugin-media/server', export: 'createMediaServer', options: o } } })],
101
- check: { include: ['**/*.{tsx,jsx}'], run: ctx => checkMedia(ctx, { gifLimit: o.gifLimit }) },
162
+ permissions: {
163
+ fs: {
164
+ read: [
165
+ '**/*.{png,jpg,jpeg,gif,webp,avif,svg,mp4,m4v,mov,webm,mkv,ogv,mp3,wav,flac,ogg,oga,opus,m4a,aac}',
166
+ ],
167
+ },
168
+ env: ['WEFT_MEDIA_DIR'],
169
+ spawn: ['ssimulacra2'],
170
+ native: true,
171
+ },
172
+ contribute: (o) => ({
173
+ provides: [
174
+ provide(mediaBuild, {
175
+ sides: {
176
+ build: {
177
+ module: fileURLToPath(new URL('./build.mjs', import.meta.url)),
178
+ export: 'createMediaBuild',
179
+ options: o,
180
+ },
181
+ },
182
+ }),
183
+ provide(mediaServer, {
184
+ sides: {
185
+ server: {
186
+ module: '@weft-js/plugin-media/server',
187
+ export: 'createMediaServer',
188
+ options: o,
189
+ },
190
+ },
191
+ }),
192
+ ],
193
+ check: { include: ['**/*.{tsx,jsx}'], run: (ctx) => checkMedia(ctx, { gifLimit: o.gifLimit }) },
102
194
  diagnostics: CODES.map(([code, catches]) => ({ code, catches, fix: `weft docs ${code}` })),
103
195
  agents: {
104
196
  title: 'Media (@weft-js/plugin-media)',
105
197
  text: [
106
- "- Import images with `?media` and render `<Media src={x} alt=\"…\" />` from `@weft-js/core/media`. Never hand-write srcset, sizes, width/height or blur placeholders.",
198
+ '- Import images with `?media` and render `<Media src={x} alt="…" />` from `@weft-js/core/media`. Never hand-write srcset, sizes, width/height or blur placeholders.',
107
199
  '- Mark the one hero image `priority` and give it `width` (`"1/2"`, `"480px"`) so its `sizes` is right (M010). Nothing else gets `priority` (M009).',
108
200
  '- `alt` is required; use `alt="" decorative` for purely visual media (M007).',
109
201
  '- Never reference files in public/ by string (M013) or render `?url` imports in `<img>` (M002).',
110
- "- Uploads: `await ctx.media.ingest(file)` in a mutation returns a record. Store it, type the field `z.media()` (from `@weft-js/start/media`), and pass it to `<Media src>` (M016).",
202
+ '- Uploads: `await ctx.media.ingest(file)` in a mutation returns a record. Store it, type the field `z.media()` (from `@weft-js/start/media`), and pass it to `<Media src>` (M016).',
111
203
  ].join('\n'),
112
204
  },
113
- catalog: [{ name: 'Media', import: '@weft-js/core/media', summary: '<Media src={x} alt>: dimensions, AVIF + fallback, srcset/sizes, placeholder' }],
205
+ catalog: [
206
+ {
207
+ name: 'Media',
208
+ import: '@weft-js/core/media',
209
+ summary: '<Media src={x} alt>: dimensions, AVIF + fallback, srcset/sizes, placeholder',
210
+ },
211
+ ],
114
212
  }),
115
213
  })
@@ -0,0 +1,118 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import sharp from 'sharp'
5
+ import { optimize } from 'svgo'
6
+ import { probe } from '@weft-js/core/media-probe'
7
+ import { hex, isPhoto, MIME } from '../shared.mjs'
8
+ import { unsafeSvg } from '../svg.mjs'
9
+ import { sniff } from '../upload.mjs'
10
+
11
+ export function imageIngest({ limit, metaOf, dir, encode, timed, url, store, refuse, o }) {
12
+ return async function ingest(bytes, { name = 'upload' } = {}) {
13
+ if (bytes.length > limit)
14
+ refuse(
15
+ 'M018',
16
+ `${name} is ${Math.round(bytes.length / 1048576)} MB, over the ${Math.round(limit / 1048576)} MB limit.`,
17
+ )
18
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 32)
19
+ const known = metaOf(hash)
20
+ if (known) return { ...known.asset, name }
21
+ const kind = sniff(bytes)
22
+ if (kind) {
23
+ if (!encode['video/h264'])
24
+ refuse(
25
+ 'M018',
26
+ `${name} is ${kind}, and nothing here encodes it: add \`mediaFfmpeg()\` from @weft-js/plugin-media-ffmpeg to \`plugins\`, with ffmpeg on the server.`,
27
+ )
28
+ mkdirSync(join(dir, hash), { recursive: true })
29
+ return timed.ingest(hash, bytes, name, kind)
30
+ }
31
+ const size = probe(bytes, name)
32
+ if (size?.type === 'image/svg+xml')
33
+ return storeSvg(hash, bytes, name, size, { refuse, url, store })
34
+ let meta
35
+ try {
36
+ meta = await sharp(bytes, { animated: true }).metadata()
37
+ } catch {
38
+ refuse('M018', `${name} isn't an image weft can read (PNG, JPEG, GIF, WebP, AVIF, SVG).`)
39
+ }
40
+ const turned = (meta.orientation ?? 1) >= 5
41
+ const tall = meta.pageHeight ?? meta.height
42
+ const width = turned ? tall : meta.width
43
+ const height = turned ? meta.width : tall
44
+ if (meta.format === 'gif' && (meta.pages ?? 1) > 1) {
45
+ // Animated: video when that's a saving, else as it is.
46
+ mkdirSync(join(dir, hash), { recursive: true })
47
+ const video = await timed.gif(hash, bytes, name, meta)
48
+ if (video) return video
49
+ return store(
50
+ hash,
51
+ bytes,
52
+ {
53
+ kind: 'graphic',
54
+ src: url(hash, width),
55
+ width,
56
+ height,
57
+ type: MIME.gif,
58
+ sources: [],
59
+ name,
60
+ quality: 'original',
61
+ },
62
+ { ladder: [width], type: MIME.gif },
63
+ )
64
+ }
65
+ return storeRaster({ meta, width, height, o, url, hash, name, bytes, store })
66
+ }
67
+ }
68
+
69
+ function storeSvg(hash, bytes, name, size, { refuse, url, store }) {
70
+ const text = new TextDecoder().decode(bytes)
71
+ const unsafe = unsafeSvg(text)
72
+ if (unsafe.length)
73
+ refuse(
74
+ 'M012',
75
+ `${name} has ${unsafe.join(', ')}: an image with active content or external references is not stored.`,
76
+ )
77
+ const svg = new TextEncoder().encode(optimize(text, { multipass: true }).data)
78
+ return store(
79
+ hash,
80
+ svg,
81
+ {
82
+ kind: 'vector',
83
+ src: url(hash, size.width),
84
+ width: size.width,
85
+ height: size.height,
86
+ type: MIME.svg,
87
+ sources: [],
88
+ name,
89
+ quality: 'original',
90
+ },
91
+ { ladder: [size.width], type: MIME.svg },
92
+ )
93
+ }
94
+
95
+ async function storeRaster({ meta, width, height, o, url, hash, name, bytes, store }) {
96
+ const photo = isPhoto(meta)
97
+ const fallback = photo ? 'jpeg' : 'png'
98
+ const largest = Math.min(width, o.widths.at(-1))
99
+ const ladder = [...new Set([...o.widths.filter((w) => w < largest), largest])]
100
+ const asset = {
101
+ kind: photo ? 'photo' : 'graphic',
102
+ src: url(hash, largest),
103
+ width,
104
+ height,
105
+ type: MIME[fallback],
106
+ sources: [
107
+ { type: MIME[fallback], srcset: ladder.map((w) => `${url(hash, w)} ${w}w`).join(', ') },
108
+ ],
109
+ name,
110
+ quality: 'fixed',
111
+ }
112
+ if (o.placeholder) {
113
+ const base = () => sharp(bytes).rotate()
114
+ asset.dominant = hex((await base().stats()).dominant)
115
+ asset.placeholder = `data:image/webp;base64,${(await base().resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()).toString('base64')}`
116
+ }
117
+ return store(hash, bytes, asset, { ladder, fallback })
118
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import sharp from 'sharp'
4
+ import { FIXED, encoders, MIME } from '../shared.mjs'
5
+
6
+ const FORMATS_TIMED = new Set(['mp4', 'webm', 'm4a', 'gif', 'webp'])
7
+ const EXT = { avif: 'avif', webp: 'webp', jpeg: 'jpg', png: 'png' }
8
+ const FORMAT = { avif: 'avif', webp: 'webp', jpg: 'jpeg', png: 'png' }
9
+
10
+ export function imageServe({ dir, flight, metaOf, timed, o, negotiate }) {
11
+ /** One variant, encoded once: concurrent requests share the encode, and the file lands whole or not at all. */
12
+ function variant(hash, width, format) {
13
+ const file = join(dir, hash, `${width}.${EXT[format]}`)
14
+ if (existsSync(file)) return readFileSync(file)
15
+ const key = `${hash}/${width}.${format}`
16
+ if (!flight.has(key)) {
17
+ const work = (async () => {
18
+ const img = sharp(readFileSync(join(dir, hash, 'source')))
19
+ .rotate()
20
+ .resize({ width })
21
+ const bytes = await encoders[format](img, FIXED[format]).toBuffer()
22
+ const tmp = `${file}.${process.pid}.tmp`
23
+ writeFileSync(tmp, bytes)
24
+ renameSync(tmp, file)
25
+ return bytes
26
+ })().finally(() => flight.delete(key))
27
+ flight.set(key, work)
28
+ }
29
+ return flight.get(key)
30
+ }
31
+
32
+ async function serve({ hash, width, format, accept }) {
33
+ const meta = /^[0-9a-f]{32}$/.test(hash) ? metaOf(hash) : null
34
+ if (meta?.timed)
35
+ return FORMATS_TIMED.has(format) ? timed.serve(hash, meta, width, format) : null
36
+ if (!meta?.ladder.includes(width)) return null
37
+ const negotiated = format === 'auto'
38
+ if (meta.type)
39
+ return negotiated
40
+ ? {
41
+ bytes: readFileSync(join(dir, hash, 'source')),
42
+ type: meta.type,
43
+ width,
44
+ format: 'source',
45
+ negotiated: false,
46
+ }
47
+ : null
48
+ const fmt = negotiated ? negotiate(accept, o.formats, meta.fallback) : FORMAT[format]
49
+ if (!fmt || !(fmt === meta.fallback || o.formats.includes(fmt))) return null
50
+ return {
51
+ bytes: new Uint8Array(await variant(hash, width, fmt)),
52
+ type: MIME[fmt],
53
+ width,
54
+ format: fmt,
55
+ negotiated,
56
+ }
57
+ }
58
+
59
+ return serve
60
+ }
package/src/server.mjs CHANGED
@@ -1,23 +1,16 @@
1
- // media.server@1 (research 15 §5.4, research 29 §6): uploads, CMS images and `encoder: 'server'`. `ingest` stores an
2
- // original under its hash and returns the record `z.media()` validates; `serve` answers `/_weft/m/<hash>/<w>.<fmt>`,
1
+ // `ingest` stores an original under its hash and returns a `z.media()` record; `serve` answers `/_weft/m/<hash>/<w>.<fmt>`,
3
2
  // encoding each variant once and keeping it next to the original. `auto` negotiates on `Accept`. Only widths on the
4
3
  // ladder and formats in the config are served, so a URL can't ask for unbounded work. Video, audio and animated GIFs go
5
4
  // through src/upload.mjs when media.encode@1 is bound.
6
- import { createHash } from 'node:crypto'
7
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
8
6
  import { join, resolve } from 'node:path'
9
- import sharp from 'sharp'
10
- import { optimize } from 'svgo'
11
- import { probe } from '@weft-js/core/media-probe'
12
- import { DEFAULTS, FIXED, encoders, hex, isPhoto, MIME } from './shared.mjs'
13
- import { unsafeSvg } from './svg.mjs'
14
- import { sniff, uploads } from './upload.mjs'
7
+ import { DEFAULTS, MIME } from './shared.mjs'
8
+ import { uploads } from './upload.mjs'
9
+ import { imageIngest } from './server/ingest.mjs'
10
+ import { imageServe } from './server/serve.mjs'
15
11
 
16
12
  const ROUTE = '/_weft/m'
17
- const FORMATS_TIMED = new Set(['mp4', 'webm', 'm4a', 'gif', 'webp'])
18
13
  const MAX_BYTES = 20 * 1024 * 1024
19
- const EXT = { avif: 'avif', webp: 'webp', jpeg: 'jpg', png: 'png' }
20
- const FORMAT = { avif: 'avif', webp: 'webp', jpg: 'jpeg', png: 'png' }
21
14
 
22
15
  const refuse = (code, message) => {
23
16
  throw Object.assign(new Error(message), { code })
@@ -28,10 +21,10 @@ export function negotiate(accept, formats, fallback) {
28
21
  const q = new Map()
29
22
  for (const part of accept.split(',')) {
30
23
  const [type, ...params] = part.trim().toLowerCase().split(';')
31
- const v = params.map(p => /^\s*q=([\d.]+)/.exec(p)?.[1]).find(Boolean)
24
+ const v = params.map((p) => /^\s*q=([\d.]+)/.exec(p)?.[1]).find(Boolean)
32
25
  q.set(type.trim(), v === undefined ? 1 : Number(v))
33
26
  }
34
- return formats.find(f => q.get(MIME[f]) > 0) ?? fallback
27
+ return formats.find((f) => q.get(MIME[f]) > 0) ?? fallback
35
28
  }
36
29
 
37
30
  /** The media.server@1 provider: `at(root, encode)` is the store for one app, its `ingest` and its `serve`. */
@@ -41,11 +34,13 @@ export function createMediaServer(input = {}) {
41
34
  }
42
35
 
43
36
  function storage(o, root, encode) {
44
- const dir = process.env.WEFT_MEDIA_DIR ? resolve(process.env.WEFT_MEDIA_DIR) : resolve(root ?? process.cwd(), o.server?.dir ?? '.weft/media')
37
+ const dir = process.env.WEFT_MEDIA_DIR
38
+ ? resolve(process.env.WEFT_MEDIA_DIR)
39
+ : resolve(root ?? process.cwd(), o.server?.dir ?? '.weft/media')
45
40
  const limit = o.server?.maxBytes ?? MAX_BYTES
46
41
  const flight = new Map()
47
42
  const url = (hash, width, format = 'auto') => `${ROUTE}/${hash}/${width}.${format}`
48
- const metaOf = hash => {
43
+ const metaOf = (hash) => {
49
44
  const file = join(dir, hash, 'meta.json')
50
45
  return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
51
46
  }
@@ -62,82 +57,9 @@ function storage(o, root, encode) {
62
57
 
63
58
  const timed = uploads({ o, dir, encode, url, store, refuse })
64
59
 
65
- async function ingest(bytes, { name = 'upload' } = {}) {
66
- if (bytes.length > limit) refuse('M018', `${name} is ${Math.round(bytes.length / 1048576)} MB, over the ${Math.round(limit / 1048576)} MB limit.`)
67
- const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 32)
68
- const known = metaOf(hash)
69
- if (known) return { ...known.asset, name }
70
- const kind = sniff(bytes)
71
- if (kind) {
72
- if (!encode['video/h264']) refuse('M018', `${name} is ${kind}, and nothing here encodes it: add \`mediaFfmpeg()\` from @weft-js/plugin-media-ffmpeg to \`plugins\`, with ffmpeg on the server.`)
73
- mkdirSync(join(dir, hash), { recursive: true })
74
- return timed.ingest(hash, bytes, name, kind)
75
- }
76
- const size = probe(bytes, name)
77
- if (size?.type === 'image/svg+xml') {
78
- const text = new TextDecoder().decode(bytes)
79
- const unsafe = unsafeSvg(text)
80
- if (unsafe.length) refuse('M012', `${name} has ${unsafe.join(', ')}: an image with active content or external references is not stored.`)
81
- const svg = new TextEncoder().encode(optimize(text, { multipass: true }).data)
82
- return store(hash, svg, { kind: 'vector', src: url(hash, size.width), width: size.width, height: size.height, type: MIME.svg, sources: [], name, quality: 'original' }, { ladder: [size.width], type: MIME.svg })
83
- }
84
- let meta
85
- try { meta = await sharp(bytes, { animated: true }).metadata() } catch { refuse('M018', `${name} isn't an image weft can read (PNG, JPEG, GIF, WebP, AVIF, SVG).`) }
86
- const turned = (meta.orientation ?? 1) >= 5
87
- const tall = meta.pageHeight ?? meta.height
88
- const width = turned ? tall : meta.width
89
- const height = turned ? meta.width : tall
90
- if (meta.format === 'gif' && (meta.pages ?? 1) > 1) { // Animated: video when that's a saving, else as it is.
91
- mkdirSync(join(dir, hash), { recursive: true })
92
- const video = await timed.gif(hash, bytes, name, meta)
93
- if (video) return video
94
- return store(hash, bytes, { kind: 'graphic', src: url(hash, width), width, height, type: MIME.gif, sources: [], name, quality: 'original' }, { ladder: [width], type: MIME.gif })
95
- }
96
- const photo = isPhoto(meta)
97
- const fallback = photo ? 'jpeg' : 'png'
98
- const largest = Math.min(width, o.widths.at(-1))
99
- const ladder = [...new Set([...o.widths.filter(w => w < largest), largest])]
100
- const asset = {
101
- kind: photo ? 'photo' : 'graphic', src: url(hash, largest), width, height, type: MIME[fallback],
102
- sources: [{ type: MIME[fallback], srcset: ladder.map(w => `${url(hash, w)} ${w}w`).join(', ') }], name, quality: 'fixed',
103
- }
104
- if (o.placeholder) {
105
- const base = () => sharp(bytes).rotate()
106
- asset.dominant = hex((await base().stats()).dominant)
107
- asset.placeholder = `data:image/webp;base64,${(await base().resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()).toString('base64')}`
108
- }
109
- return store(hash, bytes, asset, { ladder, fallback })
110
- }
111
-
112
- /** One variant, encoded once: concurrent requests share the encode, and the file lands whole or not at all. */
113
- function variant(hash, width, format) {
114
- const file = join(dir, hash, `${width}.${EXT[format]}`)
115
- if (existsSync(file)) return readFileSync(file)
116
- const key = `${hash}/${width}.${format}`
117
- if (!flight.has(key)) {
118
- const work = (async () => {
119
- const img = sharp(readFileSync(join(dir, hash, 'source'))).rotate().resize({ width })
120
- const bytes = await encoders[format](img, FIXED[format]).toBuffer()
121
- const tmp = `${file}.${process.pid}.tmp`
122
- writeFileSync(tmp, bytes)
123
- renameSync(tmp, file)
124
- return bytes
125
- })().finally(() => flight.delete(key))
126
- flight.set(key, work)
127
- }
128
- return flight.get(key)
129
- }
60
+ const ingest = imageIngest({ limit, metaOf, dir, encode, timed, url, store, refuse, o })
130
61
 
131
- async function serve({ hash, width, format, accept }) {
132
- const meta = /^[0-9a-f]{32}$/.test(hash) ? metaOf(hash) : null
133
- if (meta?.timed) return FORMATS_TIMED.has(format) ? timed.serve(hash, meta, width, format) : null
134
- if (!meta?.ladder.includes(width)) return null
135
- const negotiated = format === 'auto'
136
- if (meta.type) return negotiated ? { bytes: readFileSync(join(dir, hash, 'source')), type: meta.type, width, format: 'source', negotiated: false } : null
137
- const fmt = negotiated ? negotiate(accept, o.formats, meta.fallback) : FORMAT[format]
138
- if (!fmt || !(fmt === meta.fallback || o.formats.includes(fmt))) return null
139
- return { bytes: new Uint8Array(await variant(hash, width, fmt)), type: MIME[fmt], width, format: fmt, negotiated }
140
- }
62
+ const serve = imageServe({ dir, flight, metaOf, timed, o, negotiate })
141
63
 
142
64
  return { ingest, serve, settled: timed.settled }
143
65
  }
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'],
@@ -10,20 +12,44 @@ export const DEFAULTS = {
10
12
  gifLimit: 256 * 1024,
11
13
  video: { codecs: ['av1'], widths: [640, 960, 1280, 1920] },
12
14
  }
13
- /** Encoder settings without a scorer (research 26 §2.4). */
15
+ /** Encoder settings without a scorer. */
14
16
  export const FIXED = { avif: 50, webp: 75, jpeg: 78 }
15
17
  /** Content types of the formats this package writes. */
16
- export const MIME = { avif: 'image/avif', webp: 'image/webp', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', svg: 'image/svg+xml' }
18
+ export const MIME = {
19
+ avif: 'image/avif',
20
+ webp: 'image/webp',
21
+ jpeg: 'image/jpeg',
22
+ png: 'image/png',
23
+ gif: 'image/gif',
24
+ svg: 'image/svg+xml',
25
+ }
17
26
 
18
27
  /** One sharp call per format: quality in, a configured pipeline out. PNG takes no quality. */
19
28
  export const encoders = {
20
29
  avif: (img, q) => img.avif({ quality: q, effort: 4, chromaSubsampling: '4:2:0' }),
21
30
  webp: (img, q) => img.webp({ quality: q, effort: 5 }),
22
31
  jpeg: (img, q) => img.jpeg({ quality: q, mozjpeg: true }),
23
- png: img => img.png({ compressionLevel: 9, effort: 8 }),
32
+ png: (img) => img.png({ compressionLevel: 9, effort: 8 }),
24
33
  }
25
34
 
26
35
  /** A sharp `dominant` colour as `#rrggbb`. */
27
- export const hex = ({ r, g, b }) => `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`
36
+ export const hex = ({ r, g, b }) =>
37
+ `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`
28
38
  /** Lossy sources get a lossy fallback; a WebP with alpha is a graphic. */
29
- export const isPhoto = meta => ['jpeg', 'jpg', 'avif', 'heif', 'tiff', 'webp'].includes(meta.format) && !(meta.format === 'webp' && meta.hasAlpha)
39
+ export const isPhoto = (meta) =>
40
+ ['jpeg', 'jpg', 'avif', 'heif', 'tiff', 'webp'].includes(meta.format) &&
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
+ }
@@ -0,0 +1,46 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import sharp from 'sharp'
3
+
4
+ export function createGif({
5
+ encode,
6
+ o,
7
+ sourceOf,
8
+ video,
9
+ rung,
10
+ hasCopy,
11
+ url,
12
+ enqueue,
13
+ store,
14
+ COMPATIBLE,
15
+ SMALLER,
16
+ }) {
17
+ /** An animated GIF: null when it should stay one (the rules in src/video.mjs, on the top H.264 rung). */
18
+ return async function gif(hash, bytes, name, meta) {
19
+ if (
20
+ !encode[COMPATIBLE] ||
21
+ bytes.length < (o.gif?.keepUnder ?? 8192) ||
22
+ (meta.pages ?? 1) < 4 ||
23
+ o.gif?.toVideo === 'never'
24
+ )
25
+ return null
26
+ if (!(await sharp(bytes, { animated: true }).stats()).isOpaque) return null
27
+ writeFileSync(sourceOf(hash), bytes)
28
+ const info = await encode[COMPATIBLE].info(sourceOf(hash))
29
+ const width = meta.width
30
+ const height = meta.pageHeight ?? meta.height
31
+ const delays = meta.delay ?? []
32
+ const duration = delays.length
33
+ ? delays.reduce((a, b) => a + (b < 20 ? 100 : b), 0) / 1000
34
+ : info.duration
35
+ const full = { ...info, duration, video: { ...info.video, width, height } }
36
+ const { asset, meta: plan } = await video(hash, bytes, name, full)
37
+ const top = plan.rungs.at(-1)[0]
38
+ if ((await rung(hash, plan, top, 'mp4')).length > bytes.length * SMALLER) return null
39
+ Object.assign(asset, { animated: true, audio: false, loop: meta.loop ?? 0 })
40
+ if (hasCopy())
41
+ asset.copy = { src: url(hash, width, 'gif'), type: 'image/gif', bytes: bytes.length }
42
+ plan.gif = width
43
+ enqueue(hash, plan)
44
+ return store(hash, bytes, asset, plan)
45
+ }
46
+ }