nixamp 0.5.9 → 0.5.10
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/dist/audio.d.ts +25 -0
- package/dist/audio.js +65 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +26 -6
- package/package.json +1 -1
- package/src/audio.ts +78 -0
- package/src/server.ts +36 -6
- package/web/dist/sw.js +1 -1
package/dist/audio.d.ts
CHANGED
|
@@ -58,3 +58,28 @@ export declare function peaks(pcm: Float32Array): [number, number];
|
|
|
58
58
|
/** Interleaved stereo down to mono, for the analyser. */
|
|
59
59
|
export declare function toMono(pcm: Float32Array): Float32Array;
|
|
60
60
|
export declare function formatTime(seconds: number): string;
|
|
61
|
+
/** What is actually inside a container, as opposed to what the name suggests. */
|
|
62
|
+
export interface Codecs {
|
|
63
|
+
/** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
|
|
64
|
+
video: string;
|
|
65
|
+
/** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
|
|
66
|
+
audio: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Ask ffprobe what the streams are, without holding the event loop.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately not the spawnSync `probe` above: this one runs while a server is
|
|
72
|
+
* answering other requests, and a synchronous probe per media request is how
|
|
73
|
+
* the whole library came to be tagged with the process wedged solid.
|
|
74
|
+
*/
|
|
75
|
+
export declare function codecsOf(tools: Tools, path: string): Promise<Codecs>;
|
|
76
|
+
/**
|
|
77
|
+
* How to get this file into a browser, given what is inside it.
|
|
78
|
+
*
|
|
79
|
+
* A container a browser will not open says nothing about the streams within:
|
|
80
|
+
* most Matroska holds H.264, which every browser decodes, and only the wrapper
|
|
81
|
+
* is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
|
|
82
|
+
* would cost a core per viewer and look worse. So the streams decide, one part
|
|
83
|
+
* at a time -- a film can have its video copied and only its DTS re-encoded.
|
|
84
|
+
*/
|
|
85
|
+
export declare function videoArgs(codecs: Codecs): string[];
|
package/dist/audio.js
CHANGED
|
@@ -204,3 +204,68 @@ export function formatTime(seconds) {
|
|
|
204
204
|
const s = total % 60;
|
|
205
205
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
206
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Ask ffprobe what the streams are, without holding the event loop.
|
|
209
|
+
*
|
|
210
|
+
* Deliberately not the spawnSync `probe` above: this one runs while a server is
|
|
211
|
+
* answering other requests, and a synchronous probe per media request is how
|
|
212
|
+
* the whole library came to be tagged with the process wedged solid.
|
|
213
|
+
*/
|
|
214
|
+
export async function codecsOf(tools, path) {
|
|
215
|
+
const [cmd, ...rest] = tools.ffprobe;
|
|
216
|
+
const empty = { video: "", audio: "" };
|
|
217
|
+
if (!cmd)
|
|
218
|
+
return empty;
|
|
219
|
+
return new Promise((done) => {
|
|
220
|
+
const child = spawn(cmd, [
|
|
221
|
+
...rest,
|
|
222
|
+
"-v", "quiet",
|
|
223
|
+
"-print_format", "json",
|
|
224
|
+
"-show_entries", "stream=codec_type,codec_name",
|
|
225
|
+
path,
|
|
226
|
+
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
227
|
+
let out = "";
|
|
228
|
+
child.stdout.on("data", (chunk) => {
|
|
229
|
+
out += chunk.toString("utf8");
|
|
230
|
+
});
|
|
231
|
+
child.on("error", () => done(empty));
|
|
232
|
+
child.on("close", () => {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(out);
|
|
235
|
+
const streams = parsed.streams ?? [];
|
|
236
|
+
return done({
|
|
237
|
+
video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
|
|
238
|
+
audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return done(empty);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* How to get this file into a browser, given what is inside it.
|
|
249
|
+
*
|
|
250
|
+
* A container a browser will not open says nothing about the streams within:
|
|
251
|
+
* most Matroska holds H.264, which every browser decodes, and only the wrapper
|
|
252
|
+
* is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
|
|
253
|
+
* would cost a core per viewer and look worse. So the streams decide, one part
|
|
254
|
+
* at a time -- a film can have its video copied and only its DTS re-encoded.
|
|
255
|
+
*/
|
|
256
|
+
export function videoArgs(codecs) {
|
|
257
|
+
// What a browser can play inside MP4 without help.
|
|
258
|
+
const keepVideo = codecs.video === "h264";
|
|
259
|
+
const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
|
|
260
|
+
return [
|
|
261
|
+
"-c:v", keepVideo ? "copy" : "libx264",
|
|
262
|
+
...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
|
|
263
|
+
"-c:a", keepAudio ? "copy" : "aac",
|
|
264
|
+
...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
|
|
265
|
+
"-f", "mp4",
|
|
266
|
+
// Fragmented, because this is a pipe: a normal MP4 writes its index at the
|
|
267
|
+
// end, which for a stream never arrives and for a browser means nothing
|
|
268
|
+
// plays at all.
|
|
269
|
+
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
|
270
|
+
];
|
|
271
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -205,6 +205,8 @@ export interface HandlerOptions {
|
|
|
205
205
|
listenKey?: string | null;
|
|
206
206
|
/** How to run ffmpeg, for the sources a browser cannot play by itself. */
|
|
207
207
|
ffmpeg?: string[];
|
|
208
|
+
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
209
|
+
ffprobe?: string[];
|
|
208
210
|
/** Who is listening, for the admin view. */
|
|
209
211
|
connections?: Connections;
|
|
210
212
|
/**
|
package/dist/server.js
CHANGED
|
@@ -35,6 +35,7 @@ import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
|
35
35
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
36
36
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
37
37
|
import { isRemote, playsInBrowser } from "./sources.js";
|
|
38
|
+
import { codecsOf, videoArgs } from "./audio.js";
|
|
38
39
|
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
39
40
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
40
41
|
import { fileURLToPath } from "node:url";
|
|
@@ -1544,10 +1545,19 @@ export function createHandler(engine, options) {
|
|
|
1544
1545
|
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
1545
1546
|
// and transcoding gives it up, but an unseekable film beats a silent
|
|
1546
1547
|
// one -- and the seekable formats are untouched.
|
|
1547
|
-
if (playsInBrowser(file))
|
|
1548
|
+
if (playsInBrowser(file)) {
|
|
1548
1549
|
sendFile(request, response, file);
|
|
1549
|
-
|
|
1550
|
+
}
|
|
1551
|
+
else if (hasPicture(file)) {
|
|
1552
|
+
// A film. It used to arrive as MP3 with `-vn`, which is to say as a
|
|
1553
|
+
// soundtrack over a blank panel; what ffprobe finds inside decides how
|
|
1554
|
+
// little work it takes to keep the picture.
|
|
1555
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
1556
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
|
|
1557
|
+
}
|
|
1558
|
+
else {
|
|
1550
1559
|
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
1560
|
+
}
|
|
1551
1561
|
return;
|
|
1552
1562
|
}
|
|
1553
1563
|
// Whatever the source is, this comes back as MP3 a browser will play:
|
|
@@ -1748,7 +1758,18 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
1748
1758
|
* player falls back to /api/media for a local file it can seek, and uses this
|
|
1749
1759
|
* for everything else.
|
|
1750
1760
|
*/
|
|
1761
|
+
/** Audio, from whatever this is: the shape every non-browser source took. */
|
|
1751
1762
|
function transcode(request, response, source, ffmpeg) {
|
|
1763
|
+
pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Run ffmpeg and hand its output straight to the caller.
|
|
1767
|
+
*
|
|
1768
|
+
* The output arguments belong to the caller, because the same plumbing carries
|
|
1769
|
+
* a film and a song and the only difference is what ffmpeg is asked to write --
|
|
1770
|
+
* which for a film is decided by what ffprobe found inside it.
|
|
1771
|
+
*/
|
|
1772
|
+
function pipeFfmpeg(request, response, source, ffmpeg, outputArgs, contentType) {
|
|
1752
1773
|
const [command, ...prefix] = ffmpeg;
|
|
1753
1774
|
const child = spawn(command, [
|
|
1754
1775
|
...prefix,
|
|
@@ -1759,9 +1780,7 @@ function transcode(request, response, source, ffmpeg) {
|
|
|
1759
1780
|
// when they are handed to it for a file on disk.
|
|
1760
1781
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
1761
1782
|
"-i", source,
|
|
1762
|
-
|
|
1763
|
-
"-f", "mp3",
|
|
1764
|
-
"-b:a", "192k",
|
|
1783
|
+
...outputArgs,
|
|
1765
1784
|
"-",
|
|
1766
1785
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1767
1786
|
let failed = "";
|
|
@@ -1776,7 +1795,7 @@ function transcode(request, response, source, ffmpeg) {
|
|
|
1776
1795
|
started = true;
|
|
1777
1796
|
response.writeHead(200, {
|
|
1778
1797
|
...CORS,
|
|
1779
|
-
"content-type":
|
|
1798
|
+
"content-type": contentType,
|
|
1780
1799
|
"cache-control": "no-store",
|
|
1781
1800
|
// Length is unknowable up front, and a browser is happy without it.
|
|
1782
1801
|
"transfer-encoding": "chunked",
|
|
@@ -2094,6 +2113,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2094
2113
|
connections,
|
|
2095
2114
|
paywall,
|
|
2096
2115
|
ffmpeg: tools.ffmpeg,
|
|
2116
|
+
ffprobe: tools.ffprobe,
|
|
2097
2117
|
load: (next) => loadSource(tools, next),
|
|
2098
2118
|
...(directory ? { directory } : {}),
|
|
2099
2119
|
...(follows ? { follows, vapidPublicKey } : {}),
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -231,3 +231,81 @@ export function formatTime(seconds: number): string {
|
|
|
231
231
|
const s = total % 60;
|
|
232
232
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
233
233
|
}
|
|
234
|
+
|
|
235
|
+
/** What is actually inside a container, as opposed to what the name suggests. */
|
|
236
|
+
export interface Codecs {
|
|
237
|
+
/** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
|
|
238
|
+
video: string;
|
|
239
|
+
/** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
|
|
240
|
+
audio: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Ask ffprobe what the streams are, without holding the event loop.
|
|
245
|
+
*
|
|
246
|
+
* Deliberately not the spawnSync `probe` above: this one runs while a server is
|
|
247
|
+
* answering other requests, and a synchronous probe per media request is how
|
|
248
|
+
* the whole library came to be tagged with the process wedged solid.
|
|
249
|
+
*/
|
|
250
|
+
export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
251
|
+
const [cmd, ...rest] = tools.ffprobe;
|
|
252
|
+
const empty: Codecs = { video: "", audio: "" };
|
|
253
|
+
if (!cmd) return empty;
|
|
254
|
+
|
|
255
|
+
return new Promise<Codecs>((done) => {
|
|
256
|
+
const child = spawn(
|
|
257
|
+
cmd,
|
|
258
|
+
[
|
|
259
|
+
...rest,
|
|
260
|
+
"-v", "quiet",
|
|
261
|
+
"-print_format", "json",
|
|
262
|
+
"-show_entries", "stream=codec_type,codec_name",
|
|
263
|
+
path,
|
|
264
|
+
],
|
|
265
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
266
|
+
);
|
|
267
|
+
let out = "";
|
|
268
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
269
|
+
out += chunk.toString("utf8");
|
|
270
|
+
});
|
|
271
|
+
child.on("error", () => done(empty));
|
|
272
|
+
child.on("close", () => {
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(out) as { streams?: { codec_type?: string; codec_name?: string }[] };
|
|
275
|
+
const streams = parsed.streams ?? [];
|
|
276
|
+
return done({
|
|
277
|
+
video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
|
|
278
|
+
audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
|
|
279
|
+
});
|
|
280
|
+
} catch {
|
|
281
|
+
return done(empty);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* How to get this file into a browser, given what is inside it.
|
|
289
|
+
*
|
|
290
|
+
* A container a browser will not open says nothing about the streams within:
|
|
291
|
+
* most Matroska holds H.264, which every browser decodes, and only the wrapper
|
|
292
|
+
* is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
|
|
293
|
+
* would cost a core per viewer and look worse. So the streams decide, one part
|
|
294
|
+
* at a time -- a film can have its video copied and only its DTS re-encoded.
|
|
295
|
+
*/
|
|
296
|
+
export function videoArgs(codecs: Codecs): string[] {
|
|
297
|
+
// What a browser can play inside MP4 without help.
|
|
298
|
+
const keepVideo = codecs.video === "h264";
|
|
299
|
+
const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
|
|
300
|
+
return [
|
|
301
|
+
"-c:v", keepVideo ? "copy" : "libx264",
|
|
302
|
+
...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
|
|
303
|
+
"-c:a", keepAudio ? "copy" : "aac",
|
|
304
|
+
...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
|
|
305
|
+
"-f", "mp4",
|
|
306
|
+
// Fragmented, because this is a pipe: a normal MP4 writes its index at the
|
|
307
|
+
// end, which for a stream never arrives and for a browser means nothing
|
|
308
|
+
// plays at all.
|
|
309
|
+
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
|
310
|
+
];
|
|
311
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
paywallFromEnv,
|
|
56
56
|
} from "./paywall.ts";
|
|
57
57
|
import { isRemote, playsInBrowser } from "./sources.ts";
|
|
58
|
+
import { codecsOf, videoArgs } from "./audio.ts";
|
|
58
59
|
import {
|
|
59
60
|
allowedForListening,
|
|
60
61
|
elevate,
|
|
@@ -693,6 +694,8 @@ export interface HandlerOptions {
|
|
|
693
694
|
listenKey?: string | null;
|
|
694
695
|
/** How to run ffmpeg, for the sources a browser cannot play by itself. */
|
|
695
696
|
ffmpeg?: string[];
|
|
697
|
+
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
698
|
+
ffprobe?: string[];
|
|
696
699
|
/** Who is listening, for the admin view. */
|
|
697
700
|
connections?: Connections;
|
|
698
701
|
/**
|
|
@@ -1813,8 +1816,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1813
1816
|
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
1814
1817
|
// and transcoding gives it up, but an unseekable film beats a silent
|
|
1815
1818
|
// one -- and the seekable formats are untouched.
|
|
1816
|
-
if (playsInBrowser(file))
|
|
1817
|
-
|
|
1819
|
+
if (playsInBrowser(file)) {
|
|
1820
|
+
sendFile(request, response, file);
|
|
1821
|
+
} else if (hasPicture(file)) {
|
|
1822
|
+
// A film. It used to arrive as MP3 with `-vn`, which is to say as a
|
|
1823
|
+
// soundtrack over a blank panel; what ffprobe finds inside decides how
|
|
1824
|
+
// little work it takes to keep the picture.
|
|
1825
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
1826
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
|
|
1827
|
+
} else {
|
|
1828
|
+
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
1829
|
+
}
|
|
1818
1830
|
return;
|
|
1819
1831
|
}
|
|
1820
1832
|
|
|
@@ -2029,11 +2041,30 @@ function liveAudio(
|
|
|
2029
2041
|
* player falls back to /api/media for a local file it can seek, and uses this
|
|
2030
2042
|
* for everything else.
|
|
2031
2043
|
*/
|
|
2044
|
+
/** Audio, from whatever this is: the shape every non-browser source took. */
|
|
2032
2045
|
function transcode(
|
|
2033
2046
|
request: IncomingMessage,
|
|
2034
2047
|
response: ServerResponse,
|
|
2035
2048
|
source: string,
|
|
2036
2049
|
ffmpeg: string[],
|
|
2050
|
+
): void {
|
|
2051
|
+
pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Run ffmpeg and hand its output straight to the caller.
|
|
2056
|
+
*
|
|
2057
|
+
* The output arguments belong to the caller, because the same plumbing carries
|
|
2058
|
+
* a film and a song and the only difference is what ffmpeg is asked to write --
|
|
2059
|
+
* which for a film is decided by what ffprobe found inside it.
|
|
2060
|
+
*/
|
|
2061
|
+
function pipeFfmpeg(
|
|
2062
|
+
request: IncomingMessage,
|
|
2063
|
+
response: ServerResponse,
|
|
2064
|
+
source: string,
|
|
2065
|
+
ffmpeg: string[],
|
|
2066
|
+
outputArgs: string[],
|
|
2067
|
+
contentType: string,
|
|
2037
2068
|
): void {
|
|
2038
2069
|
const [command, ...prefix] = ffmpeg as [string, ...string[]];
|
|
2039
2070
|
const child = spawn(
|
|
@@ -2047,9 +2078,7 @@ function transcode(
|
|
|
2047
2078
|
// when they are handed to it for a file on disk.
|
|
2048
2079
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
2049
2080
|
"-i", source,
|
|
2050
|
-
|
|
2051
|
-
"-f", "mp3",
|
|
2052
|
-
"-b:a", "192k",
|
|
2081
|
+
...outputArgs,
|
|
2053
2082
|
"-",
|
|
2054
2083
|
],
|
|
2055
2084
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
@@ -2067,7 +2096,7 @@ function transcode(
|
|
|
2067
2096
|
started = true;
|
|
2068
2097
|
response.writeHead(200, {
|
|
2069
2098
|
...CORS,
|
|
2070
|
-
"content-type":
|
|
2099
|
+
"content-type": contentType,
|
|
2071
2100
|
"cache-control": "no-store",
|
|
2072
2101
|
// Length is unknowable up front, and a browser is happy without it.
|
|
2073
2102
|
"transfer-encoding": "chunked",
|
|
@@ -2404,6 +2433,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2404
2433
|
connections,
|
|
2405
2434
|
paywall,
|
|
2406
2435
|
ffmpeg: tools.ffmpeg,
|
|
2436
|
+
ffprobe: tools.ffprobe,
|
|
2407
2437
|
load: (next) => loadSource(tools, next),
|
|
2408
2438
|
...(directory ? { directory } : {}),
|
|
2409
2439
|
...(follows ? { follows, vapidPublicKey } : {}),
|
package/web/dist/sw.js
CHANGED