@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.
@@ -0,0 +1,10 @@
1
+ export function createRecord(url) {
2
+ return (hash, rungs, types) =>
3
+ [...new Set(rungs.map((r) => r[1]))].map((format) => ({
4
+ type: types[format],
5
+ srcset: rungs
6
+ .filter((r) => r[1] === format)
7
+ .map(([w]) => `${url(hash, w, format)} ${w || 1}w`)
8
+ .join(', '),
9
+ }))
10
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export function createRung(dir, encode, sourceOf, flight, COMPATIBLE) {
5
+ /** One rung, encoded once: the queue and a waiting request share the work, and the file lands whole or not at all. */
6
+ return function rung(hash, meta, width, format) {
7
+ const file = join(dir, hash, `${width}.${format}`)
8
+ if (existsSync(file)) return readFileSync(file)
9
+ const key = `${hash}/${width}.${format}`
10
+ if (!flight.has(key)) {
11
+ const work = (async () => {
12
+ const audio = meta.kind === 'audio'
13
+ const enc =
14
+ encode[
15
+ audio
16
+ ? format === 'webm'
17
+ ? 'audio/opus'
18
+ : 'audio/aac'
19
+ : format === 'webm'
20
+ ? meta.webm
21
+ : COMPATIBLE
22
+ ]
23
+ const copy = meta.keepTop === width && format === 'mp4'
24
+ const bytes = await enc.encode(
25
+ sourceOf(hash),
26
+ audio ? {} : copy ? { copy } : { width, knob: enc.knob.fixed },
27
+ )
28
+ const tmp = `${file}.${process.pid}.tmp`
29
+ writeFileSync(tmp, bytes)
30
+ renameSync(tmp, file)
31
+ return bytes
32
+ })().finally(() => flight.delete(key))
33
+ flight.set(key, work)
34
+ }
35
+ return flight.get(key)
36
+ }
37
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export function createServe(dir, sourceOf, rung, TYPES) {
5
+ return async function serve(hash, meta, width, format) {
6
+ if (format === 'webp' && width === 0)
7
+ return {
8
+ bytes: readFileSync(join(dir, hash, 'poster.webp')),
9
+ type: TYPES.webp,
10
+ width,
11
+ format,
12
+ negotiated: false,
13
+ }
14
+ if (format === 'gif' && meta.gif === width)
15
+ return {
16
+ bytes: readFileSync(sourceOf(hash)),
17
+ type: TYPES.gif,
18
+ width,
19
+ format,
20
+ negotiated: false,
21
+ }
22
+ if (!meta.rungs.some(([w, f]) => w === width && f === format)) return null
23
+ const ready = existsSync(join(dir, hash, `${width}.${format}`))
24
+ if (!ready && meta.playable)
25
+ return {
26
+ bytes: readFileSync(sourceOf(hash)),
27
+ type: TYPES[format],
28
+ width,
29
+ format,
30
+ negotiated: false,
31
+ temporary: true,
32
+ }
33
+ return {
34
+ bytes: new Uint8Array(await rung(hash, meta, width, format)),
35
+ type: meta.kind === 'audio' && format === 'webm' ? 'audio/webm' : TYPES[format],
36
+ width,
37
+ format,
38
+ negotiated: false,
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,54 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { poster } from '../video.mjs'
4
+
5
+ export function createVideo({
6
+ videoRungs,
7
+ encode,
8
+ sourceOf,
9
+ dir,
10
+ url,
11
+ compatible,
12
+ record,
13
+ hasCopy,
14
+ playable,
15
+ COMPATIBLE,
16
+ o,
17
+ }) {
18
+ return async function video(hash, bytes, name, info, extra = {}) {
19
+ const { width, height } = info.video
20
+ const plan = videoRungs(info, width)
21
+ const types = {
22
+ webm: `video/webm; codecs="${plan.webm === 'video/vp9' ? 'vp9' : 'av01.0.08M.08'}${info.audio ? ', opus' : ''}"`,
23
+ mp4: 'video/mp4',
24
+ }
25
+ const cover = await poster({
26
+ enc: encode[COMPATIBLE],
27
+ source: sourceOf(hash),
28
+ stem: 'poster',
29
+ width,
30
+ duration: info.duration,
31
+ o,
32
+ })
33
+ writeFileSync(join(dir, hash, 'poster.webp'), cover.file.bytes)
34
+ const top = url(hash, plan.rungs.at(-1)[0], 'mp4')
35
+ const asset = compatible({
36
+ kind: 'video',
37
+ src: top,
38
+ width,
39
+ height,
40
+ type: 'video/mp4',
41
+ sources: record(hash, plan.rungs, types),
42
+ name,
43
+ quality: 'fixed',
44
+ duration: info.duration,
45
+ audio: !!info.audio,
46
+ ...cover.asset,
47
+ poster: url(hash, 0, 'webp'),
48
+ ...(plan.webm && hasCopy() && { copy: { src: top, type: 'video/mp4' } }),
49
+ ...extra.asset,
50
+ })
51
+ const meta = { kind: 'video', timed: true, ...plan, playable: playable(info) }
52
+ return { asset, meta }
53
+ }
54
+ }
package/src/upload.mjs CHANGED
@@ -3,12 +3,22 @@
3
3
  // Until a rung exists its URL gives the original if browsers can play that, marked temporary so nothing caches it, and
4
4
  // otherwise waits for that encode. A queue lost to a restart costs nothing: the first request for a rung encodes it.
5
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
6
  import { join } from 'node:path'
8
- import sharp from 'sharp'
9
- import { ladderOf, poster } from './video.mjs'
7
+ import { ladderOf } from './video.mjs'
8
+ import { createIngest } from './upload/ingest.mjs'
9
+ import { createRecord } from './upload/record.mjs'
10
+ import { createRung } from './upload/rung.mjs'
11
+ import { createVideo } from './upload/video.mjs'
12
+ import { createGif } from './upload/gif.mjs'
13
+ import { createServe } from './upload/serve.mjs'
10
14
 
11
- const TYPES = { mp4: 'video/mp4', webm: 'video/webm', m4a: 'audio/mp4', gif: 'image/gif', webp: 'image/webp' }
15
+ const TYPES = {
16
+ mp4: 'video/mp4',
17
+ webm: 'video/webm',
18
+ m4a: 'audio/mp4',
19
+ gif: 'image/gif',
20
+ webp: 'image/webp',
21
+ }
12
22
  const COMPATIBLE = 'video/h264'
13
23
  const SMALLER = 0.85
14
24
 
@@ -18,116 +28,96 @@ export function sniff(bytes) {
18
28
  if (head.slice(4, 8) === 'ftyp') return /^M4A|^M4B/.test(head.slice(8, 12)) ? 'audio' : 'video'
19
29
  if (bytes[0] === 0x1a && bytes[1] === 0x45) return 'video'
20
30
  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'
31
+ if (
32
+ head.startsWith('OggS') ||
33
+ head.startsWith('fLaC') ||
34
+ head.startsWith('ID3') ||
35
+ (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0)
36
+ )
37
+ return 'audio'
22
38
  return null
23
39
  }
24
40
 
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))
41
+ const webKept = (info) =>
42
+ info.video?.codec === 'h264' &&
43
+ info.video.pix === 'yuv420p' &&
44
+ (!info.audio || info.audio.codec === 'aac')
45
+ const playable = (info) =>
46
+ webKept(info) || (/webm/.test(info.format) && ['vp9', 'av1'].includes(info.video?.codec))
27
47
 
28
48
  export function uploads({ o, dir, encode, url, store, refuse }) {
29
49
  const flight = new Map()
30
50
  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')
51
+ const efficient = () => (o.video.codecs ?? []).map((c) => `video/${c}`).find((k) => encode[k])
52
+ const sourceOf = (hash) => join(dir, hash, 'source')
33
53
 
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
+ const rung = createRung(dir, encode, sourceOf, flight, COMPATIBLE)
54
55
 
55
56
  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
+ for (const [width, format] of meta.rungs)
58
+ line = line
59
+ .then(() => rung(hash, meta, width, format))
60
+ .catch((e) => console.error(`media: ${hash}: ${e.message}`))
57
61
  }
58
62
 
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(', ') }))
63
+ const record = createRecord(url)
60
64
 
