@weft-js/plugin-media 0.1.1 → 0.1.4

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