@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/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,52 +24,99 @@ 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
122
  if (input.placeholder !== undefined) out.placeholder = !!input.placeholder
@@ -95,21 +152,55 @@ export const media = definePlugin({
95
152
  weft: '*',
96
153
  namespace: 'M',
97
154
  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 }) },
155
+ permissions: {
156
+ fs: {
157
+ read: [
158
+ '**/*.{png,jpg,jpeg,gif,webp,avif,svg,mp4,m4v,mov,webm,mkv,ogv,mp3,wav,flac,ogg,oga,opus,m4a,aac}',
159
+ ],
160
+ },
161
+ env: ['WEFT_MEDIA_DIR'],
162
+ spawn: ['ssimulacra2'],
163
+ native: true,
164
+ },
165
+ contribute: (o) => ({
166
+ provides: [
167
+ provide(mediaBuild, {
168
+ sides: {
169
+ build: {
170
+ module: fileURLToPath(new URL('./build.mjs', import.meta.url)),
171
+ export: 'createMediaBuild',
172
+ options: o,
173
+ },
174
+ },
175
+ }),
176
+ provide(mediaServer, {
177
+ sides: {
178
+ server: {
179
+ module: '@weft-js/plugin-media/server',
180
+ export: 'createMediaServer',
181
+ options: o,
182
+ },
183
+ },
184
+ }),
185
+ ],
186
+ check: { include: ['**/*.{tsx,jsx}'], run: (ctx) => checkMedia(ctx, { gifLimit: o.gifLimit }) },
102
187
  diagnostics: CODES.map(([code, catches]) => ({ code, catches, fix: `weft docs ${code}` })),
103
188
  agents: {
104
189
  title: 'Media (@weft-js/plugin-media)',
105
190
  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.",
191
+ '- 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
192
  '- 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
193
  '- `alt` is required; use `alt="" decorative` for purely visual media (M007).',
109
194
  '- 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).",
195
+ '- 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
196
  ].join('\n'),
112
197
  },
113
- catalog: [{ name: 'Media', import: '@weft-js/core/media', summary: '<Media src={x} alt>: dimensions, AVIF + fallback, srcset/sizes, placeholder' }],
198
+ catalog: [
199
+ {
200
+ name: 'Media',
201
+ import: '@weft-js/core/media',
202
+ summary: '<Media src={x} alt>: dimensions, AVIF + fallback, srcset/sizes, placeholder',
203
+ },
204
+ ],
114
205
  }),
115
206
  })
@@ -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
@@ -10,20 +10,30 @@ export const DEFAULTS = {
10
10
  gifLimit: 256 * 1024,
11
11
  video: { codecs: ['av1'], widths: [640, 960, 1280, 1920] },
12
12
  }
13
- /** Encoder settings without a scorer (research 26 §2.4). */
13
+ /** Encoder settings without a scorer. */
14
14
  export const FIXED = { avif: 50, webp: 75, jpeg: 78 }
15
15
  /** 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' }
16
+ export const MIME = {
17
+ avif: 'image/avif',
18
+ webp: 'image/webp',
19
+ jpeg: 'image/jpeg',
20
+ png: 'image/png',
21
+ gif: 'image/gif',
22
+ svg: 'image/svg+xml',
23
+ }
17
24
 
18
25
  /** One sharp call per format: quality in, a configured pipeline out. PNG takes no quality. */
19
26
  export const encoders = {
20
27
  avif: (img, q) => img.avif({ quality: q, effort: 4, chromaSubsampling: '4:2:0' }),
21
28
  webp: (img, q) => img.webp({ quality: q, effort: 5 }),
22
29
  jpeg: (img, q) => img.jpeg({ quality: q, mozjpeg: true }),
23
- png: img => img.png({ compressionLevel: 9, effort: 8 }),
30
+ png: (img) => img.png({ compressionLevel: 9, effort: 8 }),
24
31
  }
25
32
 
26
33
  /** 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('')}`
34
+ export const hex = ({ r, g, b }) =>
35
+ `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`
28
36
  /** 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)
37
+ export const isPhoto = (meta) =>
38
+ ['jpeg', 'jpg', 'avif', 'heif', 'tiff', 'webp'].includes(meta.format) &&
39
+ !(meta.format === 'webp' && meta.hasAlpha)
@@ -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
+ }
@@ -0,0 +1,45 @@
1
+ import { writeFileSync } from 'node:fs'
2
+
3
+ export function createIngest({
4
+ encode,
5
+ sourceOf,
6
+ refuse,
7
+ record,
8
+ url,
9
+ enqueue,
10
+ store,
11
+ video,
12
+ COMPATIBLE,
13
+ }) {
14
+ return async function ingest(hash, bytes, name, kind) {
15
+ const enc = encode[COMPATIBLE]
16
+ writeFileSync(sourceOf(hash), bytes)
17
+ const info = await enc.info(sourceOf(hash)).catch(() => null)
18
+ if (kind === 'audio') {
19
+ if (!info?.audio) refuse('M018', `${name} has no audio track weft can read.`)
20
+ const rungs = [...(encode['audio/opus'] ? [[0, 'webm']] : []), [0, 'm4a']]
21
+ const asset = {
22
+ kind: 'audio',
23
+ src: url(hash, 0, 'm4a'),
24
+ width: 0,
25
+ height: 0,
26
+ type: 'audio/mp4',
27
+ sources: record(hash, rungs, {
28
+ webm: 'audio/webm; codecs="opus"',
29
+ m4a: 'audio/mp4; codecs="mp4a.40.2"',
30
+ }),
31
+ name,
32
+ quality: 'fixed',
33
+ duration: info.duration,
34
+ audio: true,
35
+ }
36
+ const meta = { kind, timed: true, rungs }
37
+ enqueue(hash, meta)
38
+ return store(hash, bytes, asset, meta)
39
+ }
40
+ if (!info?.video) refuse('M018', `${name} has no video track weft can read.`)
41
+ const { asset, meta } = await video(hash, bytes, name, info)
42
+ enqueue(hash, meta)
43
+ return store(hash, bytes, asset, meta)
44
+ }
45
+ }