61
65
  /** Rungs for a video: AV1 or VP9 first when there's something to gain, H.264 always. */
62
66
  function videoRungs(info, width) {
63
67
  const widths = ladderOf(o, width)
64
68
  const keepTop = webKept(info) && widths.at(-1) === width ? width : undefined
65
69
  const webm = keepTop === undefined ? efficient() : undefined
66
- return { keepTop, webm, rungs: [...(webm ? widths.map(w => [w, 'webm']) : []), ...widths.map(w => [w, 'mp4'])] }
70
+ return {
71
+ keepTop,
72
+ webm,
73
+ rungs: [...(webm ? widths.map((w) => [w, 'webm']) : []), ...widths.map((w) => [w, 'mp4'])],
74
+ }
67
75
  }
68
76
 
69
77
  const hasCopy = () => o.encoder === 'compatible'
70
- const compatible = asset => (hasCopy() ? { ...asset, swap: o.swap ?? 'intent' } : asset)
78
+ const compatible = (asset) => (hasCopy() ? { ...asset, swap: o.swap ?? 'intent' } : asset)
71
79
 
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
- }
80
+ const video = createVideo({
81
+ videoRungs,
82
+ encode,
83
+ sourceOf,
84
+ dir,
85
+ url,
86
+ compatible,
87
+ record,
88
+ hasCopy,
89
+ playable,
90
+ COMPATIBLE,
91
+ o,
92
+ })
83
93
 
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
- }
94
+ const ingest = createIngest({
95
+ encode,
96
+ sourceOf,
97
+ refuse,
98
+ record,
99
+ url,
100
+ enqueue,
101
+ store,
102
+ video,
103
+ COMPATIBLE,
104
+ })
101
105
 
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
- }
106
+ const gif = createGif({
107
+ encode,
108
+ o,
109
+ sourceOf,
110
+ video,
111
+ rung,
112
+ hasCopy,
113
+ url,
114
+ enqueue,
115
+ store,
116
+ COMPATIBLE,
117
+ SMALLER,
118
+ })
122
119
 
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
- }
120
+ const serve = createServe(dir, sourceOf, rung, TYPES)
131
121
 
