@weft-js/plugin-media 0.1.1 → 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/README.md +470 -3
- package/package.json +12 -5
- package/src/build.mjs +143 -30
- package/src/check.mjs +216 -62
- package/src/index.mjs +123 -32
- package/src/server/ingest.mjs +118 -0
- package/src/server/serve.mjs +60 -0
- package/src/server.mjs +13 -91
- package/src/shared.mjs +15 -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 +54 -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 +222 -92
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,52 +24,99 @@ 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
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: {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
//
|
|
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
|
@@ -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
|
|
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 = {
|
|
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 }) =>
|
|
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 =
|
|
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
|
+
}
|