@weft-js/plugin-media 0.0.0-stage → 0.1.1
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/LICENSE +16 -0
- package/README.md +8 -2
- package/package.json +36 -4
- package/src/build.mjs +182 -0
- package/src/check.mjs +97 -0
- package/src/index.d.ts +28 -0
- package/src/index.mjs +115 -0
- package/src/server.mjs +143 -0
- package/src/shared.mjs +29 -0
- package/src/svg.mjs +15 -0
- package/src/upload.mjs +133 -0
- package/src/video.mjs +268 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ravonus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weft-js/plugin-media
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Early release (0.1.1).** APIs will change between minor versions.
|
|
4
|
+
|
|
5
|
+
The media.build@1 provider (sharp + SVGO: AVIF plus a compatible fallback, an SSIMULACRA2 quality search, width ladders, placeholders), the media.server@1 route provider, and M001–M018.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @weft-js/plugin-media
|
|
9
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/plugin-media",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "@weft-js/plugin-media: the media.build@1 provider (sharp + SVGO: AVIF plus a compatible fallback, an SSIMULACRA2 quality search, width ladders, placeholders), the media.server@1 route provider, and M001–M018",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.d.ts",
|
|
10
|
+
"default": "./src/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./build": "./src/build.mjs",
|
|
13
|
+
"./server": "./src/server.mjs"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"sharp": "0.35.5",
|
|
17
|
+
"svgo": "4.1.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@weft-js/core": "^0.1.1"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=26"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"weft"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"plugin.mjs",
|
|
31
|
+
"plugin.d.mts",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/build.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// media.build@1 (research 15 §2–4, research 26 §2.4): one source file in, a MediaAsset and its files out.
|
|
2
|
+
// probe (sharp, EXIF orientation) → width ladder → per-format quality search at the largest width (SSIMULACRA2,
|
|
3
|
+
// the libjxl tool) → encode every width → dominant colour + 16 px WebP placeholder. SVG: refuse unsafe files
|
|
4
|
+
// (M012), then SVGO. With a media.encode@1 provider (@weft-js/plugin-media-ffmpeg), src/video.mjs takes video, audio
|
|
5
|
+
// and animated GIFs; without one, a GIF stays as it is and video is P101.
|
|
6
|
+
// Cached by content hash under `cacheDir`, so dev restarts and rebuilds don't re-encode. With `encoder: 'server'` the
|
|
7
|
+
// build only stores the original: the variants are media.server@1's to encode (research 29 §6).
|
|
8
|
+
import { execFile, spawnSync } from 'node:child_process'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { basename, extname, join } from 'node:path'
|
|
13
|
+
import { promisify } from 'node:util'
|
|
14
|
+
import sharp from 'sharp'
|
|
15
|
+
import { optimize } from 'svgo'
|
|
16
|
+
import { probe } from '@weft-js/core/media-probe'
|
|
17
|
+
import { createMediaServer } from './server.mjs'
|
|
18
|
+
import { DEFAULTS, FIXED, encoders, hex, isPhoto, MIME } from './shared.mjs'
|
|
19
|
+
import { unsafeSvg } from './svg.mjs'
|
|
20
|
+
import { AUDIO, audioFile, gif, VIDEO, video } from './video.mjs'
|
|
21
|
+
|
|
22
|
+
const run = promisify(execFile)
|
|
23
|
+
const VERSION = 2 // bump when the output for the same input and options changes
|
|
24
|
+
|
|
25
|
+
export { DEFAULTS, FIXED, MIME, encoders, hex, isPhoto } from './shared.mjs'
|
|
26
|
+
|
|
27
|
+
/** The range the quality search walks. */
|
|
28
|
+
const RANGE = { avif: [20, 90], webp: [40, 95], jpeg: [40, 95] }
|
|
29
|
+
const EXT = { avif: 'avif', webp: 'webp', jpeg: 'jpg', png: 'png', gif: 'gif', svg: 'svg' }
|
|
30
|
+
|
|
31
|
+
const scorers = new Map()
|
|
32
|
+
/** The SSIMULACRA2 command if it runs, else null (the search then uses FIXED and says `quality: 'fixed'`). */
|
|
33
|
+
export function findScorer(name) {
|
|
34
|
+
if (!name || name === 'none') return null
|
|
35
|
+
if (!scorers.has(name)) {
|
|
36
|
+
const r = spawnSync(name, [], { encoding: 'utf8' })
|
|
37
|
+
scorers.set(name, r.error ? null : name)
|
|
38
|
+
}
|
|
39
|
+
return scorers.get(name)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Lowest quality whose SSIMULACRA2 score is ≥ target, within 5 encodes (scores rise with quality). */
|
|
43
|
+
async function search(format, base, width, scorer, target, dir) {
|
|
44
|
+
const ref = join(dir, 'ref.png')
|
|
45
|
+
if (!existsSync(ref)) writeFileSync(ref, await base().resize({ width }).png().toBuffer())
|
|
46
|
+
const score = async q => {
|
|
47
|
+
const encoded = await encoders[format](base().resize({ width }), q).toBuffer()
|
|
48
|
+
const out = join(dir, `${format}-${q}.png`)
|
|
49
|
+
writeFileSync(out, await sharp(encoded).png().toBuffer())
|
|
50
|
+
const { stdout } = await run(scorer, [ref, out])
|
|
51
|
+
return parseFloat(stdout)
|
|
52
|
+
}
|
|
53
|
+
let [lo, hi] = RANGE[format]
|
|
54
|
+
for (let i = 0; i < 5 && lo < hi; i++) {
|
|
55
|
+
const mid = Math.floor((lo + hi) / 2)
|
|
56
|
+
if ((await score(mid)) >= target) hi = mid
|
|
57
|
+
else lo = mid + 1
|
|
58
|
+
}
|
|
59
|
+
return hi
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const short = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 8)
|
|
63
|
+
|
|
64
|
+
/** The media.build@1 provider: `build` takes one source file and gives back its asset and encoded files, cached by content. */
|
|
65
|
+
export function createMediaBuild(input = {}) {
|
|
66
|
+
const o = { ...DEFAULTS, ...input, quality: { ...DEFAULTS.quality, ...input.quality }, video: { ...DEFAULTS.video, ...input.video } }
|
|
67
|
+
return {
|
|
68
|
+
/**
|
|
69
|
+
* @param {{ file: string, bytes: Uint8Array }} source
|
|
70
|
+
* @param {{ cacheDir: string, dev?: boolean, encode?: Record<string, object> }} ctx
|
|
71
|
+
*/
|
|
72
|
+
async build({ file, bytes, audio }, { cacheDir, root, encode = {} }) {
|
|
73
|
+
const name = basename(file)
|
|
74
|
+
const timed = VIDEO.test(file) || AUDIO.test(file)
|
|
75
|
+
if (o.encoder === 'server' && !timed) {
|
|
76
|
+
try {
|
|
77
|
+
return { asset: await createMediaServer(o).at(root, encode).ingest(bytes, { name }), files: [] }
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (!e.code) throw e
|
|
80
|
+
return { asset: null, files: [], diagnostics: [{ code: e.code, severity: 'error', message: e.message }] }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const stem = basename(file, extname(file)).replace(/[^\w-]+/g, '-')
|
|
84
|
+
const key = createHash('sha256').update(bytes).update(JSON.stringify(o)).update(String(VERSION)).update(audio ? readFileSync(audio) : '').update(JSON.stringify(await versions(encode))).digest('hex').slice(0, 20)
|
|
85
|
+
const cached = join(cacheDir, `${key}.json`)
|
|
86
|
+
if (existsSync(cached)) {
|
|
87
|
+
const hit = JSON.parse(readFileSync(cached, 'utf8'))
|
|
88
|
+
return { ...hit, files: hit.files.map(n => ({ name: n, bytes: new Uint8Array(readFileSync(join(cacheDir, key, n))) })) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const source = { bytes, name, stem, audio }
|
|
92
|
+
const scorer = findScorer(o.quality.scorer)
|
|
93
|
+
const out = await (/\.svg$/i.test(file) ? vector(name, stem, bytes) : VIDEO.test(file) ? video(source, o, encode, scorer) : AUDIO.test(file) ? audioFile(source, o, encode) : raster(source, o, encode))
|
|
94
|
+
if (out.diagnostics?.some(d => d.severity === 'error')) return out
|
|
95
|
+
mkdirSync(join(cacheDir, key), { recursive: true })
|
|
96
|
+
for (const f of out.files) writeFileSync(join(cacheDir, key, f.name), f.bytes)
|
|
97
|
+
writeFileSync(cached, JSON.stringify({ ...out, files: out.files.map(f => f.name) }))
|
|
98
|
+
return out
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What the encoders are: a new ffmpeg means new bytes. */
|
|
104
|
+
async function versions(encode) {
|
|
105
|
+
const found = {}
|
|
106
|
+
for (const [key, enc] of Object.entries(encode)) found[key] = await enc.versions().catch(() => null)
|
|
107
|
+
return found
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** SVG: refuse what can run or fetch (M012), else SVGO's default preset (it keeps the viewBox). */
|
|
111
|
+
function vector(name, stem, bytes) {
|
|
112
|
+
const text = new TextDecoder().decode(bytes)
|
|
113
|
+
const unsafe = unsafeSvg(text)
|
|
114
|
+
if (unsafe.length) return { asset: null, files: [], diagnostics: [{ code: 'M012', severity: 'error', message: `${name} has ${unsafe.join(', ')}: an image with active content or external references is not optimised or served. Sanitise the file.` }] }
|
|
115
|
+
const size = probe(bytes, name)
|
|
116
|
+
if (!size) return { asset: null, files: [], diagnostics: [{ code: 'M001', severity: 'error', message: `${name} has no width/height or viewBox, so its size is unknown.` }] }
|
|
117
|
+
const svg = new TextEncoder().encode(optimize(text, { multipass: true }).data)
|
|
118
|
+
const fileName = `${stem}-${short(svg)}.svg`
|
|
119
|
+
return { asset: { kind: 'vector', src: fileName, width: size.width, height: size.height, type: MIME.svg, sources: [], name, quality: 'original' }, files: [{ name: fileName, bytes: svg }] }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function raster({ bytes, name, stem, audio }, o, encode) {
|
|
123
|
+
const meta = await sharp(bytes, { animated: true }).metadata()
|
|
124
|
+
const turned = (meta.orientation ?? 1) >= 5
|
|
125
|
+
const width = turned ? meta.height : meta.width
|
|
126
|
+
const height = turned ? (meta.pageHeight ?? meta.width) : (meta.pageHeight ?? meta.height)
|
|
127
|
+
const diagnostics = []
|
|
128
|
+
|
|
129
|
+
if (meta.format === 'gif' && (meta.pages ?? 1) > 1) {
|
|
130
|
+
const converted = await gif({ bytes, name, stem, audio, meta, width, height }, o, encode, findScorer(o.quality.scorer))
|
|
131
|
+
if (converted) return converted
|
|
132
|
+
if (bytes.length > o.gifLimit) diagnostics.push({ code: 'M004', severity: 'warning', message: `${name} is a ${Math.round(bytes.length / 1024)} KB animated GIF, and it ships as is${encode['video/h264'] ? ': nothing beat it by 15%' : '. Add `mediaFfmpeg()` from @weft-js/plugin-media-ffmpeg to turn it into video'}.` })
|
|
133
|
+
const fileName = `${stem}-${short(bytes)}.gif`
|
|
134
|
+
return { asset: { kind: 'graphic', src: fileName, width, height, type: MIME.gif, sources: [], name, quality: 'original' }, files: [{ name: fileName, bytes }], diagnostics }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const photo = isPhoto(meta)
|
|
138
|
+
const fallback = photo ? 'jpeg' : 'png'
|
|
139
|
+
const formats = [...new Set(o.formats.filter(f => f !== fallback)), fallback]
|
|
140
|
+
const largest = Math.min(width, o.widths.at(-1))
|
|
141
|
+
const ladder = [...new Set([...o.widths.filter(w => w < largest), largest])]
|
|
142
|
+
const base = () => sharp(bytes).rotate()
|
|
143
|
+
|
|
144
|
+
const scorer = findScorer(o.quality.scorer)
|
|
145
|
+
const dir = scorer ? mkdtempSync(join(tmpdir(), 'weft-media-')) : null
|
|
146
|
+
const qualities = {}
|
|
147
|
+
try {
|
|
148
|
+
for (const f of formats) {
|
|
149
|
+
if (f === 'png') continue
|
|
150
|
+
qualities[f] = scorer ? await search(f, base, largest, scorer, o.quality.target, dir) : FIXED[f]
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
if (dir) rmSync(dir, { recursive: true, force: true })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const files = []
|
|
157
|
+
const sources = []
|
|
158
|
+
for (const f of formats) {
|
|
159
|
+
const entries = []
|
|
160
|
+
for (const w of ladder) {
|
|
161
|
+
const encoded = await encoders[f](base().resize({ width: w }), qualities[f]).toBuffer()
|
|
162
|
+
const fileName = `${stem}-${w}-${short(encoded)}.${EXT[f]}`
|
|
163
|
+
files.push({ name: fileName, bytes: new Uint8Array(encoded) })
|
|
164
|
+
entries.push([fileName, w])
|
|
165
|
+
}
|
|
166
|
+
sources.push({ type: MIME[f], srcset: entries.map(([n, w]) => `${n} ${w}w`).join(', ') })
|
|
167
|
+
}
|
|
168
|
+
const src = sources.at(-1).srcset.split(', ').at(-1).split(' ')[0]
|
|
169
|
+
|
|
170
|
+
const asset = { kind: photo ? 'photo' : 'graphic', src, width, height, type: MIME[fallback], sources, name, quality: scorer ? 'ssimulacra2' : 'fixed', qualities }
|
|
171
|
+
if (o.encoder === 'compatible') { // The fallback ladder is already built: a swap costs no extra files.
|
|
172
|
+
asset.copy = { src, type: MIME[fallback] }
|
|
173
|
+
asset.swap = o.swap ?? 'intent'
|
|
174
|
+
diagnostics.push({ code: 'M014', severity: 'info', message: `${name}: \`encoder: 'compatible'\` adds 0 KB: the compatible copy is the fallback ladder.` })
|
|
175
|
+
}
|
|
176
|
+
if (o.placeholder) {
|
|
177
|
+
asset.dominant = hex((await base().stats()).dominant)
|
|
178
|
+
const tiny = await base().resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()
|
|
179
|
+
asset.placeholder = `data:image/webp;base64,${tiny.toString('base64')}`
|
|
180
|
+
}
|
|
181
|
+
return { asset, files, diagnostics }
|
|
182
|
+
}
|
package/src/check.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// M001–M013 in `weft check` (research 15 §7.5; the static rules of research 26 §2.5). Runs as this plugin's `check`
|
|
2
|
+
// hook over the project's .tsx/.jsx files, with the JSX view `weft check` passes in `ctx`.
|
|
3
|
+
import { basename, dirname, extname, join, normalize } from 'node:path'
|
|
4
|
+
import { probe } from '@weft-js/core/media-probe'
|
|
5
|
+
import { unsafeSvg } from './svg.mjs'
|
|
6
|
+
|
|
7
|
+
const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)$/i
|
|
8
|
+
const MEDIA = new Set(['img', 'video', 'Media'])
|
|
9
|
+
const ROUTE = /(^|\/)src\/routes\//
|
|
10
|
+
|
|
11
|
+
const rel = (from, spec) => normalize(join(dirname(from), spec)).split('\\').join('/')
|
|
12
|
+
|
|
13
|
+
/** @param {{ files: string[], read: (f: string) => string, jsx: Function, importOf: Function, exists: (f: string) => boolean, bytes: (f: string) => Uint8Array | undefined }} ctx */
|
|
14
|
+
export function checkMedia(ctx, { gifLimit = 256 * 1024 } = {}) {
|
|
15
|
+
const out = []
|
|
16
|
+
const svgs = new Map()
|
|
17
|
+
for (const file of ctx.files) {
|
|
18
|
+
if (!/\.[jt]sx$/.test(file)) continue
|
|
19
|
+
const scan = ctx.jsx(file)
|
|
20
|
+
const report = (code, el, why, fix) => out.push({ code, file, start: el.start, end: el.tagEnd, why, ...(fix && { fix }) })
|
|
21
|
+
const media = scan.elements.filter(e => MEDIA.has(e.name))
|
|
22
|
+
const route = ROUTE.test(file)
|
|
23
|
+
let priorities = 0
|
|
24
|
+
|
|
25
|
+
for (const [i, el] of media.entries()) {
|
|
26
|
+
const a = n => el.attr(n)
|
|
27
|
+
const src = a('src')
|
|
28
|
+
const imp = src?.kind === 'expr' ? ctx.importOf(scan, src.expr) : undefined
|
|
29
|
+
const [spec, query = ''] = imp ? imp.source.split('?') : []
|
|
30
|
+
const target = imp && spec.startsWith('.') ? rel(file, spec) : undefined
|
|
31
|
+
const bytes = target && IMAGE.test(target) ? ctx.bytes(target) : undefined
|
|
32
|
+
const size = bytes && probe(bytes, target)
|
|
33
|
+
const style = a('style')?.value ?? a('style')?.expr ?? ''
|
|
34
|
+
const sized = (a('width') && a('height')) || /aspect-?ratio/i.test(style)
|
|
35
|
+
const raw = el.name !== 'Media'
|
|
36
|
+
|
|
37
|
+
// M016: a string has no dimensions until it loads
|
|
38
|
+
if (!raw && (src?.kind === 'string' || /^[`'"]/.test(src?.expr ?? '')))
|
|
39
|
+
report('M016', el, '`<Media src>` is a string, so nothing knows its size before it loads and the page shifts.', { description: 'Import it with `?media`, or store the record `ctx.media.ingest` returns and type the field `z.media()`', safe: false })
|
|
40
|
+
|
|
41
|
+
// M001: raw media with no box (X030 covers it inside a Boundary)
|
|
42
|
+
if (raw && !sized && !el.ancestors.includes('Boundary')) {
|
|
43
|
+
report('M001', el, `<${el.name}> has no \`width\`/\`height\` or \`aspect-ratio\`: it renders 0 px tall and pushes the page down when it loads.`,
|
|
44
|
+
size ? { description: `Insert the file's size (${size.width}×${size.height})`, safe: true, edits: [{ start: el.tagEnd, end: el.tagEnd, text: ` width={${size.width}} height={${size.height}}` }] } : undefined)
|
|
45
|
+
}
|
|
46
|
+
// M002: through Vite's URL import or a string path to a project file
|
|
47
|
+
if (el.name === 'img' && imp && IMAGE.test(spec) && query !== 'media') {
|
|
48
|
+
report('M002', el, `\`${imp.source}\` is the untouched file: no resizing, no AVIF, no placeholder.`, { description: 'Use ?media and <Media>', safe: false, edits: [{ start: imp.start, end: imp.end, text: `import ${imp.local} from '${spec}?media'` }] })
|
|
49
|
+
} else if (el.name === 'img' && src?.kind === 'string' && !/^(https?:|data:|\/\/)/.test(src.value) && !src.value.startsWith('/') && IMAGE.test(src.value) && ctx.exists(rel(file, src.value))) {
|
|
50
|
+
report('M002', el, `\`${src.value}\` is a project file referenced by string: no resizing, no AVIF, no dimensions.`)
|
|
51
|
+
}
|
|
52
|
+
// M003: oversize for its rendered width
|
|
53
|
+
const w = Number(a('width')?.value ?? a('width')?.expr)
|
|
54
|
+
if (el.name === 'img' && size && w > 0 && size.width > w * 2 * 1.25) {
|
|
55
|
+
report('M003', el, `${basename(target)} is ${size.width} px wide for a ${w} px slot: ${Math.round(size.width / (w * 2) * 100) / 100}× what a 2× screen needs.`)
|
|
56
|
+
}
|
|
57
|
+
// M004 and M011: GIFs in raw <img>
|
|
58
|
+
const gif = (target && /\.gif$/i.test(target)) || (src?.kind === 'string' && /\.gif$/i.test(src.value))
|
|
59
|
+
if (el.name === 'img' && gif && bytes && bytes.length > gifLimit) report('M004', el, `${basename(target)} is ${Math.round(bytes.length / 1024)} KB of GIF.`)
|
|
60
|
+
if (el.name === 'img' && gif) report('M011', el, 'An animated GIF in a raw <img> loops forever with no pause control (WCAG 2.2.2).')
|
|
61
|
+
// M005, M006, M011: video
|
|
62
|
+
if (el.name === 'video') {
|
|
63
|
+
if (a('autoplay') && !a('muted')) report('M005', el, '<video autoplay> without `muted`: browsers block it, and sound before a gesture is hostile.', { description: 'Add muted', safe: true, edits: [{ start: el.tagEnd, end: el.tagEnd, text: ' muted' }] })
|
|
64
|
+
if (!a('poster')) report('M006', el, '<video> without `poster` is a blank box until its first frame decodes.')
|
|
65
|
+
if (a('autoplay') && a('loop') && !a('controls')) report('M011', el, '<video autoplay loop> without `controls` moves forever with no way to pause it (WCAG 2.2.2).')
|
|
66
|
+
}
|
|
67
|
+
// M007: alt
|
|
68
|
+
if (el.name !== 'video') {
|
|
69
|
+
const alt = a('alt')
|
|
70
|
+
const stems = [imp?.local, target && basename(target), target && basename(target, extname(target)), src?.kind === 'string' && basename(src.value)].filter(Boolean).map(s => s.toLowerCase())
|
|
71
|
+
if (!alt) report('M007', el, `<${el.name}> has no \`alt\`.`)
|
|
72
|
+
else if (el.name === 'Media' && alt.kind === 'string' && alt.value === '' && !a('decorative')) report('M007', el, '`alt=""` hides the image from screen readers: say so with `decorative`, or describe it.')
|
|
73
|
+
else if (alt.kind === 'string' && alt.value && stems.includes(alt.value.toLowerCase())) report('M007', el, `\`alt="${alt.value}"\` is the file name, which tells nobody what the image shows.`)
|
|
74
|
+
}
|
|
75
|
+
// M008, M009, M010: priority (route files: the likely LCP image)
|
|
76
|
+
const lazy = a('loading')?.value === 'lazy'
|
|
77
|
+
const priority = !!a('priority') || a('fetchpriority')?.value === 'high'
|
|
78
|
+
if (priority && lazy) report('M008', el, '`priority` media with `loading="lazy"` loads late anyway.', { description: 'Remove loading="lazy"', safe: true, edits: [{ start: a('loading').start - 1, end: a('loading').end, text: '' }] })
|
|
79
|
+
else if (route && i === 0 && lazy) report('M008', el, `The route's first media is \`loading="lazy"\`: it is the likely LCP image, and lazy delays it.`)
|
|
80
|
+
if (route && priority && ++priorities > 2) {
|
|
81
|
+
const p = a('priority') ?? a('fetchpriority')
|
|
82
|
+
report('M009', el, `More than 2 \`priority\` media in this route: they compete for the first bytes.`, { description: 'Remove priority', safe: true, edits: [{ start: p.start - 1, end: p.end, text: '' }] })
|
|
83
|
+
}
|
|
84
|
+
if (el.name === 'Media' && a('priority') && !a('width') && !a('sizes')) report('M010', el, '`priority` makes this image eager, and eager images can\'t use `sizes="auto"`: the browser assumes 100vw and fetches the largest file.')
|
|
85
|
+
// M012: unsafe SVG used as an image
|
|
86
|
+
if (target && /\.svg$/i.test(target) && bytes) {
|
|
87
|
+
if (!svgs.has(target)) svgs.set(target, unsafeSvg(new TextDecoder().decode(bytes)))
|
|
88
|
+
if (svgs.get(target).length) report('M012', el, `${basename(target)} has ${svgs.get(target).join(', ')}.`)
|
|
89
|
+
}
|
|
90
|
+
// M013: public/ by string
|
|
91
|
+
if (src?.kind === 'string' && src.value.startsWith('/') && !src.value.startsWith('//') && ctx.exists(`public${src.value.split('?')[0]}`)) {
|
|
92
|
+
report('M013', el, `\`${src.value}\` is served from public/: untracked, unoptimised, with no dimensions.`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
/** Options of `media()`: what to encode, how far to search, and when the encoding happens. */
|
|
4
|
+
export interface MediaOptions {
|
|
5
|
+
/** Efficient formats in front of the source-format fallback. Default `['avif']`. */
|
|
6
|
+
formats?: ('avif' | 'webp')[]
|
|
7
|
+
/** The width ladder; the largest also caps the output. Default 320, 640, 960, 1280, 1920, 2560. */
|
|
8
|
+
widths?: number[]
|
|
9
|
+
/** `target`: the SSIMULACRA2 score each format is searched to (default 80). `scorer`: the command, or 'none'. */
|
|
10
|
+
quality?: { target?: number; scorer?: string }
|
|
11
|
+
/** Dominant colour and a 16 px WebP placeholder. Default true. */
|
|
12
|
+
placeholder?: boolean
|
|
13
|
+
/** Bytes over which a GIF is M004. Default 256 KB. */
|
|
14
|
+
gifLimit?: number
|
|
15
|
+
/** 'server': `?media` imports are encoded on first request by the media route, not at build. 'compatible': the page shows the efficient file, and a compatible copy swaps in so saving gives a file that opens anywhere. Default 'off'. */
|
|
16
|
+
encoder?: 'off' | 'compatible' | 'server'
|
|
17
|
+
/** When a compatible copy swaps in, with `encoder: 'compatible'`. Default 'intent': on right-click, long-press or drag. */
|
|
18
|
+
swap?: 'intent' | 'idle' | 'never'
|
|
19
|
+
/** Efficient codecs next to H.264 (default `['av1']`), and the ladder (default 640, 960, 1280, 1920). */
|
|
20
|
+
video?: { codecs?: ('av1' | 'vp9' | 'hevc')[]; widths?: number[] }
|
|
21
|
+
/** `toVideo: 'never'` keeps GIFs. `keepUnder`: bytes under which a GIF stays one. Default 8 KB. */
|
|
22
|
+
gif?: { toVideo?: 'auto' | 'never'; keepUnder?: number }
|
|
23
|
+
/** The media route's store (uploads, and `encoder: 'server'`). `dir` default `.weft/media`, `maxBytes` default 20 MB. */
|
|
24
|
+
server?: { dir?: string; maxBytes?: number }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The plugin for `weft.config.ts`: `?media` imports become optimised, responsive assets. */
|
|
28
|
+
export declare function media(options?: MediaOptions): PluginInstance<MediaOptions>
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// media() for weft.config.ts (research 15 §7.4, research 26 §2, research 29 §6): provides media.build@1 (src/build.mjs,
|
|
2
|
+
// run by @weft-js/core/vite for `?media` imports) and media.server@1 (src/server.mjs, the route for uploads and `encoder:
|
|
3
|
+
// 'server'`), and reports M001–M013 and M016 through its `check` hook (src/check.mjs).
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
6
|
+
import { checkMedia } from './check.mjs'
|
|
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'] })
|
|
10
|
+
const FORMATS = ['avif', 'webp']
|
|
11
|
+
const CODECS = ['av1', 'vp9', 'hevc']
|
|
12
|
+
|
|
13
|
+
const fail = (path, message) => {
|
|
14
|
+
throw Object.assign(new Error(message), { path })
|
|
15
|
+
}
|
|
16
|
+
|
|
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
|
+
}
|
|
29
|
+
if (input.video !== undefined) {
|
|
30
|
+
const { codecs, widths } = input.video
|
|
31
|
+
if (codecs !== undefined) {
|
|
32
|
+
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)`)
|
|
34
|
+
}
|
|
35
|
+
if (widths !== undefined && !(Array.isArray(widths) && widths.length && widths.every(w => Number.isInteger(w) && w > 0))) fail('video.widths', 'expected positive integers')
|
|
36
|
+
out.video = { ...input.video, ...(widths && { widths: [...widths].sort((a, b) => a - b) }) }
|
|
37
|
+
}
|
|
38
|
+
if (input.gif !== undefined) {
|
|
39
|
+
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')
|
|
42
|
+
out.gif = input.gif
|
|
43
|
+
}
|
|
44
|
+
if (input.server !== undefined) {
|
|
45
|
+
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')
|
|
48
|
+
out.server = input.server
|
|
49
|
+
}
|
|
50
|
+
if (input.formats !== undefined) {
|
|
51
|
+
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)`)
|
|
53
|
+
out.formats = input.formats
|
|
54
|
+
}
|
|
55
|
+
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')
|
|
57
|
+
out.widths = [...input.widths].sort((a, b) => a - b)
|
|
58
|
+
}
|
|
59
|
+
if (input.quality !== undefined) {
|
|
60
|
+
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")
|
|
63
|
+
out.quality = { target, scorer }
|
|
64
|
+
}
|
|
65
|
+
if (input.placeholder !== undefined) out.placeholder = !!input.placeholder
|
|
66
|
+
if (input.gifLimit !== undefined) out.gifLimit = input.gifLimit
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const CODES = [
|
|
71
|
+
['M001', 'Raw <img>/<video> without width/height or aspect-ratio'],
|
|
72
|
+
['M002', 'Image through ?url or a string path'],
|
|
73
|
+
['M003', 'Raw <img> file far wider than its slot'],
|
|
74
|
+
['M004', 'GIF over 256 KB rendered raw'],
|
|
75
|
+
['M005', '<video autoplay> without muted'],
|
|
76
|
+
['M006', '<video> without poster'],
|
|
77
|
+
['M007', 'Missing or meaningless alt'],
|
|
78
|
+
['M008', 'Likely LCP image loaded lazily'],
|
|
79
|
+
['M009', 'More than 2 priority media in a route'],
|
|
80
|
+
['M010', 'priority <Media> with no width or sizes'],
|
|
81
|
+
['M011', 'Endless motion with no pause control'],
|
|
82
|
+
['M012', 'Unsafe SVG used as an image'],
|
|
83
|
+
['M013', 'public/ file referenced by string'],
|
|
84
|
+
['M014', "What `encoder: 'compatible'` adds to the build"],
|
|
85
|
+
['M015', 'An HDR or over-8-bit source that comes out 8-bit SDR'],
|
|
86
|
+
['M016', '<Media src> given a string: no dimensions before render'],
|
|
87
|
+
['M017', 'Media requested with no media.server provider'],
|
|
88
|
+
['M018', 'An upload refused: not an image, or over the size limit'],
|
|
89
|
+
['M019', 'ffmpeg missing, not the pinned binary, or without an encoder'],
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
export const media = definePlugin({
|
|
93
|
+
name: '@weft-js/plugin-media',
|
|
94
|
+
version: '0.1.0',
|
|
95
|
+
weft: '*',
|
|
96
|
+
namespace: 'M',
|
|
97
|
+
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 }) },
|
|
102
|
+
diagnostics: CODES.map(([code, catches]) => ({ code, catches, fix: `weft docs ${code}` })),
|
|
103
|
+
agents: {
|
|
104
|
+
title: 'Media (@weft-js/plugin-media)',
|
|
105
|
+
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.",
|
|
107
|
+
'- 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
|
+
'- `alt` is required; use `alt="" decorative` for purely visual media (M007).',
|
|
109
|
+
'- 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).",
|
|
111
|
+
].join('\n'),
|
|
112
|
+
},
|
|
113
|
+
catalog: [{ name: 'Media', import: '@weft-js/core/media', summary: '<Media src={x} alt>: dimensions, AVIF + fallback, srcset/sizes, placeholder' }],
|
|
114
|
+
}),
|
|
115
|
+
})
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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>`,
|
|
3
|
+
// encoding each variant once and keeping it next to the original. `auto` negotiates on `Accept`. Only widths on the
|
|
4
|
+
// ladder and formats in the config are served, so a URL can't ask for unbounded work. Video, audio and animated GIFs go
|
|
5
|
+
// through src/upload.mjs when media.encode@1 is bound.
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
|
+
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'
|
|
15
|
+
|
|
16
|
+
const ROUTE = '/_weft/m'
|
|
17
|
+
const FORMATS_TIMED = new Set(['mp4', 'webm', 'm4a', 'gif', 'webp'])
|
|
18
|
+
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
|
+
|
|
22
|
+
const refuse = (code, message) => {
|
|
23
|
+
throw Object.assign(new Error(message), { code })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The first of `formats` the client names with a q above 0, else `fallback`. `*` promises nothing: only names count. */
|
|
27
|
+
export function negotiate(accept, formats, fallback) {
|
|
28
|
+
const q = new Map()
|
|
29
|
+
for (const part of accept.split(',')) {
|
|
30
|
+
const [type, ...params] = part.trim().toLowerCase().split(';')
|
|
31
|
+
const v = params.map(p => /^\s*q=([\d.]+)/.exec(p)?.[1]).find(Boolean)
|
|
32
|
+
q.set(type.trim(), v === undefined ? 1 : Number(v))
|
|
33
|
+
}
|
|
34
|
+
return formats.find(f => q.get(MIME[f]) > 0) ?? fallback
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The media.server@1 provider: `at(root, encode)` is the store for one app, its `ingest` and its `serve`. */
|
|
38
|
+
export function createMediaServer(input = {}) {
|
|
39
|
+
const o = { ...DEFAULTS, ...input, video: { ...DEFAULTS.video, ...input.video } }
|
|
40
|
+
return { at: (root, encode = {}) => storage(o, root, encode) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
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')
|
|
45
|
+
const limit = o.server?.maxBytes ?? MAX_BYTES
|
|
46
|
+
const flight = new Map()
|
|
47
|
+
const url = (hash, width, format = 'auto') => `${ROUTE}/${hash}/${width}.${format}`
|
|
48
|
+
const metaOf = hash => {
|
|
49
|
+
const file = join(dir, hash, 'meta.json')
|
|
50
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The record and what `serve` needs; written last, so a meta.json means the original is whole. */
|
|
54
|
+
function store(hash, bytes, asset, meta) {
|
|
55
|
+
mkdirSync(join(dir, hash), { recursive: true })
|
|
56
|
+
writeFileSync(join(dir, hash, 'source'), bytes)
|
|
57
|
+
const tmp = join(dir, hash, `meta.${process.pid}.tmp`)
|
|
58
|
+
writeFileSync(tmp, JSON.stringify({ ...meta, asset }))
|
|
59
|
+
renameSync(tmp, join(dir, hash, 'meta.json'))
|
|
60
|
+
return asset
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const timed = uploads({ o, dir, encode, url, store, refuse })
|
|
64
|
+
|
|
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
|
+
}
|
|
130
|
+
|
|
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
|
+
}
|
|
141
|
+
|
|
142
|
+
return { ingest, serve, settled: timed.settled }
|
|
143
|
+
}
|
package/src/shared.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
// can call the server for `encoder: 'server'` without the two importing each other.
|
|
3
|
+
|
|
4
|
+
/** Every option's default: the width ladder, the quality search, the placeholder, the GIF limit, the video ladder. */
|
|
5
|
+
export const DEFAULTS = {
|
|
6
|
+
formats: ['avif'],
|
|
7
|
+
widths: [320, 640, 960, 1280, 1920, 2560],
|
|
8
|
+
quality: { target: 80, scorer: 'ssimulacra2' },
|
|
9
|
+
placeholder: true,
|
|
10
|
+
gifLimit: 256 * 1024,
|
|
11
|
+
video: { codecs: ['av1'], widths: [640, 960, 1280, 1920] },
|
|
12
|
+
}
|
|
13
|
+
/** Encoder settings without a scorer (research 26 §2.4). */
|
|
14
|
+
export const FIXED = { avif: 50, webp: 75, jpeg: 78 }
|
|
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' }
|
|
17
|
+
|
|
18
|
+
/** One sharp call per format: quality in, a configured pipeline out. PNG takes no quality. */
|
|
19
|
+
export const encoders = {
|
|
20
|
+
avif: (img, q) => img.avif({ quality: q, effort: 4, chromaSubsampling: '4:2:0' }),
|
|
21
|
+
webp: (img, q) => img.webp({ quality: q, effort: 5 }),
|
|
22
|
+
jpeg: (img, q) => img.jpeg({ quality: q, mozjpeg: true }),
|
|
23
|
+
png: img => img.png({ compressionLevel: 9, effort: 8 }),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 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('')}`
|
|
28
|
+
/** 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)
|
package/src/svg.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// M012 (research 15 §7.5): SVG content that can run code or fetch something, which an image must not carry.
|
|
2
|
+
|
|
3
|
+
const RULES = [
|
|
4
|
+
[/<script[\s>/]/i, '<script>'],
|
|
5
|
+
[/<foreignObject[\s>/]/i, '<foreignObject>'],
|
|
6
|
+
[/\son[a-z]+\s*=/i, 'an on* event handler'],
|
|
7
|
+
[/\s(?:xlink:)?href\s*=\s*["'](?!#|data:)[^"']/i, 'an external href'],
|
|
8
|
+
[/url\(\s*['"]?(?!#|data:)[a-z]+:/i, 'an external url()'],
|
|
9
|
+
[/@import\b/i, 'a CSS @import'],
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
/** What makes this SVG unsafe as an image, or [] when nothing does. */
|
|
13
|
+
export function unsafeSvg(text) {
|
|
14
|
+
return RULES.filter(([re]) => re.test(text)).map(([, what]) => what)
|
|
15
|
+
}
|
package/src/upload.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Video, audio and animated GIFs through media.server@1, for uploads a build never saw (research 15 §5.4). `ingest`
|
|
2
|
+
// answers with the record at once and a queue encodes the rungs one at a time, so a server isn't swamped by a burst.
|
|
3
|
+
// Until a rung exists its URL gives the original if browsers can play that, marked temporary so nothing caches it, and
|
|
4
|
+
// otherwise waits for that encode. A queue lost to a restart costs nothing: the first request for a rung encodes it.
|
|
5
|
+
// No quality search here: fixed CRFs, and H.264 already at its width is kept (re-encoding can't win, research 15 §3.4).
|
|
6
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import sharp from 'sharp'
|
|
9
|
+
import { ladderOf, poster } from './video.mjs'
|
|
10
|
+
|
|
11
|
+
const TYPES = { mp4: 'video/mp4', webm: 'video/webm', m4a: 'audio/mp4', gif: 'image/gif', webp: 'image/webp' }
|
|
12
|
+
const COMPATIBLE = 'video/h264'
|
|
13
|
+
const SMALLER = 0.85
|
|
14
|
+
|
|
15
|
+
/** Video or audio, from the first bytes: what ffprobe should be asked about, not what the file's name claims. */
|
|
16
|
+
export function sniff(bytes) {
|
|
17
|
+
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 12))
|
|
18
|
+
if (head.slice(4, 8) === 'ftyp') return /^M4A|^M4B/.test(head.slice(8, 12)) ? 'audio' : 'video'
|
|
19
|
+
if (bytes[0] === 0x1a && bytes[1] === 0x45) return 'video'
|
|
20
|
+
if (head.startsWith('RIFF') && head.slice(8, 12) === 'WAVE') return 'audio'
|
|
21
|
+
if (head.startsWith('OggS') || head.startsWith('fLaC') || head.startsWith('ID3') || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0)) return 'audio'
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const webKept = info => info.video?.codec === 'h264' && info.video.pix === 'yuv420p' && (!info.audio || info.audio.codec === 'aac')
|
|
26
|
+
const playable = info => webKept(info) || (/webm/.test(info.format) && ['vp9', 'av1'].includes(info.video?.codec))
|
|
27
|
+
|
|
28
|
+
export function uploads({ o, dir, encode, url, store, refuse }) {
|
|
29
|
+
const flight = new Map()
|
|
30
|
+
let line = Promise.resolve()
|
|
31
|
+
const efficient = () => (o.video.codecs ?? []).map(c => `video/${c}`).find(k => encode[k])
|
|
32
|
+
const sourceOf = hash => join(dir, hash, 'source')
|
|
33
|
+
|
|
34
|
+
/** One rung, encoded once: the queue and a waiting request share the work, and the file lands whole or not at all. */
|
|
35
|
+
function rung(hash, meta, width, format) {
|
|
36
|
+
const file = join(dir, hash, `${width}.${format}`)
|
|
37
|
+
if (existsSync(file)) return readFileSync(file)
|
|
38
|
+
const key = `${hash}/${width}.${format}`
|
|
39
|
+
if (!flight.has(key)) {
|
|
40
|
+
const work = (async () => {
|
|
41
|
+
const audio = meta.kind === 'audio'
|
|
42
|
+
const enc = encode[audio ? (format === 'webm' ? 'audio/opus' : 'audio/aac') : format === 'webm' ? meta.webm : COMPATIBLE]
|
|
43
|
+
const copy = meta.keepTop === width && format === 'mp4'
|
|
44
|
+
const bytes = await enc.encode(sourceOf(hash), audio ? {} : copy ? { copy } : { width, knob: enc.knob.fixed })
|
|
45
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
46
|
+
writeFileSync(tmp, bytes)
|
|
47
|
+
renameSync(tmp, file)
|
|
48
|
+
return bytes
|
|
49
|
+
})().finally(() => flight.delete(key))
|
|
50
|
+
flight.set(key, work)
|
|
51
|
+
}
|
|
52
|
+
return flight.get(key)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const enqueue = (hash, meta) => {
|
|
56
|
+
for (const [width, format] of meta.rungs) line = line.then(() => rung(hash, meta, width, format)).catch(e => console.error(`media: ${hash}: ${e.message}`))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const record = (hash, rungs, types) => [...new Set(rungs.map(r => r[1]))].map(format => ({ type: types[format], srcset: rungs.filter(r => r[1] === format).map(([w]) => `${url(hash, w, format)} ${w || 1}w`).join(', ') }))
|
|
60
|
+
|
|
61
|
+
/** Rungs for a video: AV1 or VP9 first when there's something to gain, H.264 always. */
|
|
62
|
+
function videoRungs(info, width) {
|
|
63
|
+
const widths = ladderOf(o, width)
|
|
64
|
+
const keepTop = webKept(info) && widths.at(-1) === width ? width : undefined
|
|
65
|
+
const webm = keepTop === undefined ? efficient() : undefined
|
|
66
|
+
return { keepTop, webm, rungs: [...(webm ? widths.map(w => [w, 'webm']) : []), ...widths.map(w => [w, 'mp4'])] }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const hasCopy = () => o.encoder === 'compatible'
|
|
70
|
+
const compatible = asset => (hasCopy() ? { ...asset, swap: o.swap ?? 'intent' } : asset)
|
|
71
|
+
|
|
72
|
+
async function video(hash, bytes, name, info, extra = {}) {
|
|
73
|
+
const { width, height } = info.video
|
|
74
|
+
const plan = videoRungs(info, width)
|
|
75
|
+
const types = { webm: `video/webm; codecs="${plan.webm === 'video/vp9' ? 'vp9' : 'av01.0.08M.08'}${info.audio ? ', opus' : ''}"`, mp4: 'video/mp4' }
|
|
76
|
+
const cover = await poster(encode[COMPATIBLE], sourceOf(hash), 'poster', width, info.duration, o)
|
|
77
|
+
writeFileSync(join(dir, hash, 'poster.webp'), cover.file.bytes)
|
|
78
|
+
const top = url(hash, plan.rungs.at(-1)[0], 'mp4')
|
|
79
|
+
const asset = compatible({ kind: 'video', src: top, width, height, type: 'video/mp4', sources: record(hash, plan.rungs, types), name, quality: 'fixed', duration: info.duration, audio: !!info.audio, ...cover.asset, poster: url(hash, 0, 'webp'), ...(plan.webm && hasCopy() && { copy: { src: top, type: 'video/mp4' } }), ...extra.asset })
|
|
80
|
+
const meta = { kind: 'video', timed: true, ...plan, playable: playable(info) }
|
|
81
|
+
return { asset, meta }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function ingest(hash, bytes, name, kind) {
|
|
85
|
+
const enc = encode[COMPATIBLE]
|
|
86
|
+
writeFileSync(sourceOf(hash), bytes)
|
|
87
|
+
const info = await enc.info(sourceOf(hash)).catch(() => null)
|
|
88
|
+
if (kind === 'audio') {
|
|
89
|
+
if (!info?.audio) refuse('M018', `${name} has no audio track weft can read.`)
|
|
90
|
+
const rungs = [...(encode['audio/opus'] ? [[0, 'webm']] : []), [0, 'm4a']]
|
|
91
|
+
const asset = { kind: 'audio', src: url(hash, 0, 'm4a'), width: 0, height: 0, type: 'audio/mp4', sources: record(hash, rungs, { webm: 'audio/webm; codecs="opus"', m4a: 'audio/mp4; codecs="mp4a.40.2"' }), name, quality: 'fixed', duration: info.duration, audio: true }
|
|
92
|
+
const meta = { kind, timed: true, rungs }
|
|
93
|
+
enqueue(hash, meta)
|
|
94
|
+
return store(hash, bytes, asset, meta)
|
|
95
|
+
}
|
|
96
|
+
if (!info?.video) refuse('M018', `${name} has no video track weft can read.`)
|
|
97
|
+
const { asset, meta } = await video(hash, bytes, name, info)
|
|
98
|
+
enqueue(hash, meta)
|
|
99
|
+
return store(hash, bytes, asset, meta)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** An animated GIF: null when it should stay one (the rules in src/video.mjs, on the top H.264 rung). */
|
|
103
|
+
async function gif(hash, bytes, name, meta) {
|
|
104
|
+
if (!encode[COMPATIBLE] || bytes.length < (o.gif?.keepUnder ?? 8192) || (meta.pages ?? 1) < 4 || o.gif?.toVideo === 'never') return null
|
|
105
|
+
if (!(await sharp(bytes, { animated: true }).stats()).isOpaque) return null
|
|
106
|
+
writeFileSync(sourceOf(hash), bytes)
|
|
107
|
+
const info = await encode[COMPATIBLE].info(sourceOf(hash))
|
|
108
|
+
const width = meta.width
|
|
109
|
+
const height = meta.pageHeight ?? meta.height
|
|
110
|
+
const delays = meta.delay ?? []
|
|
111
|
+
const duration = delays.length ? delays.reduce((a, b) => a + (b < 20 ? 100 : b), 0) / 1000 : info.duration
|
|
112
|
+
const full = { ...info, duration, video: { ...info.video, width, height } }
|
|
113
|
+
const { asset, meta: plan } = await video(hash, bytes, name, full)
|
|
114
|
+
const top = plan.rungs.at(-1)[0]
|
|
115
|
+
if ((await rung(hash, plan, top, 'mp4')).length > bytes.length * SMALLER) return null
|
|
116
|
+
Object.assign(asset, { animated: true, audio: false, loop: meta.loop ?? 0 })
|
|
117
|
+
if (hasCopy()) asset.copy = { src: url(hash, width, 'gif'), type: 'image/gif', bytes: bytes.length }
|
|
118
|
+
plan.gif = width
|
|
119
|
+
enqueue(hash, plan)
|
|
120
|
+
return store(hash, bytes, asset, plan)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function serve(hash, meta, width, format) {
|
|
124
|
+
if (format === 'webp' && width === 0) return { bytes: readFileSync(join(dir, hash, 'poster.webp')), type: TYPES.webp, width, format, negotiated: false }
|
|
125
|
+
if (format === 'gif' && meta.gif === width) return { bytes: readFileSync(sourceOf(hash)), type: TYPES.gif, width, format, negotiated: false }
|
|
126
|
+
if (!meta.rungs.some(([w, f]) => w === width && f === format)) return null
|
|
127
|
+
const ready = existsSync(join(dir, hash, `${width}.${format}`))
|
|
128
|
+
if (!ready && meta.playable) return { bytes: readFileSync(sourceOf(hash)), type: TYPES[format], width, format, negotiated: false, temporary: true }
|
|
129
|
+
return { bytes: new Uint8Array(await rung(hash, meta, width, format)), type: meta.kind === 'audio' && format === 'webm' ? 'audio/webm' : TYPES[format], width, format, negotiated: false }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ingest, gif, serve, settled: () => line }
|
|
133
|
+
}
|
package/src/video.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Video, audio and GIF-to-video for media.build@1 (research 15 §3.3–3.4), through whichever providers media.encode@1
|
|
2
|
+
// has bound (`@weft-js/plugin-media-ffmpeg`).
|
|
3
|
+
// Keep what is already delivered: an H.264 source at or under the top width is remuxed, not re-encoded, and an
|
|
4
|
+
// efficient codec has to beat the H.264 file by 15% to ship (re-encoding a lossy source can't win at a fixed target).
|
|
5
|
+
// Quality: a CRF search at the top width, sampled SSIMULACRA2 frames with mean ≥ target and worst frame ≥ target − 5.
|
|
6
|
+
// A mean alone lets AV1 hide ugly frames (research 15 measured 55 inside an 80 mean). No scorer: fixed CRFs.
|
|
7
|
+
// GIFs stay GIFs under 8 KB, under 4 frames, with real transparency (H.264 has no alpha; animated WebP does), or when
|
|
8
|
+
// nothing is 15% smaller. Noisy GIFs came out 70–180% larger as video.
|
|
9
|
+
import { execFile } from 'node:child_process'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { extname, join } from 'node:path'
|
|
14
|
+
import { promisify } from 'node:util'
|
|
15
|
+
import sharp from 'sharp'
|
|
16
|
+
|
|
17
|
+
const run = promisify(execFile)
|
|
18
|
+
export const VIDEO = /\.(mp4|m4v|mov|webm|mkv|ogv)$/i
|
|
19
|
+
export const AUDIO = /\.(mp3|wav|flac|ogg|oga|opus|m4a|aac)$/i
|
|
20
|
+
const SMALLER = 0.85
|
|
21
|
+
const COMPATIBLE = 'video/h264'
|
|
22
|
+
const short = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 8)
|
|
23
|
+
const need = (o, key) => ({ code: 'P101', severity: 'error', message: `${o} needs \`media.encode\` for ${key}, and nothing provides it. Add \`mediaFfmpeg()\` from @weft-js/plugin-media-ffmpeg to \`plugins\` in weft.config.ts.` })
|
|
24
|
+
const errored = (name, e) => ({ asset: null, files: [], diagnostics: [{ code: e.code ?? 'M019', severity: 'error', message: `${name}: ${e.message}` }] })
|
|
25
|
+
|
|
26
|
+
/** A temp file for ffmpeg to read: `work(path)` runs with the bytes on disk. */
|
|
27
|
+
async function onDisk(name, bytes, work) {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), 'weft-media-'))
|
|
29
|
+
try {
|
|
30
|
+
const path = join(dir, `in${extname(name).toLowerCase()}`)
|
|
31
|
+
writeFileSync(path, bytes)
|
|
32
|
+
return await work(path, dir)
|
|
33
|
+
} finally {
|
|
34
|
+
rmSync(dir, { recursive: true, force: true })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sourceType = (enc, info) => {
|
|
39
|
+
const container = enc.ext === 'webm' ? 'video/webm' : 'video/mp4'
|
|
40
|
+
return `${container}; codecs="${[info.video?.string, info.audio?.string].filter(Boolean).join(', ')}"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** SSIMULACRA2 over `times`: mean ≥ target and worst ≥ target − 5. */
|
|
44
|
+
async function passes(enc, source, encoded, ctx) {
|
|
45
|
+
return onDisk(`x.${enc.ext}`, encoded, async (file, dir) => {
|
|
46
|
+
const scores = []
|
|
47
|
+
for (const [i, at] of ctx.times.entries()) {
|
|
48
|
+
const ref = join(dir, `ref${i}.png`)
|
|
49
|
+
const out = join(dir, `out${i}.png`)
|
|
50
|
+
writeFileSync(ref, await enc.still(source, { at, width: ctx.width }))
|
|
51
|
+
writeFileSync(out, await enc.still(file, { at, width: ctx.width }))
|
|
52
|
+
scores.push(parseFloat((await run(ctx.scorer, [ref, out])).stdout))
|
|
53
|
+
}
|
|
54
|
+
return scores.reduce((a, b) => a + b) / scores.length >= ctx.target && Math.min(...scores) >= ctx.target - 5
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The highest CRF that passes, within 4 encodes. Video-only: audio has its own bitrate. */
|
|
59
|
+
async function search(enc, source, ctx) {
|
|
60
|
+
let { min: lo, max: hi } = enc.knob
|
|
61
|
+
let best = lo
|
|
62
|
+
for (let i = 0; i < 4 && lo <= hi; i++) {
|
|
63
|
+
const knob = Math.round((lo + hi) / 2)
|
|
64
|
+
if (await passes(enc, source, await enc.encode(source, { knob, width: ctx.width, audio: false }), ctx)) {
|
|
65
|
+
best = knob
|
|
66
|
+
lo = knob + 1
|
|
67
|
+
} else hi = knob - 1
|
|
68
|
+
}
|
|
69
|
+
return best
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One codec at every width, biggest last. */
|
|
73
|
+
async function rungs(enc, stem, codec, source, widths, knob, { keepTop, audio, loopVideo }) {
|
|
74
|
+
const out = []
|
|
75
|
+
for (const width of widths) {
|
|
76
|
+
const copy = keepTop && width === widths.at(-1)
|
|
77
|
+
const bytes = await enc.encode(source, copy ? { copy, audio } : { knob, width, audio, loopVideo })
|
|
78
|
+
out.push({ width, bytes, name: `${stem}-${width}-${codec}-${short(bytes)}.${enc.ext}` })
|
|
79
|
+
}
|
|
80
|
+
const info = await onDisk(`x.${enc.ext}`, out.at(-1).bytes, file => enc.info(file))
|
|
81
|
+
return { key: enc.key, type: sourceType(enc, info), rungs: out, size: out.at(-1).bytes.length }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function encodersFor(o, encode) {
|
|
85
|
+
const keys = [...new Set([...o.video.codecs.map(c => `video/${c}`), COMPATIBLE])]
|
|
86
|
+
const found = []
|
|
87
|
+
const diagnostics = []
|
|
88
|
+
for (const key of keys) {
|
|
89
|
+
const enc = encode[key]
|
|
90
|
+
if (enc && (await enc.encoderName())) found.push(enc)
|
|
91
|
+
else if (key === COMPATIBLE) return { error: enc ? null : need('Video', key), found, diagnostics }
|
|
92
|
+
else diagnostics.push({ code: 'M019', severity: 'warning', message: `No encoder for ${key}: it is left out of the ladder.` })
|
|
93
|
+
}
|
|
94
|
+
return { found, diagnostics }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The poster, its dominant colour and the placeholder, from one frame. */
|
|
98
|
+
export async function poster(enc, source, stem, width, duration, o) {
|
|
99
|
+
const png = await enc.still(source, { at: Math.min(1, duration / 10), width })
|
|
100
|
+
const bytes = await sharp(png).webp({ quality: 80 }).toBuffer()
|
|
101
|
+
const name = `${stem}-poster-${short(bytes)}.webp`
|
|
102
|
+
const asset = { poster: name }
|
|
103
|
+
if (o.placeholder) {
|
|
104
|
+
const stats = await sharp(png).stats()
|
|
105
|
+
asset.dominant = `#${['r', 'g', 'b'].map(c => stats.dominant[c].toString(16).padStart(2, '0')).join('')}`
|
|
106
|
+
asset.placeholder = `data:image/webp;base64,${(await sharp(png).resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()).toString('base64')}`
|
|
107
|
+
}
|
|
108
|
+
return { asset, file: { name, bytes: new Uint8Array(bytes) } }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const ladderOf = (o, width) => {
|
|
112
|
+
const top = Math.min(width, o.video.widths.at(-1))
|
|
113
|
+
return [...new Set([...o.video.widths.filter(w => w < top), top])]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function encodeLadder(stem, source, info, o, encode, { widths, keepTop, audio, loopVideo, scorer }) {
|
|
117
|
+
const { found, diagnostics, error } = await encodersFor(o, encode)
|
|
118
|
+
if (error) return { error }
|
|
119
|
+
const top = widths.at(-1)
|
|
120
|
+
const { duration } = info
|
|
121
|
+
const times = [0.05, 0.25, 0.45, 0.65, 0.85].map(f => Math.round(duration * f * 1000) / 1000)
|
|
122
|
+
const ctx = { times, width: top, scorer, target: o.quality.target }
|
|
123
|
+
const compatible = found.find(e => e.key === COMPATIBLE)
|
|
124
|
+
const qualities = {}
|
|
125
|
+
const sets = []
|
|
126
|
+
for (const enc of [compatible, ...found.filter(e => e !== compatible)]) {
|
|
127
|
+
const kept = keepTop && enc === compatible
|
|
128
|
+
const knob = scorer && !kept ? await search(enc, source, ctx) : enc.knob.fixed
|
|
129
|
+
qualities[enc.key.slice(6)] = knob
|
|
130
|
+
sets.push(await rungs(enc, stem, enc.key.slice(6), source, widths, knob, { keepTop: kept, audio, loopVideo }))
|
|
131
|
+
}
|
|
132
|
+
return { sets, qualities, diagnostics }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
/** Efficient sets first, H.264 last, dropping any that doesn't beat H.264 by 15%. */
|
|
137
|
+
function chosen(sets) {
|
|
138
|
+
const [compatible, ...rest] = sets
|
|
139
|
+
const winners = rest.filter(s => s.size <= compatible.size * SMALLER).sort((a, b) => a.size - b.size)
|
|
140
|
+
return [...winners, compatible]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function finish(asset, sets, cover, diagnostics) {
|
|
144
|
+
const files = sets.flatMap(s => s.rungs.map(r => ({ name: r.name, bytes: r.bytes })))
|
|
145
|
+
if (cover) files.push(cover.file)
|
|
146
|
+
const compatible = sets.at(-1)
|
|
147
|
+
const top = compatible.rungs.at(-1)
|
|
148
|
+
const out = { ...asset, ...cover?.asset, src: top.name, sources: sets.map(s => ({ type: s.type, srcset: s.rungs.map(r => `${r.name} ${r.width}w`).join(', ') })) }
|
|
149
|
+
return { asset: out, files, diagnostics }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The compatible copy `encoder: 'compatible'` swaps in, and M014 with what it adds. */
|
|
153
|
+
function withCopy(out, o, name, copy) {
|
|
154
|
+
if (o.encoder !== 'compatible' || !copy) return out
|
|
155
|
+
const efficient = out.files.filter(f => f.name !== copy.src && f.name.endsWith('.webm')).at(-1)?.bytes.length ?? copy.bytes
|
|
156
|
+
out.asset.copy = { src: copy.src, type: copy.type, bytes: copy.bytes, ratio: Math.round((copy.bytes / efficient) * 100) / 100 }
|
|
157
|
+
out.asset.swap = o.swap ?? 'intent'
|
|
158
|
+
out.diagnostics.push({ code: 'M014', severity: 'info', message: `${name}: \`encoder: 'compatible'\` adds ${Math.round((copy.added ?? 0) / 1024)} KB for the compatible copy.` })
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function video({ bytes, name, stem, audio }, o, encode, scorer) {
|
|
163
|
+
const probe = encode[COMPATIBLE]
|
|
164
|
+
if (!probe) return { asset: null, files: [], diagnostics: [need(name, COMPATIBLE)] }
|
|
165
|
+
try {
|
|
166
|
+
return await onDisk(name, bytes, async source => {
|
|
167
|
+
const info = await probe.info(source)
|
|
168
|
+
if (!info.video) return { asset: null, files: [], diagnostics: [{ code: 'M018', severity: 'error', message: `${name} has no video track.` }] }
|
|
169
|
+
const { width, height, hdr, codec, pix } = info.video
|
|
170
|
+
const widths = ladderOf(o, width)
|
|
171
|
+
const keepTop = !audio && codec === 'h264' && pix === 'yuv420p' && widths.at(-1) === width && (!info.audio || info.audio.codec === 'aac')
|
|
172
|
+
const built = await encodeLadder(stem, source, info, o, encode, { widths, keepTop, audio, scorer })
|
|
173
|
+
if (built.error) return { asset: null, files: [], diagnostics: [built.error] }
|
|
174
|
+
const sets = chosen(built.sets)
|
|
175
|
+
const cover = await poster(probe, source, stem, widths.at(-1), info.duration, o)
|
|
176
|
+
const diagnostics = built.diagnostics
|
|
177
|
+
if (hdr) diagnostics.push({ code: 'M015', severity: 'warning', message: `${name} is HDR or over 8 bits, and comes out 8-bit SDR.` })
|
|
178
|
+
const asset = { kind: 'video', width, height, type: 'video/mp4', name, quality: scorer ? 'ssimulacra2' : 'fixed', qualities: built.qualities, duration: info.duration, audio: !!(audio || info.audio) }
|
|
179
|
+
const out = finish(asset, sets, cover, diagnostics)
|
|
180
|
+
const top = sets.at(-1).rungs.at(-1)
|
|
181
|
+
return withCopy(out, o, name, sets.length > 1 ? { src: top.name, type: 'video/mp4', bytes: top.bytes.length, added: 0 } : null)
|
|
182
|
+
})
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return errored(name, e)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function audioFile({ bytes, name, stem }, o, encode) {
|
|
189
|
+
const opus = encode['audio/opus']
|
|
190
|
+
const aac = encode['audio/aac']
|
|
191
|
+
if (!aac) return { asset: null, files: [], diagnostics: [need(name, 'audio/aac')] }
|
|
192
|
+
try {
|
|
193
|
+
return await onDisk(name, bytes, async source => {
|
|
194
|
+
const info = await aac.info(source)
|
|
195
|
+
if (!info.audio) return { asset: null, files: [], diagnostics: [{ code: 'M018', severity: 'error', message: `${name} has no audio track.` }] }
|
|
196
|
+
const under = limit => !info.audio.bitrate || info.audio.bitrate <= limit
|
|
197
|
+
const sets = []
|
|
198
|
+
for (const [enc, codec, limit] of [[opus, 'opus', 96_000], [aac, 'aac', 128_000]]) {
|
|
199
|
+
if (!enc || !(await enc.encoderName())) continue
|
|
200
|
+
const encoded = await enc.encode(source, { copy: info.audio.codec === codec && under(limit) })
|
|
201
|
+
const type = `${enc.ext === 'webm' ? 'audio/webm' : 'audio/mp4'}; codecs="${(await onDisk(`x.${enc.ext}`, encoded, f => enc.info(f))).audio.string}"`
|
|
202
|
+
sets.push({ type, name: `${stem}-${short(encoded)}.${enc.ext}`, bytes: encoded })
|
|
203
|
+
}
|
|
204
|
+
const last = sets.at(-1)
|
|
205
|
+
return {
|
|
206
|
+
asset: { kind: 'audio', src: last.name, width: 0, height: 0, type: 'audio/mp4', name, quality: 'fixed', duration: info.duration, audio: true, sources: sets.map(s => ({ type: s.type, srcset: `${s.name} 1w` })) },
|
|
207
|
+
files: sets.map(s => ({ name: s.name, bytes: s.bytes })),
|
|
208
|
+
diagnostics: [],
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
} catch (e) {
|
|
212
|
+
return errored(name, e)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Animated GIF: video when that's a real saving, animated WebP when it has transparency, else null (it stays a GIF). */
|
|
217
|
+
export async function gif({ bytes, name, stem, audio, meta, width, height }, o, encode, scorer) {
|
|
218
|
+
const frames = meta.pages ?? 1
|
|
219
|
+
const toVideo = o.gif?.toVideo ?? 'auto'
|
|
220
|
+
if (toVideo === 'never' || !encode[COMPATIBLE]) return null
|
|
221
|
+
const keep = o.gif?.keepUnder ?? 8192
|
|
222
|
+
if (!audio && (bytes.length < keep || frames < 4)) return null
|
|
223
|
+
const opaque = (await sharp(bytes, { animated: true }).stats()).isOpaque
|
|
224
|
+
if (!opaque) return audio ? null : alphaWebp({ bytes, name, stem, width, height })
|
|
225
|
+
try {
|
|
226
|
+
return await onDisk(name, bytes, async source => {
|
|
227
|
+
const probe = encode[COMPATIBLE]
|
|
228
|
+
const info = await probe.info(source)
|
|
229
|
+
const delays = meta.delay ?? []
|
|
230
|
+
const total = delays.length ? delays.reduce((a, b) => a + (b < 20 ? 100 : b), 0) / 1000 : info.duration
|
|
231
|
+
const widths = ladderOf(o, width)
|
|
232
|
+
const built = await encodeLadder(stem, source, { ...info, duration: total }, o, encode, { widths, audio, loopVideo: !!audio, scorer })
|
|
233
|
+
if (built.error) return null
|
|
234
|
+
const sets = chosen(built.sets)
|
|
235
|
+
const smallest = Math.min(...sets.map(s => s.size))
|
|
236
|
+
if (!audio && smallest > bytes.length * SMALLER) return null
|
|
237
|
+
if (!audio) {
|
|
238
|
+
const check = await onDisk(`x.${sets[0].rungs.at(-1).name.split('.').pop()}`, sets[0].rungs.at(-1).bytes, f => probe.info(f))
|
|
239
|
+
if (Math.abs(check.duration - total) > Math.max(0.15, total * 0.05)) built.diagnostics.push({ code: 'M019', severity: 'warning', message: `${name}: the video runs ${check.duration} s, the GIF ${total} s.` })
|
|
240
|
+
}
|
|
241
|
+
const [winner, compatible] = sets.length > 1 ? [sets[0], sets.at(-1)] : [null, sets[0]]
|
|
242
|
+
const served = winner ? [winner, compatible] : [compatible]
|
|
243
|
+
const cover = await poster(probe, source, stem, widths.at(-1), total, o)
|
|
244
|
+
const asset = { kind: 'video', width, height, type: 'video/mp4', name, quality: scorer ? 'ssimulacra2' : 'fixed', qualities: built.qualities, duration: total, audio: !!audio, animated: !audio, loop: meta.loop ?? 0 }
|
|
245
|
+
const out = finish(asset, served, cover, built.diagnostics)
|
|
246
|
+
if (o.encoder === 'compatible') {
|
|
247
|
+
const original = `${stem}-${short(bytes)}.gif`
|
|
248
|
+
out.files.push({ name: original, bytes })
|
|
249
|
+
return withCopy(out, o, name, { src: original, type: 'image/gif', bytes: bytes.length, added: bytes.length })
|
|
250
|
+
}
|
|
251
|
+
return out
|
|
252
|
+
})
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return errored(name, e)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function alphaWebp({ bytes, name, stem, width, height }) {
|
|
259
|
+
const webp = await sharp(bytes, { animated: true }).webp({ quality: 80, effort: 5 }).toBuffer()
|
|
260
|
+
if (webp.length > bytes.length * SMALLER) return null
|
|
261
|
+
const fileName = `${stem}-${short(webp)}.webp`
|
|
262
|
+
const original = `${stem}-${short(bytes)}.gif`
|
|
263
|
+
return {
|
|
264
|
+
asset: { kind: 'graphic', src: original, width, height, type: 'image/gif', sources: [{ type: 'image/webp', srcset: `${fileName} ${width}w` }, { type: 'image/gif', srcset: `${original} ${width}w` }], name, quality: 'fixed', loop: 0 },
|
|
265
|
+
files: [{ name: fileName, bytes: new Uint8Array(webp) }, { name: original, bytes }],
|
|
266
|
+
diagnostics: [],
|
|
267
|
+
}
|
|
268
|
+
}
|