halfsize 0.0.0-stage → 0.1.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/src/plan.mjs ADDED
@@ -0,0 +1,152 @@
1
+ export const CODECS = {
2
+ h264: { label: "H.264", encoder: "libx264", bpp: 0.072, twoPass: true },
3
+ h265: { label: "H.265", encoder: "libx265", bpp: 0.045, twoPass: true },
4
+ };
5
+
6
+ const TIERS = [
7
+ [3840, 2160],
8
+ [2560, 1440],
9
+ [1920, 1080],
10
+ [1600, 900],
11
+ [1280, 720],
12
+ [960, 540],
13
+ [854, 480],
14
+ [640, 360],
15
+ [426, 240],
16
+ ];
17
+
18
+ const even = (n) => Math.max(2, Math.round(n / 2) * 2);
19
+
20
+ function fitTier(video, tier) {
21
+ const portrait = video.height > video.width;
22
+ const [tw, th] = portrait ? [tier[1], tier[0]] : tier;
23
+ const scale = Math.min(1, tw / video.width, th / video.height);
24
+ return { w: even(video.width * scale), h: even(video.height * scale) };
25
+ }
26
+
27
+ function candidateSizes(info) {
28
+ const out = [];
29
+ const seen = new Set();
30
+ const push = (w, h) => {
31
+ const key = `${w}x${h}`;
32
+ if (!seen.has(key)) {
33
+ seen.add(key);
34
+ out.push({ w, h });
35
+ }
36
+ };
37
+ push(even(info.video.width), even(info.video.height));
38
+ for (const tier of TIERS) {
39
+ const { w, h } = fitTier(info.video, tier);
40
+ push(w, h);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function audioBitrate(info, totalKbps) {
46
+ if (!info.audio) return 0;
47
+ const preferred = totalKbps > 2500 ? 160 : totalKbps > 800 ? 128 : totalKbps > 300 ? 96 : 64;
48
+ const source = info.audio.bitrate ? info.audio.bitrate / 1000 : preferred;
49
+ const share = totalKbps * 0.18;
50
+ const value = Math.min(preferred, source, share);
51
+ return Math.max(32, Math.round(value / 8) * 8);
52
+ }
53
+
54
+ export function planEncode(info, options = {}) {
55
+ const codecName = options.codec || "h264";
56
+ const codec = CODECS[codecName];
57
+ if (!codec) throw new Error(`Unknown codec "${codecName}". Use h264 or h265.`);
58
+
59
+ const preset = options.preset || "medium";
60
+ const allowDownscale = options.downscale !== false;
61
+ const warnings = [];
62
+
63
+ if (!info.duration) throw new Error("Could not read the duration of this file.");
64
+
65
+ if (options.crf !== undefined) {
66
+ return {
67
+ mode: "quality",
68
+ codec: codecName,
69
+ label: codec.label,
70
+ encoder: codec.encoder,
71
+ preset,
72
+ crf: Number(options.crf),
73
+ width: info.video.width,
74
+ height: info.video.height,
75
+ fps: capFps(info.video.fps),
76
+ videoKbps: null,
77
+ audioKbps: audioBitrate(info, 2000),
78
+ targetBytes: null,
79
+ twoPass: false,
80
+ downscaled: false,
81
+ scaleFilter: null,
82
+ fpsFilter: null,
83
+ warnings,
84
+ };
85
+ }
86
+
87
+ if (!options.targetBytes) throw new Error("A target size is required.");
88
+
89
+ const totalKbps = ((options.targetBytes * 8) / info.duration / 1000) * 0.97;
90
+ const audioKbps = audioBitrate(info, totalKbps);
91
+ const videoKbps = Math.max(40, Math.round(totalKbps - audioKbps));
92
+ const fps = capFps(info.video.fps);
93
+
94
+ const sizes = candidateSizes(info);
95
+ const original = sizes[0];
96
+ let chosen = sizes[sizes.length - 1];
97
+ for (const size of sizes) {
98
+ const requiredKbps = (size.w * size.h * fps * codec.bpp) / 1000;
99
+ if (requiredKbps <= videoKbps) {
100
+ chosen = size;
101
+ break;
102
+ }
103
+ }
104
+
105
+ const requiredAtChosen = (chosen.w * chosen.h * fps * codec.bpp) / 1000;
106
+ if (chosen.w === original.w && chosen.h === original.h && requiredAtChosen > videoKbps) {
107
+ warnings.push("The target is aggressive for this resolution; keeping full size will look soft.");
108
+ }
109
+ if (!allowDownscale && (chosen.w !== original.w || chosen.h !== original.h)) {
110
+ chosen = { w: original.w, h: original.h };
111
+ warnings.push("Downscaling is disabled, so the target may be missed. Quality will suffer.");
112
+ }
113
+ if (videoKbps < 120) {
114
+ warnings.push("Very low bitrate. Consider a larger target or H.265.");
115
+ }
116
+ if (info.video.hdr) {
117
+ warnings.push("HDR source detected. HDR-to-SDR conversion is not implemented yet, so colors may look washed out.");
118
+ }
119
+
120
+ const downscaled = chosen.w !== original.w || chosen.h !== original.h;
121
+
122
+ return {
123
+ mode: "size",
124
+ codec: codecName,
125
+ label: codec.label,
126
+ encoder: codec.encoder,
127
+ preset,
128
+ crf: null,
129
+ width: chosen.w,
130
+ height: chosen.h,
131
+ fps,
132
+ videoKbps,
133
+ audioKbps,
134
+ targetBytes: options.targetBytes,
135
+ twoPass: Boolean(codec.twoPass),
136
+ downscaled,
137
+ scaleFilter: downscaled ? `scale=${chosen.w}:${chosen.h}:flags=lanczos` : null,
138
+ fpsFilter: fps < info.video.fps - 0.01 ? `fps=${fps}` : null,
139
+ warnings,
140
+ };
141
+ }
142
+
143
+ function capFps(fps) {
144
+ if (!fps || !Number.isFinite(fps)) return 30;
145
+ return Math.min(60, Math.max(10, Math.round(fps * 1000) / 1000));
146
+ }
147
+
148
+ export function estimateBytes(info, plan) {
149
+ if (plan.mode !== "size") return null;
150
+ const kbps = plan.videoKbps + plan.audioKbps;
151
+ return Math.round((kbps * 1000 * info.duration) / 8);
152
+ }
package/src/probe.mjs ADDED
@@ -0,0 +1,72 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { resolveBinaries } from "./ffmpeg.mjs";
4
+
5
+ const run = promisify(execFile);
6
+
7
+ export async function probe(file) {
8
+ const { ffprobe } = await resolveBinaries();
9
+ const { stdout } = await run(
10
+ ffprobe,
11
+ ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", file],
12
+ { maxBuffer: 32 * 1024 * 1024, windowsHide: true },
13
+ );
14
+ const json = JSON.parse(stdout);
15
+
16
+ const video = json.streams.find((s) => s.codec_type === "video" && s.disposition?.attached_pic !== 1);
17
+ if (!video) throw new Error("No video stream found in this file.");
18
+
19
+ const audio = json.streams.find((s) => s.codec_type === "audio");
20
+ const duration = Number(json.format.duration || video.duration || 0);
21
+ const sizeBytes = Number(json.format.size || 0);
22
+
23
+ const rotation = readRotation(video);
24
+ const width = rotation % 180 === 0 ? video.width : video.height;
25
+ const height = rotation % 180 === 0 ? video.height : video.width;
26
+
27
+ return {
28
+ file,
29
+ duration,
30
+ sizeBytes,
31
+ container: (json.format.format_name || "").split(",")[0],
32
+ bitrate: Number(json.format.bit_rate || 0),
33
+ video: {
34
+ codec: video.codec_name,
35
+ profile: video.profile,
36
+ width,
37
+ height,
38
+ fps: parseFps(video.avg_frame_rate) || parseFps(video.r_frame_rate) || 30,
39
+ pixFmt: video.pix_fmt,
40
+ bitrate: Number(video.bit_rate || 0),
41
+ hdr: isHdr(video),
42
+ rotation,
43
+ },
44
+ audio: audio
45
+ ? {
46
+ codec: audio.codec_name,
47
+ channels: audio.channels,
48
+ sampleRate: Number(audio.sample_rate),
49
+ bitrate: Number(audio.bit_rate || 0),
50
+ }
51
+ : null,
52
+ };
53
+ }
54
+
55
+ function parseFps(value) {
56
+ if (!value || value === "0/0") return 0;
57
+ const [a, b] = value.split("/").map(Number);
58
+ if (!b) return a || 0;
59
+ return a / b;
60
+ }
61
+
62
+ function readRotation(stream) {
63
+ const side = stream.side_data_list?.find((d) => d.rotation !== undefined);
64
+ if (side) return Math.abs(Number(side.rotation)) % 360;
65
+ const tag = stream.tags?.rotate;
66
+ return tag ? Math.abs(Number(tag)) % 360 : 0;
67
+ }
68
+
69
+ function isHdr(stream) {
70
+ const t = `${stream.color_transfer || ""} ${stream.color_primaries || ""}`;
71
+ return /smpte2084|arib-std-b67|bt2020/i.test(t);
72
+ }
@@ -0,0 +1,75 @@
1
+ import { spawn } from "node:child_process";
2
+ import { resolveBinaries } from "./ffmpeg.mjs";
3
+
4
+ const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
5
+
6
+ export async function measureQuality(source, output, duration, options = {}) {
7
+ const { ffmpeg } = await resolveBinaries();
8
+ const samples = options.samples || 3;
9
+ const seconds = Math.min(options.seconds || 2, Math.max(1, duration / 4));
10
+ const points = [];
11
+ for (let i = 0; i < samples; i++) {
12
+ const at = (duration * (i + 1)) / (samples + 1) - seconds / 2;
13
+ points.push(Math.max(0, Math.min(duration - seconds, at)));
14
+ }
15
+
16
+ const ssims = [];
17
+ for (const at of points) {
18
+ const value = await runMetric(ffmpeg, [
19
+ "-ss", String(at), "-t", String(seconds), "-i", output,
20
+ "-ss", String(at), "-t", String(seconds), "-i", source,
21
+ "-lavfi", "[0:v][1:v]scale2ref[a][b];[a][b]ssim",
22
+ "-f", "null", NULL_DEVICE,
23
+ ], /All:\s*([0-9.]+)/);
24
+ if (value !== null) ssims.push(value);
25
+ }
26
+
27
+ let vmaf = null;
28
+ if (options.vmaf !== false && duration > 1) {
29
+ const at = Math.max(0, duration / 2 - 1.5);
30
+ vmaf = await runMetric(ffmpeg, [
31
+ "-ss", String(at), "-t", "3", "-i", output,
32
+ "-ss", String(at), "-t", "3", "-i", source,
33
+ "-lavfi", "[0:v][1:v]scale2ref[a][b];[a][b]libvmaf=n_threads=2",
34
+ "-f", "null", NULL_DEVICE,
35
+ ], /VMAF score:\s*([0-9.]+)/);
36
+ }
37
+
38
+ const ssim = ssims.length ? ssims.reduce((a, b) => a + b, 0) / ssims.length : null;
39
+ return { ssim, vmaf, sampled: ssims.length, samples };
40
+ }
41
+
42
+ function runMetric(bin, args, regex) {
43
+ return new Promise((resolve) => {
44
+ const child = spawn(bin, ["-hide_banner", "-nostats", ...args], {
45
+ windowsHide: true,
46
+ stdio: ["ignore", "ignore", "pipe"],
47
+ });
48
+ let stderr = "";
49
+ child.stderr.on("data", (chunk) => {
50
+ stderr += chunk.toString();
51
+ });
52
+ child.on("error", () => resolve(null));
53
+ child.on("close", () => {
54
+ const match = stderr.match(regex);
55
+ resolve(match ? Number(match[1]) : null);
56
+ });
57
+ });
58
+ }
59
+
60
+ export function verdict({ vmaf, ssim }) {
61
+ if (vmaf !== null) {
62
+ if (vmaf >= 95) return { key: "indistinguishable", label: "indistinguishable", tone: "good" };
63
+ if (vmaf >= 90) return { key: "same", label: "looks the same", tone: "good" };
64
+ if (vmaf >= 80) return { key: "slight", label: "slight softening", tone: "ok" };
65
+ if (vmaf >= 70) return { key: "visible", label: "visible loss", tone: "warn" };
66
+ return { key: "degraded", label: "clearly degraded", tone: "bad" };
67
+ }
68
+ if (ssim !== null) {
69
+ if (ssim >= 0.98) return { key: "indistinguishable", label: "indistinguishable", tone: "good" };
70
+ if (ssim >= 0.95) return { key: "same", label: "looks the same", tone: "good" };
71
+ if (ssim >= 0.9) return { key: "slight", label: "slight softening", tone: "ok" };
72
+ return { key: "visible", label: "visible loss", tone: "warn" };
73
+ }
74
+ return { key: "none", label: "not measured", tone: "unknown" };
75
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,288 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import crypto from "node:crypto";
6
+ import { fileURLToPath } from "node:url";
7
+ import { probe } from "./probe.mjs";
8
+ import { planEncode } from "./plan.mjs";
9
+ import { encode } from "./encode.mjs";
10
+ import { measureQuality, verdict } from "./quality.mjs";
11
+ import { parseSize, bytes as fmt } from "./human.mjs";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const WEB_DIR = path.resolve(__dirname, "..", "web");
15
+
16
+ const jobs = new Map();
17
+ const clients = new Map();
18
+
19
+ let workDir = "";
20
+
21
+ function jobDir() {
22
+ if (!workDir) {
23
+ workDir = path.join(os.tmpdir(), `halfsize-${process.pid}-${Date.now().toString(36)}`);
24
+ fs.mkdirSync(workDir, { recursive: true });
25
+ const cleanup = () => {
26
+ try {
27
+ fs.rmSync(workDir, { recursive: true, force: true });
28
+ } catch {
29
+ // best effort
30
+ }
31
+ };
32
+ process.once("exit", cleanup);
33
+ process.once("SIGINT", () => {
34
+ cleanup();
35
+ process.exit(0);
36
+ });
37
+ process.once("SIGTERM", () => {
38
+ cleanup();
39
+ process.exit(0);
40
+ });
41
+ }
42
+ return workDir;
43
+ }
44
+
45
+ function send(res, status, body, headers = {}) {
46
+ const payload = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body);
47
+ res.writeHead(status, {
48
+ "content-type": "application/json; charset=utf-8",
49
+ "cache-control": "no-store",
50
+ ...headers,
51
+ });
52
+ res.end(payload);
53
+ }
54
+
55
+ function readBody(req, limit = 8 * 1024 ** 3) {
56
+ return new Promise((resolve, reject) => {
57
+ let size = 0;
58
+ const chunks = [];
59
+ req.on("data", (chunk) => {
60
+ size += chunk.length;
61
+ if (size > limit) {
62
+ reject(new Error("File is too large for the local app. Use the CLI for files over 8 GB."));
63
+ req.destroy();
64
+ return;
65
+ }
66
+ chunks.push(chunk);
67
+ });
68
+ req.on("end", () => resolve(Buffer.concat(chunks)));
69
+ req.on("error", reject);
70
+ });
71
+ }
72
+
73
+ function emit(id, event, data) {
74
+ const set = clients.get(id);
75
+ if (!set) return;
76
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
77
+ for (const res of [...set]) {
78
+ try {
79
+ res.write(payload);
80
+ } catch {
81
+ set.delete(res);
82
+ }
83
+ }
84
+ }
85
+
86
+ export async function startServer(options = {}) {
87
+ const port = options.port || Number(process.env.HALFSIZE_PORT) || 4174;
88
+ const server = http.createServer(async (req, res) => {
89
+ try {
90
+ const url = new URL(req.url, "http://local");
91
+ const pathname = url.pathname;
92
+
93
+ if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
94
+ const html = fs.readFileSync(path.join(WEB_DIR, "index.html"));
95
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
96
+ return res.end(html);
97
+ }
98
+
99
+ if (req.method === "GET" && pathname.startsWith("/assets/")) {
100
+ const file = path.join(WEB_DIR, pathname.replace("/assets/", ""));
101
+ if (!file.startsWith(WEB_DIR) || !fs.existsSync(file)) return send(res, 404, { error: "not found" });
102
+ res.writeHead(200, { "content-type": contentType(file) });
103
+ return fs.createReadStream(file).pipe(res);
104
+ }
105
+
106
+ if (req.method === "POST" && pathname === "/api/upload") {
107
+ const name = decodeURIComponent(req.headers["x-filename"] || "video.mp4");
108
+ const id = crypto.randomBytes(8).toString("hex");
109
+ const ext = path.extname(name) || ".mp4";
110
+ const dir = jobDir();
111
+ const file = path.join(dir, `${id}${ext}`);
112
+ await new Promise((resolve, reject) => {
113
+ const out = fs.createWriteStream(file);
114
+ let size = 0;
115
+ req.on("data", (chunk) => {
116
+ size += chunk.length;
117
+ if (size > 8 * 1024 ** 3) {
118
+ req.destroy();
119
+ out.destroy();
120
+ reject(new Error("File is too large for the local app. Use the CLI for files over 8 GB."));
121
+ }
122
+ });
123
+ req.pipe(out);
124
+ out.on("finish", resolve);
125
+ out.on("error", reject);
126
+ req.on("error", reject);
127
+ });
128
+ const info = await probe(file);
129
+ jobs.set(id, { id, input: file, originalName: name, info, status: "ready" });
130
+ return send(res, 200, {
131
+ id,
132
+ name,
133
+ size: info.sizeBytes,
134
+ duration: info.duration,
135
+ width: info.video.width,
136
+ height: info.video.height,
137
+ fps: info.video.fps,
138
+ codec: info.video.codec,
139
+ hasAudio: Boolean(info.audio),
140
+ });
141
+ }
142
+
143
+ if (req.method === "GET" && pathname.startsWith("/api/events/")) {
144
+ const id = pathname.split("/").pop();
145
+ res.writeHead(200, {
146
+ "content-type": "text/event-stream",
147
+ "cache-control": "no-store",
148
+ connection: "keep-alive",
149
+ });
150
+ res.write(": connected\n\n");
151
+ if (!clients.has(id)) clients.set(id, new Set());
152
+ clients.get(id).add(res);
153
+ const job = jobs.get(id);
154
+ if (job?.status === "done") emit(id, "done", job.result);
155
+ if (job?.status === "error") emit(id, "failed", { message: job.error });
156
+ req.on("close", () => {
157
+ clients.get(id)?.delete(res);
158
+ });
159
+ return;
160
+ }
161
+
162
+ if (req.method === "POST" && pathname === "/api/compress") {
163
+ const body = JSON.parse((await readBody(req, 1024 * 1024)).toString());
164
+ const job = jobs.get(body.id);
165
+ if (!job) return send(res, 404, { error: "Unknown upload" });
166
+ if (job.status === "running") return send(res, 409, { error: "Already running" });
167
+
168
+ const targetBytes = body.target ? parseSize(body.target) : null;
169
+ if (targetBytes && targetBytes >= job.info.sizeBytes) {
170
+ return send(res, 400, {
171
+ error: `This file is already ${fmt(job.info.sizeBytes)}, smaller than the ${fmt(targetBytes)} target. Pick a smaller target.`,
172
+ code: "already_small",
173
+ });
174
+ }
175
+ const plan = planEncode(job.info, {
176
+ targetBytes,
177
+ crf: body.crf,
178
+ codec: body.codec || "h264",
179
+ preset: body.preset || "medium",
180
+ downscale: true,
181
+ });
182
+ const out = job.input.replace(/(\.[^.]+)$/, `.halfsize.mp4`);
183
+ job.status = "running";
184
+ job.plan = plan;
185
+ job.output = out;
186
+
187
+ send(res, 202, { ok: true, plan });
188
+
189
+ (async () => {
190
+ const started = Date.now();
191
+ try {
192
+ const result = await encode(job.input, out, plan, job.info, (p) => {
193
+ emit(job.id, "progress", { ...p, eta: p.ratio > 0.01 ? ((Date.now() - started) / 1000 / p.ratio) * (1 - p.ratio) : 0 });
194
+ });
195
+ emit(job.id, "phase", { phase: "verify" });
196
+ const quality = await measureQuality(job.input, out, job.info.duration);
197
+ job.result = {
198
+ id: job.id,
199
+ inputBytes: job.info.sizeBytes,
200
+ outputBytes: result.outputBytes,
201
+ targetBytes,
202
+ accuracy: targetBytes ? result.outputBytes / targetBytes - 1 : null,
203
+ ratio: job.info.sizeBytes / result.outputBytes,
204
+ elapsedMs: result.elapsedMs,
205
+ quality,
206
+ verdict: verdict(quality),
207
+ plan,
208
+ name: job.originalName,
209
+ download: `/api/download/${job.id}`,
210
+ };
211
+ job.status = "done";
212
+ emit(job.id, "done", job.result);
213
+ } catch (err) {
214
+ job.status = "error";
215
+ job.error = err.message;
216
+ emit(job.id, "failed", { message: err.message });
217
+ }
218
+ })();
219
+ return;
220
+ }
221
+
222
+ if (req.method === "GET" && pathname.startsWith("/api/download/")) {
223
+ const id = pathname.split("/").pop();
224
+ const job = jobs.get(id);
225
+ if (!job?.output || !fs.existsSync(job.output)) return send(res, 404, { error: "not ready" });
226
+ const name = job.originalName.replace(/(\.[^.]+)$/, "") + ".halfsize.mp4";
227
+ res.writeHead(200, {
228
+ "content-type": "video/mp4",
229
+ "content-disposition": `attachment; filename="${name.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "")}"; filename*=UTF-8''${encodeURIComponent(name)}`,
230
+ "content-length": fs.statSync(job.output).size,
231
+ });
232
+ return fs.createReadStream(job.output).pipe(res);
233
+ }
234
+
235
+ if (req.method === "GET" && pathname.startsWith("/api/media/")) {
236
+ const [, , , id, which] = pathname.split("/");
237
+ const job = jobs.get(id);
238
+ const file = which === "out" ? job?.output : job?.input;
239
+ if (!file || !fs.existsSync(file)) return send(res, 404, { error: "not found" });
240
+ return streamVideo(req, res, file);
241
+ }
242
+
243
+ return send(res, 404, { error: "not found" });
244
+ } catch (err) {
245
+ return send(res, 500, { error: err.message });
246
+ }
247
+ });
248
+
249
+ await new Promise((resolve, reject) => {
250
+ server.once("error", reject);
251
+ server.listen(port, "127.0.0.1", resolve);
252
+ });
253
+
254
+ return `http://127.0.0.1:${port}`;
255
+ }
256
+
257
+ function streamVideo(req, res, file) {
258
+ const stat = fs.statSync(file);
259
+ const range = req.headers.range;
260
+ if (range) {
261
+ const [startRaw, endRaw] = range.replace("bytes=", "").split("-");
262
+ const start = Number(startRaw) || 0;
263
+ const end = endRaw ? Number(endRaw) : stat.size - 1;
264
+ res.writeHead(206, {
265
+ "content-range": `bytes ${start}-${end}/${stat.size}`,
266
+ "accept-ranges": "bytes",
267
+ "content-length": end - start + 1,
268
+ "content-type": "video/mp4",
269
+ });
270
+ return fs.createReadStream(file, { start, end }).pipe(res);
271
+ }
272
+ res.writeHead(200, { "content-length": stat.size, "content-type": "video/mp4", "accept-ranges": "bytes" });
273
+ return fs.createReadStream(file).pipe(res);
274
+ }
275
+
276
+ function contentType(file) {
277
+ const ext = path.extname(file).toLowerCase();
278
+ return (
279
+ {
280
+ ".html": "text/html; charset=utf-8",
281
+ ".css": "text/css; charset=utf-8",
282
+ ".js": "text/javascript; charset=utf-8",
283
+ ".svg": "image/svg+xml",
284
+ ".png": "image/png",
285
+ ".woff2": "font/woff2",
286
+ }[ext] || "application/octet-stream"
287
+ );
288
+ }