132
122
  return { ingest, gif, serve, settled: () => line }
133
123
  }
@@ -0,0 +1,52 @@
1
+ import { errored, need, onDisk, short } from './common.mjs'
2
+
3
+ export async function audioFile({ bytes, name, stem }, o, encode) {
4
+ const opus = encode['audio/opus']
5
+ const aac = encode['audio/aac']
6
+ if (!aac) return { asset: null, files: [], diagnostics: [need(name, 'audio/aac')] }
7
+ try {
8
+ return await onDisk(name, bytes, async (source) => {
9
+ const info = await aac.info(source)
10
+ if (!info.audio)
11
+ return {
12
+ asset: null,
13
+ files: [],
14
+ diagnostics: [
15
+ { code: 'M018', severity: 'error', message: `${name} has no audio track.` },
16
+ ],
17
+ }
18
+ const under = (limit) => !info.audio.bitrate || info.audio.bitrate <= limit
19
+ const sets = []
20
+ for (const [enc, codec, limit] of [
21
+ [opus, 'opus', 96_000],
22
+ [aac, 'aac', 128_000],
23
+ ]) {
24
+ if (!enc || !(await enc.encoderName())) continue
25
+ const encoded = await enc.encode(source, {
26
+ copy: info.audio.codec === codec && under(limit),
27
+ })
28
+ const type = `${enc.ext === 'webm' ? 'audio/webm' : 'audio/mp4'}; codecs="${(await onDisk(`x.${enc.ext}`, encoded, (f) => enc.info(f))).audio.string}"`
29
+ sets.push({ type, name: `${stem}-${short(encoded)}.${enc.ext}`, bytes: encoded })
30
+ }
31
+ const last = sets.at(-1)
32
+ return {
33
+ asset: {
34
+ kind: 'audio',
35
+ src: last.name,
36
+ width: 0,
37
+ height: 0,
38
+ type: 'audio/mp4',
39
+ name,
40
+ quality: 'fixed',
41
+ duration: info.duration,
42
+ audio: true,
43
+ sources: sets.map((s) => ({ type: s.type, srcset: `${s.name} 1w` })),
44
+ },
45
+ files: sets.map((s) => ({ name: s.name, bytes: s.bytes })),
46
+ diagnostics: [],
47
+ }
48
+ })
49
+ } catch (e) {
50
+ return errored(name, e)
51
+ }
52
+ }
@@ -0,0 +1,28 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { extname, join } from 'node:path'
5
+
6
+ export const short = (bytes) => createHash('sha256').update(bytes).digest('hex').slice(0, 8)
7
+ export const need = (o, key) => ({
8
+ code: 'P101',
9
+ severity: 'error',
10
+ 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.`,
11
+ })
12
+ export const errored = (name, e) => ({
13
+ asset: null,
14
+ files: [],
15
+ diagnostics: [{ code: e.code ?? 'M019', severity: 'error', message: `${name}: ${e.message}` }],
16
+ })
17
+
18
+ /** A temp file for ffmpeg to read: `work(path)` runs with the bytes on disk. */
19
+ export async function onDisk(name, bytes, work) {
20
+ const dir = mkdtempSync(join(tmpdir(), 'weft-media-'))
21
+ try {
22
+ const path = join(dir, `in${extname(name).toLowerCase()}`)
23
+ writeFileSync(path, bytes)
24
+ return await work(path, dir)
25
+ } finally {
26
+ rmSync(dir, { recursive: true, force: true })
27
+ }
28
+ }