@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 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
- # Temporary Holding Version
1
+ # @weft-js/plugin-media
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }