@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/README.md +470 -3
- package/package.json +12 -5
- package/src/build.mjs +143 -30
- package/src/check.mjs +216 -62
- package/src/index.mjs +123 -32
- package/src/server/ingest.mjs +118 -0
- package/src/server/serve.mjs +60 -0
- package/src/server.mjs +13 -91
- package/src/shared.mjs +15 -5
- package/src/upload/gif.mjs +46 -0
- package/src/upload/ingest.mjs +45 -0
- package/src/upload/record.mjs +10 -0
- package/src/upload/rung.mjs +37 -0
- package/src/upload/serve.mjs +41 -0
- package/src/upload/video.mjs +54 -0
- package/src/upload.mjs +79 -89
- package/src/video/audio.mjs +52 -0
- package/src/video/common.mjs +28 -0
- package/src/video.mjs +222 -92
|
@@ -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
|
|
9
|
-
import {
|
|
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 = {
|
|
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 (
|
|
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 =>
|
|
26
|
-
|
|
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
|
-
|
|
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)
|
|
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 = (
|
|
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 {
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
+
}
|