@weft-js/plugin-media 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/video.mjs CHANGED
@@ -7,34 +7,19 @@
7
7
  // GIFs stay GIFs under 8 KB, under 4 frames, with real transparency (H.264 has no alpha; animated WebP does), or when
8
8
  // nothing is 15% smaller. Noisy GIFs came out 70–180% larger as video.
9
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'
10
+ import { writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
14
12
  import { promisify } from 'node:util'
15
13
  import sharp from 'sharp'
14
+ import { errored, need, onDisk, short } from './video/common.mjs'
15
+
16
+ export { audioFile } from './video/audio.mjs'
16
17
 
17
18
  const run = promisify(execFile)
18
19
  export const VIDEO = /\.(mp4|m4v|mov|webm|mkv|ogv)$/i
19
20
  export const AUDIO = /\.(mp3|wav|flac|ogg|oga|opus|m4a|aac)$/i
20
21
  const SMALLER = 0.85
21
22
  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
23
  const sourceType = (enc, info) => {
39
24
  const container = enc.ext === 'webm' ? 'video/webm' : 'video/mp4'
40
25
  return `${container}; codecs="${[info.video?.string, info.audio?.string].filter(Boolean).join(', ')}"`
@@ -51,7 +36,10 @@ async function passes(enc, source, encoded, ctx) {
51
36
  writeFileSync(out, await enc.still(file, { at, width: ctx.width }))
52
37
  scores.push(parseFloat((await run(ctx.scorer, [ref, out])).stdout))
53
38
  }
54
- return scores.reduce((a, b) => a + b) / scores.length >= ctx.target && Math.min(...scores) >= ctx.target - 5
39
+ return (
40
+ scores.reduce((a, b) => a + b) / scores.length >= ctx.target &&
41
+ Math.min(...scores) >= ctx.target - 5
42
+ )
55
43
  })
56
44
  }
57
45
 
@@ -61,7 +49,14 @@ async function search(enc, source, ctx) {
61
49
  let best = lo
62
50
  for (let i = 0; i < 4 && lo <= hi; i++) {
63
51
  const knob = Math.round((lo + hi) / 2)
64
- if (await passes(enc, source, await enc.encode(source, { knob, width: ctx.width, audio: false }), ctx)) {
52
+ if (
53
+ await passes(
54
+ enc,
55
+ source,
56
+ await enc.encode(source, { knob, width: ctx.width, audio: false }),
57
+ ctx,
58
+ )
59
+ ) {
65
60
  best = knob
66
61
  lo = knob + 1
67
62
  } else hi = knob - 1
@@ -70,39 +65,48 @@ async function search(enc, source, ctx) {
70
65
  }
71
66
 
72
67
  /** One codec at every width, biggest last. */
73
- async function rungs(enc, stem, codec, source, widths, knob, { keepTop, audio, loopVideo }) {
68
+ async function rungs({ enc, stem, codec, source, widths, knob, keepTop, audio, loopVideo }) {
74
69
  const out = []
75
70
  for (const width of widths) {
76
71
  const copy = keepTop && width === widths.at(-1)
77
- const bytes = await enc.encode(source, copy ? { copy, audio } : { knob, width, audio, loopVideo })
72
+ const bytes = await enc.encode(
73
+ source,
74
+ copy ? { copy, audio } : { knob, width, audio, loopVideo },
75
+ )
78
76
  out.push({ width, bytes, name: `${stem}-${width}-${codec}-${short(bytes)}.${enc.ext}` })
79
77
  }
80
- const info = await onDisk(`x.${enc.ext}`, out.at(-1).bytes, file => enc.info(file))
78
+ const info = await onDisk(`x.${enc.ext}`, out.at(-1).bytes, (file) => enc.info(file))
81
79
  return { key: enc.key, type: sourceType(enc, info), rungs: out, size: out.at(-1).bytes.length }
82
80
  }
83
81
 
84
82
  async function encodersFor(o, encode) {
85
- const keys = [...new Set([...o.video.codecs.map(c => `video/${c}`), COMPATIBLE])]
83
+ const keys = [...new Set([...o.video.codecs.map((c) => `video/${c}`), COMPATIBLE])]
86
84
  const found = []
87
85
  const diagnostics = []
88
86
  for (const key of keys) {
89
87
  const enc = encode[key]
90
88
  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.` })
89
+ else if (key === COMPATIBLE)
90
+ return { error: enc ? null : need('Video', key), found, diagnostics }
91
+ else
92
+ diagnostics.push({
93
+ code: 'M019',
94
+ severity: 'warning',
95
+ message: `No encoder for ${key}: it is left out of the ladder.`,
96
+ })
93
97
  }
94
98
  return { found, diagnostics }
95
99
  }
96
100
 
97
101
  /** The poster, its dominant colour and the placeholder, from one frame. */
98
- export async function poster(enc, source, stem, width, duration, o) {
102
+ export async function poster({ enc, source, stem, width, duration, o }) {
99
103
  const png = await enc.still(source, { at: Math.min(1, duration / 10), width })
100
104
  const bytes = await sharp(png).webp({ quality: 80 }).toBuffer()
101
105
  const name = `${stem}-poster-${short(bytes)}.webp`
102
106
  const asset = { poster: name }
103
107
  if (o.placeholder) {
104
108
  const stats = await sharp(png).stats()
105
- asset.dominant = `#${['r', 'g', 'b'].map(c => stats.dominant[c].toString(16).padStart(2, '0')).join('')}`
109
+ asset.dominant = `#${['r', 'g', 'b'].map((c) => stats.dominant[c].toString(16).padStart(2, '0')).join('')}`
106
110
  asset.placeholder = `data:image/webp;base64,${(await sharp(png).resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()).toString('base64')}`
107
111
  }
108
112
  return { asset, file: { name, bytes: new Uint8Array(bytes) } }
@@ -110,52 +114,95 @@ export async function poster(enc, source, stem, width, duration, o) {
110
114
 
111
115
  export const ladderOf = (o, width) => {
112
116
  const top = Math.min(width, o.video.widths.at(-1))
113
- return [...new Set([...o.video.widths.filter(w => w < top), top])]
117
+ return [...new Set([...o.video.widths.filter((w) => w < top), top])]
114
118
  }
115
119
 
116
- async function encodeLadder(stem, source, info, o, encode, { widths, keepTop, audio, loopVideo, scorer }) {
120
+ async function encodeLadder({
121
+ stem,
122
+ source,
123
+ info,
124
+ o,
125
+ encode,
126
+ widths,
127
+ keepTop,
128
+ audio,
129
+ loopVideo,
130
+ scorer,
131
+ }) {
117
132
  const { found, diagnostics, error } = await encodersFor(o, encode)
118
133
  if (error) return { error }
119
134
  const top = widths.at(-1)
120
135
  const { duration } = info
121
- const times = [0.05, 0.25, 0.45, 0.65, 0.85].map(f => Math.round(duration * f * 1000) / 1000)
136
+ const times = [0.05, 0.25, 0.45, 0.65, 0.85].map((f) => Math.round(duration * f * 1000) / 1000)
122
137
  const ctx = { times, width: top, scorer, target: o.quality.target }
123
- const compatible = found.find(e => e.key === COMPATIBLE)
138
+ const compatible = found.find((e) => e.key === COMPATIBLE)
124
139
  const qualities = {}
125
140
  const sets = []
126
- for (const enc of [compatible, ...found.filter(e => e !== compatible)]) {
141
+ for (const enc of [compatible, ...found.filter((e) => e !== compatible)]) {
127
142
  const kept = keepTop && enc === compatible
128
143
  const knob = scorer && !kept ? await search(enc, source, ctx) : enc.knob.fixed
129
144
  qualities[enc.key.slice(6)] = knob
130
- sets.push(await rungs(enc, stem, enc.key.slice(6), source, widths, knob, { keepTop: kept, audio, loopVideo }))
145
+ sets.push(
146
+ await rungs({
147
+ enc,
148
+ stem,
149
+ codec: enc.key.slice(6),
150
+ source,
151
+ widths,
152
+ knob,
153
+ keepTop: kept,
154
+ audio,
155
+ loopVideo,
156
+ }),
157
+ )
131
158
  }
132
159
  return { sets, qualities, diagnostics }
133
160
  }
134
161
 
135
-
136
162
  /** Efficient sets first, H.264 last, dropping any that doesn't beat H.264 by 15%. */
137
163
  function chosen(sets) {
138
164
  const [compatible, ...rest] = sets
139
- const winners = rest.filter(s => s.size <= compatible.size * SMALLER).sort((a, b) => a.size - b.size)
165
+ const winners = rest
166
+ .filter((s) => s.size <= compatible.size * SMALLER)
167
+ .sort((a, b) => a.size - b.size)
140
168
  return [...winners, compatible]
141
169
  }
142
170
 
143
171
  function finish(asset, sets, cover, diagnostics) {
144
- const files = sets.flatMap(s => s.rungs.map(r => ({ name: r.name, bytes: r.bytes })))
172
+ const files = sets.flatMap((s) => s.rungs.map((r) => ({ name: r.name, bytes: r.bytes })))
145
173
  if (cover) files.push(cover.file)
146
174
  const compatible = sets.at(-1)
147
175
  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(', ') })) }
176
+ const out = {
177
+ ...asset,
178
+ ...cover?.asset,
179
+ src: top.name,
180
+ sources: sets.map((s) => ({
181
+ type: s.type,
182
+ srcset: s.rungs.map((r) => `${r.name} ${r.width}w`).join(', '),
183
+ })),
184
+ }
149
185
  return { asset: out, files, diagnostics }
150
186
  }
151
187
 
152
188
  /** The compatible copy `encoder: 'compatible'` swaps in, and M014 with what it adds. */
153
189
  function withCopy(out, o, name, copy) {
154
190
  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 }
191
+ const efficient =
192
+ out.files.filter((f) => f.name !== copy.src && f.name.endsWith('.webm')).at(-1)?.bytes.length ??
193
+ copy.bytes
194
+ out.asset.copy = {
195
+ src: copy.src,
196
+ type: copy.type,
197
+ bytes: copy.bytes,
198
+ ratio: Math.round((copy.bytes / efficient) * 100) / 100,
199
+ }
157
200
  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.` })
201
+ out.diagnostics.push({
202
+ code: 'M014',
203
+ severity: 'info',
204
+ message: `${name}: \`encoder: 'compatible'\` adds ${Math.round((copy.added ?? 0) / 1024)} KB for the compatible copy.`,
205
+ })
159
206
  return out
160
207
  }
161
208
 
@@ -163,50 +210,73 @@ export async function video({ bytes, name, stem, audio }, o, encode, scorer) {
163
210
  const probe = encode[COMPATIBLE]
164
211
  if (!probe) return { asset: null, files: [], diagnostics: [need(name, COMPATIBLE)] }
165
212
  try {
166
- return await onDisk(name, bytes, async source => {
213
+ return await onDisk(name, bytes, async (source) => {
167
214
  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.` }] }
215
+ if (!info.video)
216
+ return {
217
+ asset: null,
218
+ files: [],
219
+ diagnostics: [
220
+ { code: 'M018', severity: 'error', message: `${name} has no video track.` },
221
+ ],
222
+ }
169
223
  const { width, height, hdr, codec, pix } = info.video
170
224
  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 })
225
+ const keepTop =
226
+ !audio &&
227
+ codec === 'h264' &&
228
+ pix === 'yuv420p' &&
229
+ widths.at(-1) === width &&
230
+ (!info.audio || info.audio.codec === 'aac')
231
+ const built = await encodeLadder({
232
+ stem,
233
+ source,
234
+ info,
235
+ o,
236
+ encode,
237
+ widths,
238
+ keepTop,
239
+ audio,
240
+ scorer,
241
+ })
173
242
  if (built.error) return { asset: null, files: [], diagnostics: [built.error] }
174
243
  const sets = chosen(built.sets)
175
- const cover = await poster(probe, source, stem, widths.at(-1), info.duration, o)
244
+ const cover = await poster({
245
+ enc: probe,
246
+ source,
247
+ stem,
248
+ width: widths.at(-1),
249
+ duration: info.duration,
250
+ o,
251
+ })
176
252
  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) }
253
+ if (hdr)
254
+ diagnostics.push({
255
+ code: 'M015',
256
+ severity: 'warning',
257
+ message: `${name} is HDR or over 8 bits, and comes out 8-bit SDR.`,
258
+ })
259
+ const asset = {
260
+ kind: 'video',
261
+ width,
262
+ height,
263
+ type: 'video/mp4',
264
+ name,
265
+ quality: scorer ? 'ssimulacra2' : 'fixed',
266
+ qualities: built.qualities,
267
+ duration: info.duration,
268
+ audio: !!(audio || info.audio),
269
+ }
179
270
  const out = finish(asset, sets, cover, diagnostics)
180
271
  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
- }
272
+ return withCopy(
273
+ out,
274
+ o,
275
+ name,
276
+ sets.length > 1
277
+ ? { src: top.name, type: 'video/mp4', bytes: top.bytes.length, added: 0 }
278
+ : null,
279
+ )
210
280
  })
211
281
  } catch (e) {
212
282
  return errored(name, e)
@@ -223,30 +293,63 @@ export async function gif({ bytes, name, stem, audio, meta, width, height }, o,
223
293
  const opaque = (await sharp(bytes, { animated: true }).stats()).isOpaque
224
294
  if (!opaque) return audio ? null : alphaWebp({ bytes, name, stem, width, height })
225
295
  try {
226
- return await onDisk(name, bytes, async source => {
296
+ return await onDisk(name, bytes, async (source) => {
227
297
  const probe = encode[COMPATIBLE]
228
298
  const info = await probe.info(source)
229
299
  const delays = meta.delay ?? []
230
- const total = delays.length ? delays.reduce((a, b) => a + (b < 20 ? 100 : b), 0) / 1000 : info.duration
300
+ const total = delays.length
301
+ ? delays.reduce((a, b) => a + (b < 20 ? 100 : b), 0) / 1000
302
+ : info.duration
231
303
  const widths = ladderOf(o, width)
232
- const built = await encodeLadder(stem, source, { ...info, duration: total }, o, encode, { widths, audio, loopVideo: !!audio, scorer })
304
+ const built = await encodeLadder({
305
+ stem,
306
+ source,
307
+ info: { ...info, duration: total },
308
+ o,
309
+ encode,
310
+ widths,
311
+ audio,
312
+ loopVideo: !!audio,
313
+ scorer,
314
+ })
233
315
  if (built.error) return null
234
316
  const sets = chosen(built.sets)
235
- const smallest = Math.min(...sets.map(s => s.size))
317
+ const smallest = Math.min(...sets.map((s) => s.size))
236
318
  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
- }
319
+ if (!audio) await checkRuntime(probe, sets, total, name, built.diagnostics)
241
320
  const [winner, compatible] = sets.length > 1 ? [sets[0], sets.at(-1)] : [null, sets[0]]
242
321
  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 }
322
+ const cover = await poster({
323
+ enc: probe,
324
+ source,
325
+ stem,
326
+ width: widths.at(-1),
327
+ duration: total,
328
+ o,
329
+ })
330
+ const asset = {
331
+ kind: 'video',
332
+ width,
333
+ height,
334
+ type: 'video/mp4',
335
+ name,
336
+ quality: scorer ? 'ssimulacra2' : 'fixed',
337
+ qualities: built.qualities,
338
+ duration: total,
339
+ audio: !!audio,
340
+ animated: !audio,
341
+ loop: meta.loop ?? 0,
342
+ }
245
343
  const out = finish(asset, served, cover, built.diagnostics)
246
344
  if (o.encoder === 'compatible') {
247
345
  const original = `${stem}-${short(bytes)}.gif`
248
346
  out.files.push({ name: original, bytes })
249
- return withCopy(out, o, name, { src: original, type: 'image/gif', bytes: bytes.length, added: bytes.length })
347
+ return withCopy(out, o, name, {
348
+ src: original,
349
+ type: 'image/gif',
350
+ bytes: bytes.length,
351
+ added: bytes.length,
352
+ })
250
353
  }
251
354
  return out
252
355
  })
@@ -255,14 +358,41 @@ export async function gif({ bytes, name, stem, audio, meta, width, height }, o,
255
358
  }
256
359
  }
257
360
 
361
+ async function checkRuntime(probe, sets, total, name, diagnostics) {
362
+ const top = sets[0].rungs.at(-1)
363
+ const check = await onDisk(`x.${top.name.split('.').pop()}`, top.bytes, (f) => probe.info(f))
364
+ if (Math.abs(check.duration - total) > Math.max(0.15, total * 0.05))
365
+ diagnostics.push({
366
+ code: 'M019',
367
+ severity: 'warning',
368
+ message: `${name}: the video runs ${check.duration} s, the GIF ${total} s.`,
369
+ })
370
+ }
371
+
258
372
  async function alphaWebp({ bytes, name, stem, width, height }) {
259
373
  const webp = await sharp(bytes, { animated: true }).webp({ quality: 80, effort: 5 }).toBuffer()
260
374
  if (webp.length > bytes.length * SMALLER) return null
261
375
  const fileName = `${stem}-${short(webp)}.webp`
262
376
  const original = `${stem}-${short(bytes)}.gif`
263
377
  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 }],
378
+ asset: {
379
+ kind: 'graphic',
380
+ src: original,
381
+ width,
382
+ height,
383
+ type: 'image/gif',
384
+ sources: [
385
+ { type: 'image/webp', srcset: `${fileName} ${width}w` },
386
+ { type: 'image/gif', srcset: `${original} ${width}w` },
387
+ ],
388
+ name,
389
+ quality: 'fixed',
390
+ loop: 0,
391
+ },
392
+ files: [
393
+ { name: fileName, bytes: new Uint8Array(webp) },
394
+ { name: original, bytes },
395
+ ],
266
396
  diagnostics: [],
267
397
  }
268
398
  }