@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/README.md +482 -3
- package/package.json +13 -6
- package/src/build.mjs +145 -33
- package/src/check.mjs +216 -62
- package/src/index.d.ts +5 -2
- package/src/index.mjs +131 -33
- package/src/server/ingest.mjs +118 -0
- package/src/server/serve.mjs +60 -0
- package/src/server.mjs +13 -91
- package/src/shared.mjs +31 -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 +55 -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 +227 -95
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 {
|
|
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
|
+
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
|
|
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 (
|
|
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,
|
|
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(
|
|
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)
|
|
92
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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 = {
|
|
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 =
|
|
156
|
-
|
|
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({
|
|
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)
|
|
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 =
|
|
172
|
-
|
|
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(
|
|
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)
|
|
178
|
-
|
|
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(
|
|
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
|
-
}
|
|
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
|
|
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(
|
|
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(
|
|
244
|
-
|
|
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, {
|
|
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: {
|
|
265
|
-
|
|
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
|
}
|