halfsize 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +256 -2
- package/README.zh-CN.md +242 -0
- package/bin/halfsize.mjs +8 -0
- package/package.json +45 -4
- package/src/cli.mjs +298 -0
- package/src/encode.mjs +142 -0
- package/src/ffmpeg.mjs +70 -0
- package/src/human.mjs +53 -0
- package/src/plan.mjs +152 -0
- package/src/probe.mjs +72 -0
- package/src/quality.mjs +75 -0
- package/src/server.mjs +288 -0
- package/web/index.html +557 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { probe } from "./probe.mjs";
|
|
5
|
+
import { planEncode, estimateBytes } from "./plan.mjs";
|
|
6
|
+
import { encode } from "./encode.mjs";
|
|
7
|
+
import { measureQuality, verdict } from "./quality.mjs";
|
|
8
|
+
import { bytes, duration, bitrate, ratio, parseSize, clock } from "./human.mjs";
|
|
9
|
+
|
|
10
|
+
const VERSION = "0.1.1";
|
|
11
|
+
|
|
12
|
+
const HELP = `
|
|
13
|
+
halfsize ${VERSION} - compress a video to an exact size, and prove it still looks the same.
|
|
14
|
+
|
|
15
|
+
Usage
|
|
16
|
+
halfsize <file> --to 25mb Compress to a target size
|
|
17
|
+
halfsize <file> --to 25mb --codec h265
|
|
18
|
+
halfsize <file> --crf 23 Quality-first mode
|
|
19
|
+
halfsize --web Open the local app
|
|
20
|
+
halfsize --doctor Check FFmpeg and hardware encoders
|
|
21
|
+
|
|
22
|
+
Options
|
|
23
|
+
--to <size> Target size: 25mb, 700k, 1.5gb
|
|
24
|
+
--crf <n> Quality mode (18 best / 23 balanced / 28 small)
|
|
25
|
+
--codec <name> h264 (default, compatible) or h265 (smaller)
|
|
26
|
+
--preset <name> ultrafast..veryslow (default: medium)
|
|
27
|
+
--out <file> Output path
|
|
28
|
+
--no-verify Skip the SSIM/VMAF check
|
|
29
|
+
--no-downscale Never reduce resolution
|
|
30
|
+
--json Machine-readable result
|
|
31
|
+
--dry-run Print the plan, do not encode
|
|
32
|
+
-h, --help Show this help
|
|
33
|
+
-v, --version Show version
|
|
34
|
+
|
|
35
|
+
Why
|
|
36
|
+
"Just make it smaller" is easy. "Make it fit in 25 MB and still look identical"
|
|
37
|
+
is the part everyone gets wrong. halfsize computes the bitrate, picks the
|
|
38
|
+
resolution, encodes twice, then measures the result with SSIM and VMAF.
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
const DEFAULTS = { codec: "h264", preset: "medium", verify: true, downscale: true };
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const args = { _: [], flags: { ...DEFAULTS } };
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const a = argv[i];
|
|
47
|
+
if (a === "-h" || a === "--help") args.flags.help = true;
|
|
48
|
+
else if (a === "-v" || a === "--version") args.flags.version = true;
|
|
49
|
+
else if (a === "--json") args.flags.json = true;
|
|
50
|
+
else if (a === "--dry-run") args.flags.dryRun = true;
|
|
51
|
+
else if (a === "--no-verify") args.flags.verify = false;
|
|
52
|
+
else if (a === "--no-downscale") args.flags.downscale = false;
|
|
53
|
+
else if (a === "--web") args.flags.web = true;
|
|
54
|
+
else if (a === "--doctor") args.flags.doctor = true;
|
|
55
|
+
else if (a === "--to") args.flags.to = argv[++i];
|
|
56
|
+
else if (a === "--crf") args.flags.crf = Number(argv[++i]);
|
|
57
|
+
else if (a === "--codec") args.flags.codec = argv[++i];
|
|
58
|
+
else if (a === "--preset") args.flags.preset = argv[++i];
|
|
59
|
+
else if (a === "--out") args.flags.out = argv[++i];
|
|
60
|
+
else if (a.startsWith("-")) throw new Error(`Unknown option ${a}`);
|
|
61
|
+
else args._.push(a);
|
|
62
|
+
}
|
|
63
|
+
return args;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function main(argv) {
|
|
67
|
+
const args = parseArgs(argv);
|
|
68
|
+
const flags = args.flags;
|
|
69
|
+
|
|
70
|
+
if (flags.version) {
|
|
71
|
+
console.log(VERSION);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (flags.help || (!args._.length && !flags.web && !flags.doctor)) {
|
|
75
|
+
console.log(HELP);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (flags.doctor) return doctor();
|
|
79
|
+
if (flags.web) return startWeb();
|
|
80
|
+
|
|
81
|
+
const input = path.resolve(args._[0]);
|
|
82
|
+
if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
|
|
83
|
+
if (!flags.to && flags.crf === undefined) {
|
|
84
|
+
throw new Error("Pick a target: --to 25mb, or use --crf 23 for quality mode.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const targetBytes = flags.to ? parseSize(flags.to) : null;
|
|
88
|
+
const info = await probe(input);
|
|
89
|
+
|
|
90
|
+
if (targetBytes && targetBytes >= info.sizeBytes) {
|
|
91
|
+
console.log(`\n This file is already ${bytes(info.sizeBytes)}, smaller than the ${bytes(targetBytes)} target.\n`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const plan = planEncode(info, {
|
|
96
|
+
targetBytes,
|
|
97
|
+
crf: flags.crf,
|
|
98
|
+
codec: flags.codec,
|
|
99
|
+
preset: flags.preset,
|
|
100
|
+
downscale: flags.downscale,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const outPath = flags.out
|
|
104
|
+
? path.resolve(flags.out)
|
|
105
|
+
: defaultOutput(input, flags, plan);
|
|
106
|
+
|
|
107
|
+
if (!flags.json) printPlan(info, plan, flags, outPath);
|
|
108
|
+
|
|
109
|
+
if (flags.dryRun) {
|
|
110
|
+
if (flags.json) console.log(JSON.stringify({ info, plan, out: outPath }, null, 2));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const progress = new Progress(Boolean(process.stdout.isTTY) && !flags.json, info.duration);
|
|
115
|
+
let result;
|
|
116
|
+
try {
|
|
117
|
+
result = await encode(input, outPath, plan, info, (p) => progress.update(p));
|
|
118
|
+
} finally {
|
|
119
|
+
progress.finish();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let quality = { ssim: null, vmaf: null };
|
|
123
|
+
if (flags.verify && !flags.noVerify) {
|
|
124
|
+
if (!flags.json) process.stdout.write(` measuring quality `);
|
|
125
|
+
quality = await measureQuality(input, outPath, info.duration);
|
|
126
|
+
if (!flags.json) process.stdout.write(`\r${" ".repeat(40)}\r`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const summary = {
|
|
130
|
+
input: { file: input, bytes: info.sizeBytes, ...info.video, duration: info.duration },
|
|
131
|
+
output: { file: outPath, bytes: result.outputBytes },
|
|
132
|
+
targetBytes,
|
|
133
|
+
accuracy: targetBytes ? result.outputBytes / targetBytes - 1 : null,
|
|
134
|
+
ratio: info.sizeBytes / result.outputBytes,
|
|
135
|
+
saved: info.sizeBytes - result.outputBytes,
|
|
136
|
+
plan,
|
|
137
|
+
quality,
|
|
138
|
+
verdict: verdict(quality),
|
|
139
|
+
elapsedMs: result.elapsedMs,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (flags.json) {
|
|
143
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
printResult(summary, outPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function defaultOutput(input, flags, plan) {
|
|
151
|
+
const dir = path.dirname(input);
|
|
152
|
+
const ext = path.extname(input);
|
|
153
|
+
const base = path.basename(input, ext);
|
|
154
|
+
const tag = flags.to ? String(flags.to).replace(/\s+/g, "").toLowerCase() : `crf${flags.crf}`;
|
|
155
|
+
return path.join(dir, `${base}.${tag}.mp4`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function line(label, value) {
|
|
159
|
+
console.log(` ${label.padEnd(10)} ${value}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function printPlan(info, plan, flags, outPath) {
|
|
163
|
+
console.log("");
|
|
164
|
+
console.log(` halfsize ${VERSION}`);
|
|
165
|
+
console.log("");
|
|
166
|
+
console.log(` ${path.basename(info.file)}`);
|
|
167
|
+
console.log(
|
|
168
|
+
` ${bytes(info.sizeBytes)} ${duration(info.duration)} ${info.video.width}x${info.video.height} ${info.video.fps.toFixed(2)} fps ${info.video.codec}`,
|
|
169
|
+
);
|
|
170
|
+
console.log("");
|
|
171
|
+
if (plan.mode === "size") line("target", bytes(plan.targetBytes));
|
|
172
|
+
else line("mode", `CRF ${plan.crf}`);
|
|
173
|
+
line(
|
|
174
|
+
"plan",
|
|
175
|
+
`${plan.label} ${plan.width}x${plan.height} ${plan.videoKbps ? bitrate(plan.videoKbps) : "auto"} audio ${plan.audioKbps || 0} kbps ${plan.twoPass ? "2-pass" : "1-pass"}`,
|
|
176
|
+
);
|
|
177
|
+
if (plan.downscaled) line("scale", `${info.video.width}x${info.video.height} -> ${plan.width}x${plan.height}`);
|
|
178
|
+
if (plan.mode === "size") {
|
|
179
|
+
const est = estimateBytes(info, plan);
|
|
180
|
+
line("estimate", `${bytes(est)} (${ratio(info.sizeBytes, est)} smaller)`);
|
|
181
|
+
}
|
|
182
|
+
line("output", path.basename(outPath));
|
|
183
|
+
for (const warning of plan.warnings) console.log(` ! ${warning}`);
|
|
184
|
+
console.log("");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
class Progress {
|
|
188
|
+
constructor(enabled, total) {
|
|
189
|
+
this.enabled = enabled;
|
|
190
|
+
this.total = total;
|
|
191
|
+
this.lastPrint = 0;
|
|
192
|
+
this.start = Date.now();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
update({ ratio, speed }) {
|
|
196
|
+
if (!this.enabled) return;
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
if (now - this.lastPrint < 120 && ratio < 1) return;
|
|
199
|
+
this.lastPrint = now;
|
|
200
|
+
const width = 28;
|
|
201
|
+
const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
|
|
202
|
+
const bar = "=".repeat(filled) + (filled < width ? ">" : "") + " ".repeat(Math.max(0, width - filled - 1));
|
|
203
|
+
const elapsed = (now - this.start) / 1000;
|
|
204
|
+
const eta = ratio > 0.02 ? (elapsed / ratio) * (1 - ratio) : 0;
|
|
205
|
+
const speedText = String(speed || "").replace("x", "");
|
|
206
|
+
process.stdout.write(
|
|
207
|
+
`\r [${bar}] ${String(Math.round(ratio * 100)).padStart(3)}% ${speedText ? `${speedText}x` : ""} eta ${clock(eta)} `,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
finish() {
|
|
212
|
+
if (!this.enabled) return;
|
|
213
|
+
process.stdout.write(`\r${" ".repeat(72)}\r`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function printResult(summary, outPath) {
|
|
218
|
+
const accuracy =
|
|
219
|
+
summary.accuracy === null ? "" : `${Math.abs(summary.accuracy * 100).toFixed(1)}% ${summary.accuracy > 0 ? "over" : "under"} target`;
|
|
220
|
+
console.log(` done in ${duration(summary.elapsedMs / 1000)}`);
|
|
221
|
+
console.log("");
|
|
222
|
+
console.log(
|
|
223
|
+
` ${bytes(summary.input.bytes)} -> ${bytes(summary.output.bytes)} ${ratio(summary.input.bytes, summary.output.bytes)} smaller ${accuracy}`,
|
|
224
|
+
);
|
|
225
|
+
if (summary.quality.vmaf !== null || summary.quality.ssim !== null) {
|
|
226
|
+
const parts = [];
|
|
227
|
+
if (summary.quality.vmaf !== null) parts.push(`VMAF ${summary.quality.vmaf.toFixed(1)}`);
|
|
228
|
+
if (summary.quality.ssim !== null) parts.push(`SSIM ${summary.quality.ssim.toFixed(3)}`);
|
|
229
|
+
parts.push(summary.verdict.label);
|
|
230
|
+
console.log(` ${parts.join(" ")}`);
|
|
231
|
+
}
|
|
232
|
+
console.log("");
|
|
233
|
+
console.log(` -> ${outPath}`);
|
|
234
|
+
console.log("");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function doctor() {
|
|
238
|
+
const { resolveBinaries } = await import("./ffmpeg.mjs");
|
|
239
|
+
const { execFile } = await import("node:child_process");
|
|
240
|
+
const { promisify } = await import("node:util");
|
|
241
|
+
const run = promisify(execFile);
|
|
242
|
+
|
|
243
|
+
console.log("");
|
|
244
|
+
console.log(` halfsize ${VERSION} doctor`);
|
|
245
|
+
console.log("");
|
|
246
|
+
try {
|
|
247
|
+
const bins = await resolveBinaries();
|
|
248
|
+
const { stdout } = await run(bins.ffmpeg, ["-version"]);
|
|
249
|
+
console.log(` ffmpeg ${bins.ffmpeg}`);
|
|
250
|
+
console.log(` source ${bins.source}`);
|
|
251
|
+
console.log(` version ${stdout.split("\n")[0].replace("ffmpeg version ", "")}`);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.log(` ffmpeg missing`);
|
|
254
|
+
console.log(` ${err.message.split("\n")[0]}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const bins = await resolveBinaries();
|
|
260
|
+
const { stdout } = await run(bins.ffmpeg, ["-hide_banner", "-encoders"]);
|
|
261
|
+
const encoders = ["libx264", "libx265", "h264_nvenc", "hevc_nvenc", "h264_qsv", "h264_amf"];
|
|
262
|
+
console.log("");
|
|
263
|
+
for (const name of encoders) {
|
|
264
|
+
console.log(` encoder ${name.padEnd(12)} ${stdout.includes(name) ? "yes" : "no"}`);
|
|
265
|
+
}
|
|
266
|
+
const { stdout: filters } = await run(bins.ffmpeg, ["-hide_banner", "-filters"]);
|
|
267
|
+
console.log(` filter ${"libvmaf".padEnd(12)} ${/\blibvmaf\b/.test(filters) ? "yes" : "no"}`);
|
|
268
|
+
} catch {
|
|
269
|
+
// ignore
|
|
270
|
+
}
|
|
271
|
+
console.log("");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function startWeb() {
|
|
275
|
+
const { startServer } = await import("./server.mjs");
|
|
276
|
+
const url = await startServer();
|
|
277
|
+
console.log("");
|
|
278
|
+
console.log(` halfsize is running at ${url}`);
|
|
279
|
+
console.log(" Press Ctrl+C to stop.");
|
|
280
|
+
console.log("");
|
|
281
|
+
if (!process.env.HALFSIZE_NO_OPEN) openBrowser(url);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function openBrowser(url) {
|
|
285
|
+
const [command, args] =
|
|
286
|
+
process.platform === "win32"
|
|
287
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
288
|
+
: process.platform === "darwin"
|
|
289
|
+
? ["open", [url]]
|
|
290
|
+
: ["xdg-open", [url]];
|
|
291
|
+
try {
|
|
292
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
293
|
+
child.on("error", () => {});
|
|
294
|
+
child.unref();
|
|
295
|
+
} catch {
|
|
296
|
+
// The URL is already printed; opening a browser is a convenience.
|
|
297
|
+
}
|
|
298
|
+
}
|
package/src/encode.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { resolveBinaries } from "./ffmpeg.mjs";
|
|
6
|
+
|
|
7
|
+
const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
8
|
+
|
|
9
|
+
function buildFilters(plan) {
|
|
10
|
+
const filters = [];
|
|
11
|
+
if (plan.scaleFilter) filters.push(plan.scaleFilter);
|
|
12
|
+
if (plan.fps) filters.push(`fps=${plan.fps}`);
|
|
13
|
+
return filters.length ? filters.join(",") : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function baseArgs(input, plan, passlog) {
|
|
17
|
+
const args = ["-hide_banner", "-y", "-i", input];
|
|
18
|
+
const filters = buildFilters(plan);
|
|
19
|
+
if (filters) args.push("-vf", filters);
|
|
20
|
+
args.push("-fps_mode", "cfr");
|
|
21
|
+
args.push("-c:v", plan.encoder, "-preset", plan.preset);
|
|
22
|
+
if (plan.codec === "h265") args.push("-x265-params", "log-level=error");
|
|
23
|
+
if (plan.codec === "h265") args.push("-tag:v", "hvc1");
|
|
24
|
+
args.push("-pix_fmt", "yuv420p");
|
|
25
|
+
if (passlog) args.push("-passlogfile", passlog);
|
|
26
|
+
return args;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function audioArgs(plan) {
|
|
30
|
+
if (!plan.audioKbps) return ["-an"];
|
|
31
|
+
return ["-c:a", "aac", "-b:a", `${plan.audioKbps}k`, "-ac", "2"];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function encode(input, output, plan, info, onProgress = () => {}) {
|
|
35
|
+
const { ffmpeg } = await resolveBinaries();
|
|
36
|
+
const passlog = path.join(os.tmpdir(), `halfsize-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
37
|
+
const started = Date.now();
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (plan.mode === "size" && plan.twoPass) {
|
|
41
|
+
const pass1 = [
|
|
42
|
+
...baseArgs(input, plan, passlog),
|
|
43
|
+
"-b:v", `${plan.videoKbps}k`,
|
|
44
|
+
"-pass", "1",
|
|
45
|
+
"-an",
|
|
46
|
+
"-f", "null",
|
|
47
|
+
NULL_DEVICE,
|
|
48
|
+
];
|
|
49
|
+
await runFfmpeg(ffmpeg, pass1, info.duration, () => {});
|
|
50
|
+
const pass2 = [
|
|
51
|
+
...baseArgs(input, plan, passlog),
|
|
52
|
+
"-b:v", `${plan.videoKbps}k`,
|
|
53
|
+
"-pass", "2",
|
|
54
|
+
...audioArgs(plan),
|
|
55
|
+
"-movflags", "+faststart",
|
|
56
|
+
output,
|
|
57
|
+
];
|
|
58
|
+
await runFfmpeg(ffmpeg, pass2, info.duration, onProgress);
|
|
59
|
+
} else {
|
|
60
|
+
const args = [...baseArgs(input, plan, null)];
|
|
61
|
+
if (plan.mode === "size") {
|
|
62
|
+
args.push("-b:v", `${plan.videoKbps}k`, "-maxrate", `${Math.round(plan.videoKbps * 1.2)}k`, "-bufsize", `${plan.videoKbps * 2}k`);
|
|
63
|
+
} else {
|
|
64
|
+
args.push("-crf", String(plan.crf));
|
|
65
|
+
}
|
|
66
|
+
args.push(...audioArgs(plan), "-movflags", "+faststart", output);
|
|
67
|
+
await runFfmpeg(ffmpeg, args, info.duration, onProgress);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
cleanup(passlog);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
outputBytes: fs.statSync(output).size,
|
|
75
|
+
elapsedMs: Date.now() - started,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cleanup(passlog) {
|
|
80
|
+
try {
|
|
81
|
+
const dir = path.dirname(passlog);
|
|
82
|
+
const base = path.basename(passlog);
|
|
83
|
+
for (const name of fs.readdirSync(dir)) {
|
|
84
|
+
if (name.startsWith(base)) fs.unlinkSync(path.join(dir, name));
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// best effort
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function runFfmpeg(bin, args, duration, onProgress) {
|
|
92
|
+
if (process.env.HALFSIZE_DEBUG_FFMPEG) console.error(`\n[ffmpeg] ${bin} ${args.join(" ")}\n`);
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const child = spawn(bin, ["-progress", "pipe:1", "-nostats", ...args], {
|
|
95
|
+
windowsHide: true,
|
|
96
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
let stderr = "";
|
|
100
|
+
let buffer = "";
|
|
101
|
+
let last = {};
|
|
102
|
+
|
|
103
|
+
child.stdout.on("data", (chunk) => {
|
|
104
|
+
buffer += chunk.toString();
|
|
105
|
+
const lines = buffer.split(/\r?\n/);
|
|
106
|
+
buffer = lines.pop() || "";
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const index = line.indexOf("=");
|
|
109
|
+
if (index === -1) continue;
|
|
110
|
+
last[line.slice(0, index)] = line.slice(index + 1);
|
|
111
|
+
if (line.startsWith("out_time=")) {
|
|
112
|
+
const seconds = parseTime(last.out_time);
|
|
113
|
+
onProgress({
|
|
114
|
+
seconds,
|
|
115
|
+
total: duration,
|
|
116
|
+
ratio: duration ? Math.min(1, seconds / duration) : 0,
|
|
117
|
+
speed: last.speed || "",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
child.stderr.on("data", (chunk) => {
|
|
124
|
+
stderr += chunk.toString();
|
|
125
|
+
if (stderr.length > 20000) stderr = stderr.slice(-20000);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
child.on("error", reject);
|
|
129
|
+
child.on("close", (code) => {
|
|
130
|
+
if (code === 0) return resolve();
|
|
131
|
+
const tail = stderr.split(/\r?\n/).filter(Boolean).slice(-12).join("\n");
|
|
132
|
+
reject(new Error(`FFmpeg exited with code ${code}\n${tail}`));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseTime(value) {
|
|
138
|
+
if (!value) return 0;
|
|
139
|
+
const [hms, frac = "0"] = value.split(".");
|
|
140
|
+
const [h, m, s] = hms.split(":").map(Number);
|
|
141
|
+
return h * 3600 + m * 60 + s + Number(`0.${frac}`);
|
|
142
|
+
}
|
package/src/ffmpeg.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
let cache = null;
|
|
4
|
+
|
|
5
|
+
function works(bin) {
|
|
6
|
+
try {
|
|
7
|
+
execFileSync(bin, ["-version"], { stdio: "ignore", timeout: 6000, windowsHide: true });
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function bundled(spec) {
|
|
15
|
+
try {
|
|
16
|
+
const mod = await import(spec);
|
|
17
|
+
const value = mod.default ?? mod;
|
|
18
|
+
return typeof value === "string" ? value : value?.path || "";
|
|
19
|
+
} catch {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function resolveBinaries() {
|
|
25
|
+
if (cache) return cache;
|
|
26
|
+
|
|
27
|
+
const envFfmpeg = process.env.HALFSIZE_FFMPEG || "";
|
|
28
|
+
const envFfprobe = process.env.HALFSIZE_FFPROBE || "";
|
|
29
|
+
|
|
30
|
+
let ffmpeg = envFfmpeg && works(envFfmpeg) ? envFfmpeg : "";
|
|
31
|
+
let ffprobe = envFfprobe && works(envFfprobe) ? envFfprobe : "";
|
|
32
|
+
let source = ffmpeg ? "env" : "";
|
|
33
|
+
|
|
34
|
+
if (!ffmpeg && works("ffmpeg")) {
|
|
35
|
+
ffmpeg = "ffmpeg";
|
|
36
|
+
source = "path";
|
|
37
|
+
}
|
|
38
|
+
if (!ffprobe && works("ffprobe")) ffprobe = "ffprobe";
|
|
39
|
+
|
|
40
|
+
if (!ffmpeg) {
|
|
41
|
+
const bin = await bundled("ffmpeg-static");
|
|
42
|
+
if (bin && works(bin)) {
|
|
43
|
+
ffmpeg = bin;
|
|
44
|
+
source = "bundled";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!ffprobe) {
|
|
48
|
+
const bin = await bundled("ffprobe-static");
|
|
49
|
+
if (bin && works(bin)) ffprobe = bin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!ffmpeg || !ffprobe) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
[
|
|
55
|
+
"FFmpeg was not found.",
|
|
56
|
+
"",
|
|
57
|
+
" Install it once and halfsize will use it:",
|
|
58
|
+
" Windows winget install Gyan.FFmpeg",
|
|
59
|
+
" macOS brew install ffmpeg",
|
|
60
|
+
" Linux sudo apt install ffmpeg",
|
|
61
|
+
"",
|
|
62
|
+
" Or point at your own build:",
|
|
63
|
+
" HALFSIZE_FFMPEG=/path/to/ffmpeg HALFSIZE_FFPROBE=/path/to/ffprobe",
|
|
64
|
+
].join("\n"),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cache = { ffmpeg, ffprobe, source };
|
|
69
|
+
return cache;
|
|
70
|
+
}
|
package/src/human.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function bytes(n) {
|
|
2
|
+
if (n === null || n === undefined) return "-";
|
|
3
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
4
|
+
let value = n;
|
|
5
|
+
let unit = 0;
|
|
6
|
+
while (value >= 1000 && unit < units.length - 1) {
|
|
7
|
+
value /= 1000;
|
|
8
|
+
unit++;
|
|
9
|
+
}
|
|
10
|
+
const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
11
|
+
return `${value.toFixed(digits)} ${units[unit]}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function duration(seconds) {
|
|
15
|
+
if (!seconds || !Number.isFinite(seconds)) return "-";
|
|
16
|
+
const s = Math.round(seconds);
|
|
17
|
+
const h = Math.floor(s / 3600);
|
|
18
|
+
const m = Math.floor((s % 3600) / 60);
|
|
19
|
+
const sec = s % 60;
|
|
20
|
+
if (h) return `${h}h ${String(m).padStart(2, "0")}m`;
|
|
21
|
+
if (m) return `${m}m ${String(sec).padStart(2, "0")}s`;
|
|
22
|
+
return `${sec}s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function bitrate(kbps) {
|
|
26
|
+
if (!kbps) return "-";
|
|
27
|
+
return kbps >= 1000 ? `${(kbps / 1000).toFixed(2)} Mbps` : `${Math.round(kbps)} kbps`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ratio(from, to) {
|
|
31
|
+
if (!from || !to) return "-";
|
|
32
|
+
const value = from / to;
|
|
33
|
+
return value >= 10 ? `${Math.round(value)}x` : `${value.toFixed(1)}x`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseSize(input) {
|
|
37
|
+
if (typeof input === "number") return input;
|
|
38
|
+
const match = String(input)
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.match(/^([0-9]*\.?[0-9]+)\s*(b|kb|k|mb|m|gb|g)?$/);
|
|
42
|
+
if (!match) throw new Error(`Cannot read size "${input}". Try 25mb, 700k or 1.5gb.`);
|
|
43
|
+
const value = Number(match[1]);
|
|
44
|
+
const unit = match[2] || "mb";
|
|
45
|
+
const scale = { b: 1, k: 1000, kb: 1000, m: 1000 ** 2, mb: 1000 ** 2, g: 1000 ** 3, gb: 1000 ** 3 }[unit];
|
|
46
|
+
return Math.round(value * scale);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clock(seconds) {
|
|
50
|
+
const s = Math.max(0, Math.round(seconds));
|
|
51
|
+
const m = Math.floor(s / 60);
|
|
52
|
+
return `${String(m).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
|
53
|
+
}
|