@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/README.md +482 -3
- package/package.json +13 -6
- package/src/build.mjs +145 -33
- package/src/check.mjs +216 -62
- package/src/index.d.ts +5 -2
- package/src/index.mjs +131 -33
- package/src/server/ingest.mjs +118 -0
- package/src/server/serve.mjs +60 -0
- package/src/server.mjs +13 -91
- package/src/shared.mjs +31 -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 +55 -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 +227 -95
package/src/index.mjs
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
// media() for weft.config.ts
|
|
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({
|
|
9
|
-
|
|
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
|
|
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())
|
|
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 (
|
|
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))
|
|
41
|
-
|
|
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')
|
|
47
|
-
|
|
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())
|
|
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 (
|
|
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))
|
|
62
|
-
|
|
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)
|
|
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: {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
//
|
|
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
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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 }) =>
|
|
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 =
|
|
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
|
+
}
|