@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
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 {
|
|
11
|
-
import {
|
|
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
|
|
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 (
|
|
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,
|
|
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(
|
|
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)
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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 = {
|
|
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 =
|
|
156
|
-
|
|
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({
|
|
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)
|
|
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 =
|
|
172
|
-
|
|
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(
|
|
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)
|
|
178
|
-
|
|
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(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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(
|
|
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(
|
|
244
|
-
|
|
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, {
|
|
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: {
|
|
265
|
-
|
|
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
|
}
|