imgtune 0.1.0 → 0.3.0
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/CHANGELOG.md +172 -0
- package/README.md +109 -5
- package/dist/animated.js +176 -0
- package/dist/audit.js +1 -0
- package/dist/backgrounds.js +42 -7
- package/dist/broken.js +5 -1
- package/dist/browser.js +4 -4
- package/dist/candidates.js +25 -0
- package/dist/check-report.js +56 -6
- package/dist/check.js +26 -5
- package/dist/cli.js +86 -8
- package/dist/egress-proxy.js +2 -1
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +530 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +704 -16
- package/dist/image-file.js +154 -34
- package/dist/image-open.js +9 -0
- package/dist/images.js +142 -8
- package/dist/layout.js +7 -7
- package/dist/lcp.js +2 -1
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/measure.js +39 -1
- package/dist/media-encode.js +1054 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +251 -0
- package/dist/media-phase.js +675 -0
- package/dist/media-prepare.js +31 -0
- package/dist/px.js +11 -0
- package/dist/recheck.js +12 -4
- package/dist/remote.js +1065 -107
- package/dist/replay.js +20 -0
- package/dist/resources/audio.js +248 -0
- package/dist/resources/brotli.js +22 -0
- package/dist/resources/cache.js +59 -0
- package/dist/resources/capture.js +225 -0
- package/dist/resources/compression.js +122 -0
- package/dist/resources/favicon.js +130 -0
- package/dist/resources/font.js +440 -0
- package/dist/resources/index.js +8 -0
- package/dist/resources/lottie.js +491 -0
- package/dist/resources/model.js +478 -0
- package/dist/resources/phase.js +171 -0
- package/dist/resources/pool.js +47 -0
- package/dist/resources/report.js +57 -0
- package/dist/resources/sfnt.js +305 -0
- package/dist/resources/svg.js +178 -0
- package/dist/resources/types.js +1 -0
- package/dist/resources/zip.js +67 -0
- package/dist/result-cache.js +56 -3
- package/dist/run.js +32 -8
- package/dist/sizes.js +7 -5
- package/dist/summary.js +69 -3
- package/dist/vector-art.js +294 -0
- package/dist/vector-phase.js +222 -0
- package/dist/video-analysis.js +371 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +25 -4
- package/skills/imgtune/SKILL.md +8 -4
- package/skills/imgtune/reference.md +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Single-file media for `imgtune image`: an animated GIF, animated WebP or APNG, or a video (MP4, MOV, WebM), read from a
|
|
2
|
+
// local file. Two modes. Without a reference, an animated file gets the candidates of the three animated codecs (animated
|
|
3
|
+
// AVIF, animated WebP and AV1 MP4) at a width, each the lowest-bytes quality that meets the bars. With a reference, a
|
|
4
|
+
// candidate (a re-encode an agent made) is compared with the original at a width, and passes or fails the same bars.
|
|
5
|
+
// Both measure from 2-second samples, so every byte figure is an estimate. The files are read, never written, except the
|
|
6
|
+
// lossless copy of an animated WebP (ffmpeg cannot read one) in a scratch folder the caller removes.
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { animationOf } from "./animated.js";
|
|
9
|
+
import { prepareMedia } from "./media-prepare.js";
|
|
10
|
+
import { compareToReference, cutSamples, effectiveStillBar, searchQuality, } from "./media-encode.js";
|
|
11
|
+
import { reasonText } from "./media-model.js";
|
|
12
|
+
import { pixelSize, safeText } from "./remote.js";
|
|
13
|
+
import { IMGTUNE_VERSION } from "./version.js";
|
|
14
|
+
/** The animated codecs a single animated file is searched for, in report order. */
|
|
15
|
+
export const ANIMATED_CODECS = ["animated-avif", "animated-webp", "av1-mp4"];
|
|
16
|
+
/** Time one codec's search may take, in ms. */
|
|
17
|
+
export const CODEC_BUDGET_MS = 180_000;
|
|
18
|
+
/** Time a reference check may take, in ms. */
|
|
19
|
+
export const REFERENCE_BUDGET_MS = 300_000;
|
|
20
|
+
/** A candidate may end this much before the reference and still be compared (encoders trim a fraction of a second). */
|
|
21
|
+
const LENGTH_TOLERANCE_S = 0.5;
|
|
22
|
+
/** The note printed when ffmpeg is missing: the bytes are still measured, the encodes are not made. */
|
|
23
|
+
export const NO_FFMPEG_NOTE = "ffmpeg is not on PATH: the candidates and the reference check need it (ffmpeg enables the full hints)";
|
|
24
|
+
/** Brands of an ISO box file that are still images (AVIF, HEIC), not video. */
|
|
25
|
+
const STILL_BRANDS = new Set(["avif", "avis", "heic", "heix", "heim", "heis", "mif1", "msf1"]);
|
|
26
|
+
export { prepareMedia };
|
|
27
|
+
/** Whether an MP4, MOV or WebM (or Matroska) file: an ISO box file with a video brand, or an EBML header. */
|
|
28
|
+
export function isVideoContainer(buffer) {
|
|
29
|
+
if (buffer.length >= 12 && buffer.toString("latin1", 4, 8) === "ftyp")
|
|
30
|
+
return !STILL_BRANDS.has(buffer.toString("latin1", 8, 12));
|
|
31
|
+
return buffer.length >= 4 && buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3;
|
|
32
|
+
}
|
|
33
|
+
/** The kind of media a file is, from its bytes: animated (GIF, WebP, APNG with more than one frame), video, or undefined. */
|
|
34
|
+
export function mediaKindOf(buffer) {
|
|
35
|
+
if (animationOf(buffer))
|
|
36
|
+
return "animated";
|
|
37
|
+
if (isVideoContainer(buffer))
|
|
38
|
+
return "video";
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const base = (label, kind) => ({ label, evidence: "media-file", imgtuneVersion: IMGTUNE_VERSION, kind });
|
|
42
|
+
/** The current size of a file: its bytes, its pixel size from the header, and the animation's length and frames. */
|
|
43
|
+
async function currentOf(buffer, format, animation, probe) {
|
|
44
|
+
const size = probe ? { width: probe.width, height: probe.height } : await pixelSize(buffer);
|
|
45
|
+
const current = { format, width: size?.width ?? 0, height: size?.height ?? 0, bytes: buffer.length };
|
|
46
|
+
if (animation) {
|
|
47
|
+
current.seconds = animation.durationMs / 1000;
|
|
48
|
+
current.frames = animation.frames;
|
|
49
|
+
}
|
|
50
|
+
else if (probe && probe.durationS > 0)
|
|
51
|
+
current.seconds = probe.durationS;
|
|
52
|
+
return current;
|
|
53
|
+
}
|
|
54
|
+
/** The formats a file's probe or animation names, for the report. */
|
|
55
|
+
function formatOf(animation, probe) {
|
|
56
|
+
return probe?.videoCodec ?? animation?.format ?? "?";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One animated file without a reference: the candidates of each animated codec at `width` (the file's own width when not
|
|
60
|
+
* given), each the lowest-bytes quality that meets the bars. Never throws: a failure is the result's error.
|
|
61
|
+
*/
|
|
62
|
+
export async function measureAnimated(input, width, ctx, dir) {
|
|
63
|
+
const animation = animationOf(input.buffer);
|
|
64
|
+
const bars = { vmaf: ctx.videoBar, stillRequested: ctx.videoStillBar };
|
|
65
|
+
const current = await currentOf(input.buffer, formatOf(animation, undefined), animation);
|
|
66
|
+
const widthOf = (px) => ({ source: width === undefined ? "file" : "user", px });
|
|
67
|
+
const result = (extra) => ({
|
|
68
|
+
...base(input.label, "animated"),
|
|
69
|
+
current,
|
|
70
|
+
width: widthOf(width ?? current.width),
|
|
71
|
+
bars,
|
|
72
|
+
candidates: [],
|
|
73
|
+
...extra,
|
|
74
|
+
});
|
|
75
|
+
if (!ctx.caps.ffmpeg)
|
|
76
|
+
return result({ note: NO_FFMPEG_NOTE });
|
|
77
|
+
try {
|
|
78
|
+
const prepared = await prepareMedia(input.path, input.buffer, join(dir, "prepared"), undefined, ctx.signal);
|
|
79
|
+
current.format = formatOf(animation, prepared.probe);
|
|
80
|
+
current.width = prepared.probe.width;
|
|
81
|
+
current.height = prepared.probe.height;
|
|
82
|
+
const px = width ?? prepared.probe.width;
|
|
83
|
+
const samples = await cutSamples(prepared.source, prepared.probe, join(dir, "samples"), Date.now() + CODEC_BUDGET_MS, undefined, ctx.signal);
|
|
84
|
+
const candidates = [];
|
|
85
|
+
for (const codec of ANIMATED_CODECS) {
|
|
86
|
+
const outcome = await searchQuality(samples, prepared.probe, codec, px, {
|
|
87
|
+
vmafBar: ctx.videoBar,
|
|
88
|
+
stillBar: ctx.videoStillBar,
|
|
89
|
+
deadline: Date.now() + CODEC_BUDGET_MS,
|
|
90
|
+
caps: ctx.caps,
|
|
91
|
+
gpu: ctx.gpu,
|
|
92
|
+
score: ctx.score,
|
|
93
|
+
workDir: join(dir, codec),
|
|
94
|
+
signal: ctx.signal,
|
|
95
|
+
});
|
|
96
|
+
candidates.push({ codec, outcome });
|
|
97
|
+
}
|
|
98
|
+
return result({ width: widthOf(px), candidates });
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return result({ error: err.message });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** One video file without a reference: its size and length, and what to do with it. */
|
|
105
|
+
export async function describeVideo(input, ctx, dir) {
|
|
106
|
+
const note = "a video: give --reference <original> to check a re-encode of it against the bars";
|
|
107
|
+
if (!ctx.caps.ffmpeg)
|
|
108
|
+
return { ...base(input.label, "video"), current: await currentOf(input.buffer, "video"), note: NO_FFMPEG_NOTE };
|
|
109
|
+
try {
|
|
110
|
+
const prepared = await prepareMedia(input.path, input.buffer, join(dir, "prepared"), undefined, ctx.signal);
|
|
111
|
+
return { ...base(input.label, "video"), current: await currentOf(input.buffer, formatOf(undefined, prepared.probe), undefined, prepared.probe), note };
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return { ...base(input.label, "video"), current: await currentOf(input.buffer, "video"), error: err.message };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The codec a candidate is judged in: its own (animated WebP, H.264 or AV1), so its still ceiling is the best its codec can
|
|
119
|
+
* reach. Any other format is judged against AV1, as the search does.
|
|
120
|
+
*/
|
|
121
|
+
export function candidateCodecOf(cand) {
|
|
122
|
+
if (cand.animation?.format === "webp")
|
|
123
|
+
return "animated-webp";
|
|
124
|
+
if (cand.probe.videoCodec === "h264")
|
|
125
|
+
return "h264-mp4";
|
|
126
|
+
return "av1-mp4";
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A candidate compared with its reference at `width` (the candidate's own width when not given). The still bar is made
|
|
130
|
+
* relative to the reference's ceiling (its own AV1 CRF 20 encode's still guard) as the search does; without an AV1 encoder
|
|
131
|
+
* the bar stays absolute. Never throws: a failure is the result's error, and the verdict is a fail.
|
|
132
|
+
*/
|
|
133
|
+
export async function compareReferenceFile(candidate, reference, width, ctx, dir) {
|
|
134
|
+
const requested = ctx.videoStillBar;
|
|
135
|
+
const fail = (error, extra = {}) => ({
|
|
136
|
+
label: candidate.label,
|
|
137
|
+
evidence: "media-reference",
|
|
138
|
+
imgtuneVersion: IMGTUNE_VERSION,
|
|
139
|
+
current: { format: "?", width: 0, height: 0, bytes: candidate.buffer.length },
|
|
140
|
+
reference: { label: reference.label, width: 0, height: 0, seconds: 0 },
|
|
141
|
+
width: width ?? 0,
|
|
142
|
+
vmaf: 0,
|
|
143
|
+
vmafBar: ctx.videoBar,
|
|
144
|
+
still: 0,
|
|
145
|
+
stillBar: requested,
|
|
146
|
+
stillBarRequested: requested,
|
|
147
|
+
samples: 0,
|
|
148
|
+
pass: false,
|
|
149
|
+
error,
|
|
150
|
+
...extra,
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
const cand = await prepareMedia(candidate.path, candidate.buffer, join(dir, "candidate"), undefined, ctx.signal);
|
|
154
|
+
const ref = await prepareMedia(reference.path, reference.buffer, join(dir, "reference"), undefined, ctx.signal);
|
|
155
|
+
// The samples are cut at the reference's moments, so a candidate that ends before the reference cannot be compared.
|
|
156
|
+
if (cand.probe.durationS + LENGTH_TOLERANCE_S < ref.probe.durationS) {
|
|
157
|
+
return fail(`the candidate is ${cand.probe.durationS.toFixed(1)} s but the reference is ${ref.probe.durationS.toFixed(1)} s: give a candidate of the same length (the samples are cut at the same moments)`);
|
|
158
|
+
}
|
|
159
|
+
const px = width ?? cand.probe.width;
|
|
160
|
+
const score = await compareToReference(cand.source, ref.source, ref.probe, px, {
|
|
161
|
+
vmafBar: ctx.videoBar,
|
|
162
|
+
stillBar: requested,
|
|
163
|
+
deadline: Date.now() + REFERENCE_BUDGET_MS,
|
|
164
|
+
caps: ctx.caps,
|
|
165
|
+
gpu: ctx.gpu,
|
|
166
|
+
score: ctx.score,
|
|
167
|
+
workDir: join(dir, "compare"),
|
|
168
|
+
signal: ctx.signal,
|
|
169
|
+
}, candidateCodecOf(cand));
|
|
170
|
+
const stillBar = score.ceiling === undefined ? requested : effectiveStillBar(requested, score.ceiling);
|
|
171
|
+
const current = await currentOf(candidate.buffer, formatOf(cand.animation, cand.probe), cand.animation, cand.probe);
|
|
172
|
+
return {
|
|
173
|
+
label: candidate.label,
|
|
174
|
+
evidence: "media-reference",
|
|
175
|
+
imgtuneVersion: IMGTUNE_VERSION,
|
|
176
|
+
current,
|
|
177
|
+
reference: { label: reference.label, width: ref.probe.width, height: ref.probe.height, seconds: ref.probe.durationS },
|
|
178
|
+
width: px,
|
|
179
|
+
vmaf: score.vmaf,
|
|
180
|
+
vmafBar: ctx.videoBar,
|
|
181
|
+
still: score.still,
|
|
182
|
+
stillBar,
|
|
183
|
+
stillBarRequested: requested,
|
|
184
|
+
...(score.ceiling !== undefined ? { ceiling: score.ceiling } : {}),
|
|
185
|
+
...(score.ceilingCodec !== undefined ? { ceilingCodec: score.ceilingCodec } : {}),
|
|
186
|
+
samples: score.samples,
|
|
187
|
+
pass: score.vmaf >= ctx.videoBar && score.still >= stillBar,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
return fail(err.message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
195
|
+
// Text
|
|
196
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
197
|
+
const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
|
|
198
|
+
/** How a candidate's quality reads: a CRF for AV1 and AVIF, a q for WebP. */
|
|
199
|
+
function qualityText(codec, quality) {
|
|
200
|
+
return codec === "animated-webp" ? `q ${quality}` : `CRF ${quality}`;
|
|
201
|
+
}
|
|
202
|
+
/** The text of one candidate line: its quality, width, estimated bytes and scores against the bars. */
|
|
203
|
+
function candidateLine(codec, best) {
|
|
204
|
+
return ` ${codec.padEnd(14)} ${qualityText(codec, best.quality)} at ${best.width} px: ${kb(best.bytes)} est. (VMAF ${best.vmaf.toFixed(1)} bar ${best.vmafBar}, still p10 ${best.still.toFixed(1)} bar ${best.stillBar.toFixed(1)})`;
|
|
205
|
+
}
|
|
206
|
+
/** The text of one animated file: its size, the bars, one line per codec, and the best saving. */
|
|
207
|
+
export function renderAnimated(result) {
|
|
208
|
+
const label = safeText(result.label);
|
|
209
|
+
const c = result.current;
|
|
210
|
+
const span = c.seconds !== undefined ? `, ${c.seconds.toFixed(1)} s${c.frames !== undefined ? `, ${c.frames} frames` : ""}` : "";
|
|
211
|
+
const head = `${label} ${safeText(c.format)} ${c.width}x${c.height} ${kb(c.bytes)} now animated${span}`;
|
|
212
|
+
if (result.error)
|
|
213
|
+
return `${head}\n ${safeText(result.error)}\n`;
|
|
214
|
+
const lines = [head];
|
|
215
|
+
const widthNote = result.width.source === "user" ? "from --width" : "the file's own width; --width sets another";
|
|
216
|
+
lines.push(` width ${result.width.px} px (${widthNote})`);
|
|
217
|
+
// The note only when a candidate's still bar was lowered below the requested one (its own ceiling is low).
|
|
218
|
+
const lowered = result.candidates.some(({ outcome }) => outcome.ok && outcome.best.stillBar < result.bars.stillRequested);
|
|
219
|
+
lines.push(` bars VMAF ${result.bars.vmaf}, still p10 ${result.bars.stillRequested}${lowered ? " (lowered for a source whose own best encode scores low)" : ""}`);
|
|
220
|
+
if (result.note)
|
|
221
|
+
lines.push(` ${safeText(result.note)}`);
|
|
222
|
+
let best;
|
|
223
|
+
for (const { codec, outcome } of result.candidates) {
|
|
224
|
+
if (outcome.ok) {
|
|
225
|
+
lines.push(candidateLine(codec, outcome.best));
|
|
226
|
+
if (best === undefined || outcome.best.bytes < best.bytes)
|
|
227
|
+
best = { codec, bytes: outcome.best.bytes };
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const why = reasonText(outcome.reason);
|
|
231
|
+
lines.push(` ${codec.padEnd(14)} no quality meets the bars (${why})`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (best) {
|
|
235
|
+
const saves = c.bytes - best.bytes;
|
|
236
|
+
lines.push(` best ${best.codec} ${kb(best.bytes)} est.${saves >= 0 ? ` (saves ${kb(saves)} est.)` : ""}`);
|
|
237
|
+
}
|
|
238
|
+
if (result.candidates.length > 0)
|
|
239
|
+
lines.push(" estimates from 2 s samples; the full length is not encoded");
|
|
240
|
+
return `${lines.join("\n")}\n`;
|
|
241
|
+
}
|
|
242
|
+
/** How each codec's best-quality ceiling is named in a report. */
|
|
243
|
+
const CEILING_NAME = {
|
|
244
|
+
"av1-mp4": "AV1 CRF 20",
|
|
245
|
+
"animated-avif": "AVIF CRF 20",
|
|
246
|
+
"h264-mp4": "H.264 CRF 18",
|
|
247
|
+
"animated-webp": "WebP q 90",
|
|
248
|
+
};
|
|
249
|
+
/** The text of one video file without a reference. */
|
|
250
|
+
export function renderVideo(result) {
|
|
251
|
+
const c = result.current;
|
|
252
|
+
const span = c.seconds !== undefined ? `, ${c.seconds.toFixed(1)} s` : "";
|
|
253
|
+
const lines = [`${safeText(result.label)} ${safeText(c.format)} ${c.width}x${c.height} ${kb(c.bytes)} now video${span}`];
|
|
254
|
+
if (result.error)
|
|
255
|
+
lines.push(` ${safeText(result.error)}`);
|
|
256
|
+
if (result.note)
|
|
257
|
+
lines.push(` ${safeText(result.note)}`);
|
|
258
|
+
return `${lines.join("\n")}\n`;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The text of a reference check: the candidate's size, the reference, and the verdict line
|
|
262
|
+
* `VMAF <v> (bar <b>) still p10 <s> (bar <sb>): pass|fail`. The still bar line says when the bar was lowered.
|
|
263
|
+
*/
|
|
264
|
+
export function renderReference(result) {
|
|
265
|
+
const c = result.current;
|
|
266
|
+
const head = `${safeText(result.label)} ${safeText(c.format)} ${c.width}x${c.height} ${kb(c.bytes)} now`;
|
|
267
|
+
if (result.error)
|
|
268
|
+
return `${head}\n ${safeText(result.error)}\n`;
|
|
269
|
+
const lines = [head];
|
|
270
|
+
const ref = result.reference;
|
|
271
|
+
lines.push(` reference ${safeText(ref.label)} (${ref.width}x${ref.height}, ${ref.seconds.toFixed(1)} s); compared at ${result.width} px`);
|
|
272
|
+
if (result.ceiling === undefined)
|
|
273
|
+
lines.push(" still bar is absolute: no AV1 encoder, so the reference's own ceiling is not measured");
|
|
274
|
+
else {
|
|
275
|
+
if (result.stillBar < result.stillBarRequested) {
|
|
276
|
+
lines.push(` still bar lowered from ${result.stillBarRequested} to ${result.stillBar.toFixed(1)}: the reference's own best encode scores ${result.ceiling.toFixed(1)}`);
|
|
277
|
+
}
|
|
278
|
+
// The ceiling is the reference's best encode in the candidate's codec (AV1 when that codec has no encoder), named here.
|
|
279
|
+
lines.push(` still bar ceiling: the reference's best ${CEILING_NAME[result.ceilingCodec ?? "av1-mp4"]} encode (candidate ${safeText(c.format)})`);
|
|
280
|
+
}
|
|
281
|
+
const verdict = `VMAF ${result.vmaf.toFixed(1)} (bar ${result.vmafBar}) still p10 ${result.still.toFixed(1)} (bar ${result.stillBar.toFixed(1)}): ${result.pass ? "pass" : "fail"}`;
|
|
282
|
+
lines.push(verdict);
|
|
283
|
+
return `${lines.join("\n")}\n`;
|
|
284
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The size limits of the media pipeline, and the errors a refused input raises. Kept apart from media-encode.ts so the
|
|
2
|
+
// WebP reader (webp-animation.ts) can use them without importing the encoder.
|
|
3
|
+
/**
|
|
4
|
+
* The most pixels a frame may have before ffmpeg or the decoder is asked to read it (8K is about 33 Mpx): above this the input
|
|
5
|
+
* is refused before any decode or encode, since ffmpeg would allocate frames of several GB. Reported as "too-large".
|
|
6
|
+
*/
|
|
7
|
+
export const MAX_INPUT_PIXELS_FFMPEG = 40_000_000;
|
|
8
|
+
/** The most frames one 2-second sample window may hold (a sanity cap: at 30 fps a window is 60 frames). */
|
|
9
|
+
export const MAX_WINDOW_FRAMES = 2000;
|
|
10
|
+
/** A frame over the pixel limit: refused before any decode or encode. Its message starts "too large to score". */
|
|
11
|
+
export class PixelsTooLarge extends Error {
|
|
12
|
+
width;
|
|
13
|
+
height;
|
|
14
|
+
constructor(width, height, limit = MAX_INPUT_PIXELS_FFMPEG) {
|
|
15
|
+
super(`too large to score: a frame of ${width}x${height} has ${(width * height / 1_000_000).toFixed(1)} Mpx, above the ${limit / 1_000_000} Mpx limit`);
|
|
16
|
+
this.name = "PixelsTooLarge";
|
|
17
|
+
this.width = width;
|
|
18
|
+
this.height = height;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** A sample window with more frames than MAX_WINDOW_FRAMES. Its message starts "too large to score". */
|
|
22
|
+
export class WindowTooLarge extends Error {
|
|
23
|
+
constructor(frames) {
|
|
24
|
+
super(`too large to score: a 2 s sample window holds ${frames} frames (the cap is ${MAX_WINDOW_FRAMES})`);
|
|
25
|
+
this.name = "WindowTooLarge";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** True for the refusals above: the input is too large to score, so the search reports "too-large". */
|
|
29
|
+
export function isTooLarge(err) {
|
|
30
|
+
return err instanceof PixelsTooLarge || err instanceof WindowTooLarge;
|
|
31
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rules for animated media and `<video>`: which widths to encode at, how many bytes a view or a play costs,
|
|
3
|
+
* which target to recommend for a GIF, how to split a saving across levers, how to sample a clip, and which assets
|
|
4
|
+
* fit the encode budget. No I/O and no ffmpeg here, so every rule can be tested without a browser or an encoder.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* How a not-encoded reason code reads in a report. Codes that a report prints as words (the agent reads them): a code with
|
|
8
|
+
* no entry passes through unchanged, as it is already a sentence (for example "ffmpeg not found").
|
|
9
|
+
*/
|
|
10
|
+
const REASON_TEXT = {
|
|
11
|
+
"time-limit": "time limit reached",
|
|
12
|
+
"no-passing-quality": "no setting passed the quality bar",
|
|
13
|
+
"too-large": "too large to score",
|
|
14
|
+
"no-encoder": "ffmpeg has no encoder for this codec",
|
|
15
|
+
"no-vmaf": "ffmpeg has no libvmaf",
|
|
16
|
+
"no-ffmpeg": "ffmpeg not found",
|
|
17
|
+
"probe-failed": "ffprobe could not read the file",
|
|
18
|
+
"score-failed": "the quality scorer failed",
|
|
19
|
+
aborted: "aborted",
|
|
20
|
+
"never-shown": "never shown on load",
|
|
21
|
+
};
|
|
22
|
+
/** The text of a not-encoded reason (see REASON_TEXT). */
|
|
23
|
+
export function reasonText(reason) {
|
|
24
|
+
return REASON_TEXT[reason] ?? reason;
|
|
25
|
+
}
|
|
26
|
+
/** Widths a video rendition can be snapped to. Few buckets keep the encode count and the cache keys small. */
|
|
27
|
+
export const WIDTH_BUCKETS = [320, 480, 640, 720, 854, 960, 1080, 1280, 1440, 1920, 2560, 3840];
|
|
28
|
+
/**
|
|
29
|
+
* Snaps a needed pixel width to the smallest bucket at or above it, never above the source width. A need the source
|
|
30
|
+
* cannot reach returns the source width, since encoding wider than the source adds bytes and no detail.
|
|
31
|
+
*/
|
|
32
|
+
export function snapWidth(need, sourceWidth) {
|
|
33
|
+
if (need >= sourceWidth)
|
|
34
|
+
return sourceWidth;
|
|
35
|
+
const bucket = WIDTH_BUCKETS.find((b) => b >= need);
|
|
36
|
+
return bucket === undefined ? sourceWidth : Math.min(bucket, sourceWidth);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Applies the per-view rules. A video that is not visible (0×0 or `display:none`) costs nothing per view, because the
|
|
40
|
+
* browser does not fetch it for the page. Autoplay fetches the whole file once it plays, so it counts the full file;
|
|
41
|
+
* before that it counts what the scan saw. preload="auto" and the default fetch whatever the browser chooses, so they
|
|
42
|
+
* count the observed bytes. metadata and none fetch no media on load.
|
|
43
|
+
*/
|
|
44
|
+
export function videoBytes(input) {
|
|
45
|
+
const perPlay = input.fullBytes;
|
|
46
|
+
const bounded = (view) => ({ ...view, perView: Math.min(view.perView, perPlay) });
|
|
47
|
+
if (!input.visible)
|
|
48
|
+
return { perView: 0, perPlay, perViewBasis: "none", browserDependent: false };
|
|
49
|
+
switch (input.kind) {
|
|
50
|
+
case "autoplay":
|
|
51
|
+
return input.playing
|
|
52
|
+
? bounded({ perView: input.fullBytes, perPlay, perViewBasis: "full-file", browserDependent: false })
|
|
53
|
+
: bounded({ perView: input.observedBytes, perPlay, perViewBasis: "observed", browserDependent: false });
|
|
54
|
+
case "auto":
|
|
55
|
+
return bounded({ perView: input.observedBytes, perPlay, perViewBasis: "observed", browserDependent: true });
|
|
56
|
+
case "default":
|
|
57
|
+
return bounded({ perView: input.observedBytes, perPlay, perViewBasis: "observed", browserDependent: false });
|
|
58
|
+
case "metadata":
|
|
59
|
+
case "none":
|
|
60
|
+
return { perView: 0, perPlay, perViewBasis: "none", browserDependent: false };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The bytes of a video once its full file length is known (from a Range probe): the per-play figure is the length, and the
|
|
65
|
+
* per-view figure is the length when the view counts the full file (autoplay), else the observed bytes, never above the
|
|
66
|
+
* length. Keeps perView ≤ perPlay.
|
|
67
|
+
*/
|
|
68
|
+
export function withFileLength(bytes, length) {
|
|
69
|
+
return {
|
|
70
|
+
...bytes,
|
|
71
|
+
perPlay: length,
|
|
72
|
+
perView: Math.min(bytes.perViewBasis === "full-file" ? length : bytes.perView, length),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Ignores floating-point noise such as 1080.0000000002 before taking the ceiling. */
|
|
76
|
+
function pixelNeed(boxWidth, dpr) {
|
|
77
|
+
return Math.ceil(boxWidth * dpr - 1e-9);
|
|
78
|
+
}
|
|
79
|
+
/** True when a larger viewport never gets a smaller box, so the widths can be picked by viewport. */
|
|
80
|
+
function isMonotonic(needs) {
|
|
81
|
+
for (const a of needs) {
|
|
82
|
+
for (const b of needs) {
|
|
83
|
+
if (a.viewportWidth < b.viewportWidth && a.boxWidth > b.boxWidth)
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/** The contiguous ranges [first, last] (inclusive indexes) that a list of cut positions makes. */
|
|
90
|
+
function rangesFrom(count, cuts) {
|
|
91
|
+
const ranges = [];
|
|
92
|
+
let start = 0;
|
|
93
|
+
for (const cut of cuts) {
|
|
94
|
+
ranges.push([start, cut]);
|
|
95
|
+
start = cut + 1;
|
|
96
|
+
}
|
|
97
|
+
ranges.push([start, count - 1]);
|
|
98
|
+
return ranges;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Picks at most `max` widths for a video so that the browser serves each viewport a width that covers its need.
|
|
102
|
+
*
|
|
103
|
+
* The browser chooses a `<source>` by CSS viewport width, not by DPR, so the plan is built the same way. The viewports
|
|
104
|
+
* are sorted by CSS width (ties keep input order) and cut into at most `max` contiguous ranges. A cut is allowed only
|
|
105
|
+
* where the CSS width grows, because two viewports of one width always get the same source. Each range gets the
|
|
106
|
+
* largest snapped need in it, so every viewport in the range is covered. Every set of cuts is tried (at most six
|
|
107
|
+
* viewports, so brute force is cheap). The set with the smallest sum of per-viewport widths wins; ties go to fewer
|
|
108
|
+
* ranges, then to the smaller sum of distinct widths. Adjacent ranges that end at the same width are merged.
|
|
109
|
+
*
|
|
110
|
+
* `sources` is the `<source>` list in browser order: the largest range first, each with `(min-width: N)` where N is the
|
|
111
|
+
* CSS width of the range's first viewport, and the first range as the final source with no media. The browser takes
|
|
112
|
+
* the first source whose media matches the viewport, so it serves each viewport exactly `perViewport[i].width`.
|
|
113
|
+
*/
|
|
114
|
+
export function clusterVideoWidths(needs, max, sourceWidth) {
|
|
115
|
+
const containerDriven = !isMonotonic(needs);
|
|
116
|
+
if (needs.length === 0)
|
|
117
|
+
return { widths: [], perViewport: [], sources: [], conditions: [], containerDriven };
|
|
118
|
+
const snapped = needs.map((n, order) => ({ ...n, order, need: snapWidth(pixelNeed(n.boxWidth, n.dpr), sourceWidth) }));
|
|
119
|
+
const byCssWidth = [...snapped].sort((a, b) => a.viewportWidth - b.viewportWidth || a.order - b.order);
|
|
120
|
+
const count = byCssWidth.length;
|
|
121
|
+
const cuts = [];
|
|
122
|
+
for (let j = 0; j < count - 1; j++) {
|
|
123
|
+
if (byCssWidth[j].viewportWidth < byCssWidth[j + 1].viewportWidth)
|
|
124
|
+
cuts.push(j);
|
|
125
|
+
}
|
|
126
|
+
const limit = Math.max(1, Math.floor(max));
|
|
127
|
+
let best;
|
|
128
|
+
for (let mask = 0; mask < 1 << cuts.length; mask++) {
|
|
129
|
+
const picked = cuts.filter((_, i) => (mask >> i) & 1);
|
|
130
|
+
if (picked.length + 1 > limit)
|
|
131
|
+
continue;
|
|
132
|
+
const ranges = rangesFrom(count, picked);
|
|
133
|
+
const widths = ranges.map(([first, last]) => Math.max(...byCssWidth.slice(first, last + 1).map((s) => s.need)));
|
|
134
|
+
let cost = 0;
|
|
135
|
+
ranges.forEach(([first, last], i) => {
|
|
136
|
+
cost += widths[i] * (last - first + 1);
|
|
137
|
+
});
|
|
138
|
+
const distinctSum = [...new Set(widths)].reduce((acc, w) => acc + w, 0);
|
|
139
|
+
if (!best ||
|
|
140
|
+
cost < best.cost ||
|
|
141
|
+
(cost === best.cost &&
|
|
142
|
+
(ranges.length < best.ranges.length || (ranges.length === best.ranges.length && distinctSum < best.distinctSum)))) {
|
|
143
|
+
best = { ranges, widths, cost, distinctSum };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const merged = [];
|
|
147
|
+
best.ranges.forEach(([first, last], i) => {
|
|
148
|
+
const width = best.widths[i];
|
|
149
|
+
const previous = merged[merged.length - 1];
|
|
150
|
+
if (previous && previous.width === width)
|
|
151
|
+
previous.last = last;
|
|
152
|
+
else
|
|
153
|
+
merged.push({ first, last, width });
|
|
154
|
+
});
|
|
155
|
+
const sources = [];
|
|
156
|
+
for (let i = merged.length - 1; i >= 0; i--) {
|
|
157
|
+
const range = merged[i];
|
|
158
|
+
sources.push({
|
|
159
|
+
width: range.width,
|
|
160
|
+
media: i === 0 ? undefined : `(min-width: ${byCssWidth[range.first].viewportWidth}px)`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const widthByOrder = new Array(count);
|
|
164
|
+
for (const range of merged) {
|
|
165
|
+
for (let k = range.first; k <= range.last; k++)
|
|
166
|
+
widthByOrder[byCssWidth[k].order] = range.width;
|
|
167
|
+
}
|
|
168
|
+
const perViewport = snapped.map((s) => ({ viewport: s.viewport, need: s.need, width: widthByOrder[s.order] }));
|
|
169
|
+
const widths = [...new Set(merged.map((r) => r.width))].sort((a, b) => a - b);
|
|
170
|
+
const conditions = sources.flatMap((s) => (s.media === undefined ? [] : [s.media]));
|
|
171
|
+
return { widths, perViewport, sources, conditions, containerDriven };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The plans for `max` = 1, 2 and 3, for `audit`, which reports the bytes per view at each count and leaves the pick
|
|
175
|
+
* to the agent.
|
|
176
|
+
*/
|
|
177
|
+
export function widthOptions(needs, sourceWidth) {
|
|
178
|
+
return [1, 2, 3].map((max) => clusterVideoWidths(needs, max, sourceWidth));
|
|
179
|
+
}
|
|
180
|
+
/** A GIF bigger than this is recommended as `<video>`; a smaller one as an animated AVIF/WebP image. */
|
|
181
|
+
export const GIF_VIDEO_THRESHOLD = 1_000_000;
|
|
182
|
+
/**
|
|
183
|
+
* Chooses the target for an animated GIF by where it is used. A CSS background cannot use `<video>` in a way that keeps
|
|
184
|
+
* the same markup, so it gets animated AVIF or WebP only. A small `<img>` GIF only changes `src`. A big `<img>` GIF
|
|
185
|
+
* is best as `<video autoplay muted loop playsinline>`, with the animated image kept as an alternative.
|
|
186
|
+
*/
|
|
187
|
+
export function animatedTarget(context, avifBytes) {
|
|
188
|
+
if (context === "css-background")
|
|
189
|
+
return { main: "animated-image", alternatives: ["animated-avif", "animated-webp"] };
|
|
190
|
+
if (avifBytes === undefined || avifBytes <= GIF_VIDEO_THRESHOLD) {
|
|
191
|
+
return { main: "animated-image", alternatives: ["animated-avif", "animated-webp", "video"] };
|
|
192
|
+
}
|
|
193
|
+
return { main: "video", alternatives: ["animated-avif", "animated-webp"] };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Splits the saving of a re-encode into levers, so that nothing is double-counted. The stages are first made
|
|
197
|
+
* non-increasing (a stage that grows the file is held at the level before it), so every step is at least zero and
|
|
198
|
+
* the four steps sum to start − the lowest stage. When the last stage is the lowest, that is start − final. If the
|
|
199
|
+
* last stage grows the file, the sum is start − recoded: a growing stage cannot carry a negative lever.
|
|
200
|
+
*/
|
|
201
|
+
export function attributeLevers(chain) {
|
|
202
|
+
const noAudio = Math.min(chain.noAudio, chain.start);
|
|
203
|
+
const resized = Math.min(chain.resized, noAudio);
|
|
204
|
+
const recoded = Math.min(chain.recoded, resized);
|
|
205
|
+
const final = Math.min(chain.final, recoded);
|
|
206
|
+
return {
|
|
207
|
+
audio: chain.start - noAudio,
|
|
208
|
+
resolution: noAudio - resized,
|
|
209
|
+
codec: resized - recoded,
|
|
210
|
+
quality: recoded - final,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Scales the bytes of a sample clip to the full duration. A sample with no length returns its bytes unchanged. */
|
|
214
|
+
export function extrapolate(sampleBytes, sampleSeconds, totalSeconds) {
|
|
215
|
+
if (sampleSeconds <= 0)
|
|
216
|
+
return sampleBytes;
|
|
217
|
+
return Math.round((sampleBytes * totalSeconds) / sampleSeconds);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Start offsets, in seconds, of the 2-second sample clips. A clip of up to 6 s is sampled once, centred. A longer one
|
|
221
|
+
* is sampled at 1/6, 1/2 and 5/6 of its length, each shifted back by 1 s so the clip sits around that point, and kept
|
|
222
|
+
* inside the file. Clips under 2 s are sampled from the start.
|
|
223
|
+
*/
|
|
224
|
+
export function sampleOffsets(durationS) {
|
|
225
|
+
if (durationS < 2)
|
|
226
|
+
return [0];
|
|
227
|
+
if (durationS <= 6)
|
|
228
|
+
return [Math.max(0, (durationS - 2) / 2)];
|
|
229
|
+
return [1 / 6, 1 / 2, 5 / 6].map((f) => Math.min(Math.max(0, durationS * f - 1), durationS - 2));
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Orders assets for the encode budget: heaviest per-view bytes first, then per-play bytes, then key. The per-view bytes are
|
|
233
|
+
* `rankView` when the asset has one (the largest per-view bytes of one viewport), else `perView`: the claim is averaged over
|
|
234
|
+
* the viewports, and an average would rank a video shown at one viewport of two at half its weight. Duplicate keys are
|
|
235
|
+
* dropped, keeping the first (heaviest) copy. The first `budget` assets are encoded; the rest are skipped and still get
|
|
236
|
+
* their cheap hints. The input is not mutated.
|
|
237
|
+
*/
|
|
238
|
+
export function rankForBudget(assets, budget) {
|
|
239
|
+
const rank = (a) => a.rankView ?? a.perView;
|
|
240
|
+
const sorted = [...assets].sort((a, b) => rank(b) - rank(a) || b.perPlay - a.perPlay || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
241
|
+
const seen = new Set();
|
|
242
|
+
const unique = [];
|
|
243
|
+
for (const a of sorted) {
|
|
244
|
+
if (seen.has(a.key))
|
|
245
|
+
continue;
|
|
246
|
+
seen.add(a.key);
|
|
247
|
+
unique.push(a);
|
|
248
|
+
}
|
|
249
|
+
const cut = Math.max(0, Math.floor(budget));
|
|
250
|
+
return { encode: unique.slice(0, cut), skipped: unique.slice(cut) };
|
|
251
|
+
}
|