musicfit 0.2.0 → 0.2.2
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 +1 -0
- package/README.md +17 -10
- package/package.json +4 -4
- package/skills/musicfit/SKILL.md +2 -1
- package/skills/musicfit/scripts/lib/audio.js +364 -17
- package/skills/musicfit/scripts/lib/fit.js +4 -1
- package/skills/musicfit/scripts/lib/version.js +1 -1
- package/skills/musicfit/scripts/lib/video.js +4 -3
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -12,6 +12,15 @@ AI music models ignore the length you ask for. Ask for 30 seconds and you may ge
|
|
|
12
12
|
|
|
13
13
|
The track can come from any music model, and [Wiro](https://wiro.ai/explore/music-generation) has several.
|
|
14
14
|
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node.js 22 or newer.
|
|
18
|
+
- ffmpeg 4.4 or newer, Ubuntu 22.04's 4.4.2 included. musicfit does not use ffprobe, and needs no H.264 encoder, as `--video` copies the picture as it is. The filters it uses are ffmpeg's own, and so is the AAC encoder. `brew install ffmpeg` and `winget install ffmpeg` give a new enough one, and so does `sudo apt install ffmpeg` on Ubuntu 22.04, Debian 12 or newer. Set `FFMPEG_PATH` to use another build, as a path or a name on your PATH. Before any work, a run checks that ffmpeg starts and answers as ffmpeg; one that does not stops the run with what the system or the program said, and how to get one. One that gives no answer in two minutes, as on a machine short of memory, stops the run too, and the message says to run it again. It does not check the version number ffmpeg gives, so an ffmpeg older than 4.4 goes ahead, though no test has run on one. When the system is short of open files, processes or memory to start ffmpeg, the run says so and what to do.
|
|
19
|
+
- Linux, where it is tested: on Ubuntu 22.04 with its ffmpeg 4.4.2, on Ubuntu 24.04 with its ffmpeg 6.1 and on GitHub's ubuntu-latest runner, on every push and before every release. This release has also run on Ubuntu 26.04 with ffmpeg 8.0. It is written for macOS and Windows as well, but it has not been run on either.
|
|
20
|
+
- Room on the disk for the files of a run and those of the run before it, as the earlier files stay until the new ones are in place. `fitted.wav` and `mix.wav` take 17 MB each for every minute of the fit, and `video.mp4` as much as the video's picture and about 2 MB a minute of sound. Until a run with `--video` ends, it also keeps up to four AAC encodings of that sound, about 2 MB a minute each, in its hidden folder in the out folder, or next to it when the out folder is not there yet.
|
|
21
|
+
- Memory that grows with the length of the fit and of the track, and with that of a voice or of the video's own sound, which are read to their end even where they run on past the fit: a 10 minute fit of a 10 minute track took 0.9 to 1.7 GB, and a 20 second fit with a voice took 0.2 GB, or 0.8 GB when the voice ran on 10 minutes past it (see [Limits](#limits)).
|
|
22
|
+
- A run needs no network access, no browser and no API key: musicfit reads files on your disk, and nothing leaves your machine. Installing it needs the network, and `npx musicfit@latest` asks npm for the newest version each time it runs. There are no npm dependencies: the beat tracking is plain JavaScript.
|
|
23
|
+
|
|
15
24
|
## What you get
|
|
16
25
|
|
|
17
26
|
```
|
|
@@ -93,20 +102,20 @@ When another program holds a file open so that it cannot be moved, the run says
|
|
|
93
102
|
musicfit ships as an [Agent Skill](https://agentskills.io). Install it once and your agent runs it by itself when a video needs music that fits:
|
|
94
103
|
|
|
95
104
|
```bash
|
|
96
|
-
npx skills add
|
|
105
|
+
npx skills add MakersMCP/musicfit
|
|
97
106
|
```
|
|
98
107
|
|
|
99
108
|
That covers Claude Code, Codex, Cursor and the other agents the [`skills`](https://github.com/vercel-labs/skills) CLI supports. In Claude Code you can add it as a plugin instead:
|
|
100
109
|
|
|
101
110
|
```
|
|
102
|
-
/plugin marketplace add
|
|
111
|
+
/plugin marketplace add MakersMCP/musicfit
|
|
103
112
|
/plugin install musicfit@musicfit
|
|
104
113
|
```
|
|
105
114
|
|
|
106
115
|
In GitHub Copilot CLI it takes the same two steps:
|
|
107
116
|
|
|
108
117
|
```bash
|
|
109
|
-
copilot plugin marketplace add
|
|
118
|
+
copilot plugin marketplace add MakersMCP/musicfit
|
|
110
119
|
copilot plugin install musicfit@musicfit
|
|
111
120
|
```
|
|
112
121
|
|
|
@@ -172,9 +181,7 @@ npx musicfit@latest track.mp3 --length 32 --video talk.mp4 --video-sound voice
|
|
|
172
181
|
npx musicfit@latest track.mp3 --analyze
|
|
173
182
|
```
|
|
174
183
|
|
|
175
|
-
Installed globally (`npm install -g musicfit`), the command is `musicfit`. npx keeps the copy an earlier run fetched, so `npx musicfit@latest` is how you get a newer version.
|
|
176
|
-
|
|
177
|
-
It needs Node 22 or newer and ffmpeg 4.4 or newer (`brew install ffmpeg`, `sudo apt install ffmpeg` or `winget install ffmpeg`); the tests run on Ubuntu 22.04's ffmpeg 4.4 and on the latest Ubuntu's, before every release too. Set `FFMPEG_PATH` to pick the ffmpeg. There are no npm dependencies; the beat tracking is plain JavaScript and nothing leaves your machine.
|
|
184
|
+
Installed globally (`npm install -g musicfit`), the command is `musicfit`. npx keeps the copy an earlier run fetched, so `npx musicfit@latest` is how you get a newer version. What a run needs is under [Requirements](#requirements).
|
|
178
185
|
|
|
179
186
|
## How it works
|
|
180
187
|
|
|
@@ -197,15 +204,15 @@ It needs Node 22 or newer and ffmpeg 4.4 or newer (`brew install ffmpeg`, `sudo
|
|
|
197
204
|
- Seams follow bars and sound, not words: in a song with singing a cut can fall in the middle of a line. An instrumental fits best.
|
|
198
205
|
- Lengthening loops bars the track already has, so a 30 second track stretched to 3 minutes repeats, and so does the intro when the drop is asked for much later than the track has it (musicfit says how many times). Ask the music model for a little more than you need.
|
|
199
206
|
- `--video` copies the picture as it is, so a picture an MP4 cannot hold that way (ProRes, DNxHD, VP8) is refused rather than encoded again. Only the picture and the new sound go into `video.mp4`: subtitles and other streams of the video are left out, a note says so of subtitles, and of several sound streams only the first is read.
|
|
200
|
-
- A fit holds its sound in memory a few times over, so memory grows with `--length` and with the length of the track itself: a 10 minute fit of a 10 minute track took 0.9 to 1 GB, 1.2 GB with `--video` and its own sound kept, 1.4 to 1.5 GB with `--voice` and 1.7 GB with a voice and the video's own sound as well, on a desktop with ffmpeg 8 and in a 2 GB container with ffmpeg 4.4. A run takes a few seconds, and with `--video` of a long film a minute or more, most of it spent encoding the sound as AAC.
|
|
207
|
+
- A fit holds its sound in memory a few times over, so memory grows with `--length` and with the length of the track itself: a 10 minute fit of a 10 minute track took 0.9 to 1 GB, 1.2 GB with `--video` and its own sound kept, 1.4 to 1.5 GB with `--voice` and 1.7 GB with a voice and the video's own sound as well, on a desktop with ffmpeg 8 and in a 2 GB container with ffmpeg 4.4. A voice and the video's own sound are read to their end before they are cut to the fit, so memory grows with their length too: a 20 second fit took 0.2 GB with a voice as long as itself, 0.8 GB with one that ran on 10 minutes past it, and 0.6 GB with a video whose own sound ran on 10 minutes past its picture, on the desktop and in the container alike. A run takes a few seconds, and with `--video` of a long film a minute or more, most of it spent encoding the sound as AAC.
|
|
201
208
|
- musicfit cannot tell speech from sound effects or from music, which is why it asks for `--video-sound`. With `voice` the music dips under anything in that sound that stands out, a cough or a door as much as a word, and a hiss or a hum under a quiet voice comes up with it. With `keep` each place is set on its own, so a soft effect and a loud one over the same music come out alike, and a hiss or a hum between them comes up with the places around it.
|
|
202
209
|
- It does not change the tempo or the pitch, and it does not check whether you may use the track.
|
|
203
210
|
|
|
204
211
|
## See also
|
|
205
212
|
|
|
206
|
-
- [launch-film](https://github.com/
|
|
207
|
-
- [easyscreenshotkit](https://github.com/
|
|
208
|
-
- [easybrandkit](https://github.com/
|
|
213
|
+
- [launch-film](https://github.com/MakersMCP/launch-film) turns a site into a short launch film, rendered on your machine.
|
|
214
|
+
- [easyscreenshotkit](https://github.com/MakersMCP/easyscreenshotkit) saves clean screenshots of a website's pages.
|
|
215
|
+
- [easybrandkit](https://github.com/MakersMCP/easybrandkit) saves a site's real logo files, colors and fonts as a brand kit.
|
|
209
216
|
|
|
210
217
|
## License
|
|
211
218
|
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "musicfit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Fit a music track to a video cut: exactly the length you need, cut and looped on bar lines, the drop on the second you pick, a fade that ends on a beat, ducked under a voice, at the right loudness, and laid under the video when you give it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Emin Budak",
|
|
7
|
-
"homepage": "https://github.com/
|
|
7
|
+
"homepage": "https://github.com/MakersMCP/musicfit#readme",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/
|
|
10
|
+
"url": "git+https://github.com/MakersMCP/musicfit.git"
|
|
11
11
|
},
|
|
12
|
-
"bugs": "https://github.com/
|
|
12
|
+
"bugs": "https://github.com/MakersMCP/musicfit/issues",
|
|
13
13
|
"bin": {
|
|
14
14
|
"musicfit": "skills/musicfit/scripts/musicfit.js"
|
|
15
15
|
},
|
package/skills/musicfit/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ Music models ignore the length you ask for, and a track cut at the video's last
|
|
|
13
13
|
node <skill>/scripts/musicfit.js track.mp3 --length 32 --hit 27.5 --fps 30 --out audio/fit
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
`<skill>` is this skill's folder. The script needs Node 22 or newer and ffmpeg 4.4 or newer on the machine; it installs nothing and needs no API key. A run takes a few seconds, and with a long `--video` a minute or more. A
|
|
16
|
+
`<skill>` is this skill's folder. The script needs Node 22 or newer and ffmpeg 4.4 or newer on the machine; it installs nothing and needs no API key. A run takes a few seconds, and with a long `--video` a minute or more. A run needs memory, more the longer the fit and the track, and the longer a voice or the video's own sound, which are read to their end even where they run on past the fit: a 10 minute fit of a 10 minute track took 0.9 to 1 GB, 1.2 GB with `--video`, 1.4 to 1.5 GB with `--voice` and 1.7 GB with a voice and the video's own sound, and a 20 second fit took 0.8 GB with a voice that ran on 10 minutes past it.
|
|
17
17
|
|
|
18
18
|
- `--length` is the video's length: seconds (`32`), frames (`960f`, at `--fps`) or minutes (`1:05`).
|
|
19
19
|
- `--hit` is the second the drop should land on, for example the logo reveal. Leave it out and the track keeps its own ending; a short fit can then start later in the track, so its drop may not play (FIT.md says where it starts). Give `--hit` when the drop has to be heard.
|
|
@@ -59,4 +59,5 @@ A voice the video already holds goes with `--video-sound voice`, not as a copy g
|
|
|
59
59
|
- A run is refused before any work when its out folder holds, under a name the run writes, a file that is not as an earlier musicfit run wrote it there, such as the user's own `video.mp4` beside a copied `cues.json` for a run with `--video`, or a `FIT.md` or `cues.json` the user changed (move it, with the files it lists for a `cues.json`, or give another `--out`); a run that does not write that name leaves the file alone. It is refused too when the track, the voice or the video is one of the files the run would replace or remove.
|
|
60
60
|
- A run that fails or is stopped (Ctrl-C, or a signal such as SIGTERM) leaves its folder as it was, so a fit that worked is never lost to one that did not. As the earlier files stay until the new ones are in place, a run needs room on the disk for both, and a full disk says so. A run that ends well removes the earlier files it does not make again, and a note names them: tell the user when one was a fit's `video.mp4` or `fitted.wav`. A run killed outright after it began writing can leave a hidden `.musicfit-part-` folder, inside the out folder or next to it when the out folder was not there yet, which a later run that ends well in the same place removes once the killed run's process is gone and 15 minutes have passed (one it may not look into stays, and does not fail that run); a run killed in the moment it moved its files in is undone by the next run there, which puts the earlier files back first, and no other run removes its hidden folder. When a failed or stopped run could not remove its hidden folder, its message names it, and a later run removes it the same way. When another program held a file open so that a run could not put every earlier file back, the message names the folder they are in, and its `musicfit-kept.txt` says where each one belongs: tell the user.
|
|
61
61
|
- musicfit reads files: a track, a voice or a video given as an address is refused. Download it first.
|
|
62
|
+
- "musicfit needs ffmpeg" means the run stopped before any work, as it found no ffmpeg, or the one it found or `FFMPEG_PATH` names could not be started or did not answer as ffmpeg when asked its version. The message names that file and gives what the system said, or the first line the program printed and how it ended: tell the user, and ask them to install ffmpeg or to set `FFMPEG_PATH` to one that runs. "musicfit could not start ffmpeg" means the system could not start a program then, as when too many files are open, and "musicfit could not check ffmpeg" that ffmpeg gave no answer in two minutes when asked its version, as on a machine short of memory; the message says what to do.
|
|
62
63
|
- Never say a track is cleared for use; musicfit does not check rights.
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// floats, or as stereo { L, R }, which is interleaved a block at a time on
|
|
8
8
|
// its way, so no interleaved copy of a long sound is ever held whole.
|
|
9
9
|
|
|
10
|
+
const fs = require("node:fs");
|
|
10
11
|
const path = require("node:path");
|
|
12
|
+
const { getSystemErrorMap } = require("node:util");
|
|
11
13
|
const { spawn } = require("node:child_process");
|
|
12
14
|
const { CHUNK, breathe } = require("./breathe");
|
|
13
15
|
|
|
@@ -30,20 +32,30 @@ function ffmpegPath() {
|
|
|
30
32
|
return path.basename(named) === named ? named : whole(named);
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
// The variable that holds PATH and its entries, as the system reads them.
|
|
36
|
+
// Windows takes the variable's name in any case, and an entry in quotes,
|
|
37
|
+
// which may hold a ; of its own. No entries when there is no PATH.
|
|
38
|
+
function pathEntries() {
|
|
39
|
+
const windows = process.platform === "win32";
|
|
40
|
+
const key = windows ? Object.keys(process.env).find((name) => name.toUpperCase() === "PATH") : "PATH";
|
|
41
|
+
if (!key || process.env[key] === undefined) return { key, entries: null };
|
|
42
|
+
return { key, entries: windows ? process.env[key].match(/(?<=^|;)("[^"]*"?|'[^']*'?)?[^;]*/g) : process.env[key].split(path.posix.delimiter) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The bare folder of an entry of PATH written in quotes on Windows.
|
|
46
|
+
const unquoted = (entry) => entry.replace(/^["']|["']$/g, "");
|
|
47
|
+
|
|
33
48
|
// The environment for a call run in another folder: a relative entry of PATH
|
|
34
49
|
// reads from that folder when ffmpeg is looked up, so that entry is written
|
|
35
50
|
// from where musicfit was started, and the others stay as they are. An empty
|
|
36
|
-
// entry is the current folder too. Windows passes over an empty entry
|
|
37
|
-
// one in quotes, which may hold a ; of its own, and the variable's name in
|
|
38
|
-
// any case.
|
|
51
|
+
// entry is the current folder too. Windows passes over an empty entry.
|
|
39
52
|
function fromHere() {
|
|
40
53
|
const windows = process.platform === "win32";
|
|
41
54
|
const { delimiter, isAbsolute } = windows ? path.win32 : path.posix;
|
|
42
|
-
const key
|
|
43
|
-
if (!
|
|
44
|
-
const entries = windows ? process.env[key].match(/(?<=^|;)("[^"]*"?|'[^']*'?)?[^;]*/g) : process.env[key].split(delimiter);
|
|
55
|
+
const { key, entries } = pathEntries();
|
|
56
|
+
if (!entries) return process.env;
|
|
45
57
|
const written = entries.map((entry) => {
|
|
46
|
-
const bare = windows ? entry
|
|
58
|
+
const bare = windows ? unquoted(entry) : entry;
|
|
47
59
|
return isAbsolute(bare) || (windows && !bare) ? entry : whole(bare);
|
|
48
60
|
});
|
|
49
61
|
return { ...process.env, [key]: written.join(delimiter) };
|
|
@@ -54,6 +66,318 @@ function fromHere() {
|
|
|
54
66
|
// protocol, and never a full path.
|
|
55
67
|
const where = (file) => whole(file);
|
|
56
68
|
|
|
69
|
+
// Whether a file the run looked at before any work is not there now, as
|
|
70
|
+
// when it was taken away since: only then is a "No such file" that ffmpeg
|
|
71
|
+
// says while it reads it about that file. ffmpeg 7 and newer say it on a
|
|
72
|
+
// line that does not name the file.
|
|
73
|
+
function gone(file) {
|
|
74
|
+
try {
|
|
75
|
+
fs.statSync(where(file));
|
|
76
|
+
return false;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return err.code === "ENOENT" || err.code === "ENOTDIR";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// How each message that asks for an ffmpeg says to get one.
|
|
83
|
+
const INSTALL = "brew install ffmpeg, sudo apt install ffmpeg or winget install ffmpeg";
|
|
84
|
+
|
|
85
|
+
// Where the system looks for a program when there is no PATH: glibc in
|
|
86
|
+
// /bin, then /usr/bin, musl in /usr/local/bin first, and Node on macOS, as
|
|
87
|
+
// the BSDs do, in /usr/bin, then /bin.
|
|
88
|
+
function defaultFolders() {
|
|
89
|
+
if (process.platform !== "linux") return ["/usr/bin", "/bin"];
|
|
90
|
+
return process.report.getReport().header.glibcVersionRuntime ? ["/bin", "/usr/bin"] : ["/usr/local/bin", "/bin", "/usr/bin"];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The files the system tries, in its order, for the program FFMPEG_PATH
|
|
94
|
+
// names, or ffmpeg: that path when it has a folder in it, else that name in
|
|
95
|
+
// each folder of PATH, an empty or a relative one read from where musicfit
|
|
96
|
+
// was started, or with no PATH at all in the system's own folders. Windows
|
|
97
|
+
// looks in the current folder first and adds .com and .exe to the name,
|
|
98
|
+
// which it tries as it is only when it has an extension. A path with a
|
|
99
|
+
// folder in it comes first as it is all the same, so a file there with no
|
|
100
|
+
// extension is named for what it is (see barred()).
|
|
101
|
+
function places(program) {
|
|
102
|
+
const windows = process.platform === "win32";
|
|
103
|
+
if (path.basename(program) !== program) return [program, ...(windows ? [`${program}.com`, `${program}.exe`] : [])];
|
|
104
|
+
const { entries } = pathEntries();
|
|
105
|
+
const folders = windows ? ["", ...(entries || []).map(unquoted).filter(Boolean)] : entries || defaultFolders();
|
|
106
|
+
const names = windows ? [...(path.extname(program) ? [program] : []), `${program}.com`, `${program}.exe`] : [program];
|
|
107
|
+
return folders.flatMap((folder) => {
|
|
108
|
+
const at = whole(folder || ".");
|
|
109
|
+
return names.map((name) => `${at}${at.endsWith(path.sep) ? "" : path.sep}${name}`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The system's words for an error, such as "permission denied", else its code.
|
|
114
|
+
const worded = (err) => (getSystemErrorMap().get(err.errno) || [])[1] || err.code;
|
|
115
|
+
|
|
116
|
+
// The file a path goes through as if it were a folder, as notes.txt in
|
|
117
|
+
// notes.txt/ffmpeg, or null.
|
|
118
|
+
function fileOnTheWay(file) {
|
|
119
|
+
const parts = file.split(path.sep);
|
|
120
|
+
for (let i = 2; i < parts.length; i++) {
|
|
121
|
+
const part = parts.slice(0, i).join(path.sep);
|
|
122
|
+
try {
|
|
123
|
+
if (!fs.statSync(part).isDirectory()) return part;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// What keeps the system from running the file at a place, as far as can be
|
|
132
|
+
// told for certain before it runs: null when nothing does, else the words
|
|
133
|
+
// for it, and in `on` what the system does with it when it looks for a
|
|
134
|
+
// program on PATH. It "passes" over a folder, a file this user may not run
|
|
135
|
+
// and a link to nothing, and looks in the next folder; it passes over
|
|
136
|
+
// nothing there and a folder this user may not enter "quietly", as these
|
|
137
|
+
// may hold no such program at all; and it "stops" at a path it cannot
|
|
138
|
+
// follow, as a loop of links. Windows passes over a file whose name has no
|
|
139
|
+
// extension too, as it tries that name with .com and .exe added (see
|
|
140
|
+
// places()).
|
|
141
|
+
function barred(file) {
|
|
142
|
+
let stat;
|
|
143
|
+
try {
|
|
144
|
+
stat = fs.statSync(file);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
|
147
|
+
let link = false;
|
|
148
|
+
try {
|
|
149
|
+
link = fs.lstatSync(file).isSymbolicLink();
|
|
150
|
+
} catch {
|
|
151
|
+
// Nothing there at all.
|
|
152
|
+
}
|
|
153
|
+
if (link) return { on: "passes", words: "is a link to a file that is not there" };
|
|
154
|
+
const through = err.code === "ENOTDIR" && fileOnTheWay(file);
|
|
155
|
+
return { on: "quietly", words: through ? `where there is none, as ${through} is a file, not a folder` : "where there is none" };
|
|
156
|
+
}
|
|
157
|
+
if (err.code === "EACCES" || err.code === "EPERM") return { on: "quietly", words: "may not be run: permission denied" };
|
|
158
|
+
if (err.code === "ELOOP") return { on: "stops", words: "goes through a loop of links" };
|
|
159
|
+
return { on: "stops", words: `cannot be looked at: ${worded(err)}` };
|
|
160
|
+
}
|
|
161
|
+
if (stat.isDirectory()) return { on: "passes", words: "is a folder" };
|
|
162
|
+
if (process.platform === "win32" && !path.extname(file)) return { on: "passes", words: "has no .exe in its name, so Windows does not run it" };
|
|
163
|
+
try {
|
|
164
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
165
|
+
} catch {
|
|
166
|
+
return { on: "passes", words: (stat.mode & 0o111) === 0 ? "is not executable" : "may not be run: permission denied" };
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The errors that say the system is short of something to start a program
|
|
172
|
+
// with, not that ffmpeg is wrong: what it is short of, and what to do.
|
|
173
|
+
const SHORT = {
|
|
174
|
+
EMFILE: "too many open files. Raise the limit on open files (ulimit -n) and run it again",
|
|
175
|
+
ENFILE: "the system has too many files open. Run it again once fewer are",
|
|
176
|
+
EAGAIN: "the system starts no more processes for now. Run it again once fewer are running",
|
|
177
|
+
ENOMEM: "not enough memory. Free some and run it again",
|
|
178
|
+
E2BIG: "its environment is too large. Run it with fewer or shorter environment variables",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// The errors a start fails with that the system passes over, as glibc's
|
|
182
|
+
// execvp does, to try the next folder of PATH: a file that is not there or
|
|
183
|
+
// may not be run, or one whose interpreter or loader is not there.
|
|
184
|
+
const PASSED = new Set(["ENOENT", "EACCES", "ENOTDIR", "ESTALE", "ENODEV", "ETIMEDOUT"]);
|
|
185
|
+
|
|
186
|
+
// How long ffmpeg -version may take, in milliseconds: on a machine short
|
|
187
|
+
// of memory, with its swap full, ffmpeg 4.4 took more than 30 seconds.
|
|
188
|
+
const LIMIT = 120000;
|
|
189
|
+
|
|
190
|
+
// A line a program printed, in quotes, as it printed it, with its control
|
|
191
|
+
// characters written out, as \r or \x1b. A line longer than 200 characters
|
|
192
|
+
// keeps its first 120 and its last 80, as its end most often says why.
|
|
193
|
+
function quoted(line) {
|
|
194
|
+
const chars = Array.from(line).map((c) => {
|
|
195
|
+
if (c === "\t") return "\\t";
|
|
196
|
+
if (c === "\r") return "\\r";
|
|
197
|
+
if (!/[\p{Cc}\p{Cf}]/u.test(c)) return c;
|
|
198
|
+
const n = c.codePointAt(0);
|
|
199
|
+
return n < 0x100 ? `\\x${n.toString(16).padStart(2, "0")}` : `\\u${n.toString(16).padStart(4, "0")}`;
|
|
200
|
+
});
|
|
201
|
+
const shown = chars.length > 200 ? [...chars.slice(0, 120), "...", ...chars.slice(-80)] : chars;
|
|
202
|
+
return `"${shown.join("")}"`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The first line with anything in it of what a program printed, or null.
|
|
206
|
+
function firstLine(text) {
|
|
207
|
+
return (
|
|
208
|
+
String(text || "")
|
|
209
|
+
.split(/\r?\n/)
|
|
210
|
+
.find((l) => l.trim()) || null
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The first line of a file that starts with #!, as the system reads it to
|
|
215
|
+
// find the program that runs the file, or null.
|
|
216
|
+
function bangLine(file) {
|
|
217
|
+
let fd;
|
|
218
|
+
try {
|
|
219
|
+
fd = fs.openSync(file, "r");
|
|
220
|
+
const bytes = Buffer.alloc(256);
|
|
221
|
+
const got = bytes.subarray(0, fs.readSync(fd, bytes, 0, bytes.length, 0));
|
|
222
|
+
if (got.indexOf("#!") !== 0) return null;
|
|
223
|
+
const end = got.indexOf(10);
|
|
224
|
+
return got.subarray(0, end < 0 ? got.length : end).toString("utf8");
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
} finally {
|
|
228
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// What happened to a file the system did not start with `err`, after the
|
|
233
|
+
// certain checks found nothing wrong with the file itself. The system says
|
|
234
|
+
// ENOENT of a file that is there when an interpreter or a loader it needs
|
|
235
|
+
// is not, so a script's first line, which names its interpreter, is shown.
|
|
236
|
+
function unstartable(file, err) {
|
|
237
|
+
if (["ENOEXEC", "EFTYPE", "EBADARCH"].includes(err.code)) return "it is not a program this system can run";
|
|
238
|
+
const line = bangLine(file);
|
|
239
|
+
const first = line === null ? "" : `; its first line is ${quoted(line)}`;
|
|
240
|
+
if (err.code === "ENOENT") return `the system could not start it, as an interpreter or a loader it needs is not there${first}`;
|
|
241
|
+
return `the system could not start it: ${worded(err)}${first}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// What a file that ran with -version and did not answer as ffmpeg did:
|
|
245
|
+
// the first line it printed, on its error output, where a program says
|
|
246
|
+
// what is wrong, else on its output, then how it ended. ffmpeg prints its
|
|
247
|
+
// version on its output, so a program that ended well printing only on its
|
|
248
|
+
// error output is said to.
|
|
249
|
+
function answered(r) {
|
|
250
|
+
const ended = r.signal ? `was stopped by ${r.signal}` : `exited with ${r.code}`;
|
|
251
|
+
const line = firstLine(r.stderr);
|
|
252
|
+
if (line && r.code === 0 && !firstLine(r.stdout)) return `it printed ${quoted(line)} on its error output, nothing on its output, and exited with 0`;
|
|
253
|
+
if (line || firstLine(r.stdout)) return `it printed ${quoted(line || firstLine(r.stdout))} and ${ended}`;
|
|
254
|
+
return r.signal ? `it was stopped by ${r.signal}` : `it printed nothing and exited with ${r.code}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// An error for an ffmpeg that could not be used, marked, so what reads
|
|
258
|
+
// ffmpeg's words of a failure leaves it as it is.
|
|
259
|
+
const unusable = (message, more) => Object.assign(new Error(message), { unstarted: true }, more);
|
|
260
|
+
|
|
261
|
+
// The error for the ffmpeg at `file`, with `words` that say what happened
|
|
262
|
+
// there: by FFMPEG_PATH when it gives a path, else as the one on PATH, and
|
|
263
|
+
// how to get an ffmpeg or point to one.
|
|
264
|
+
function needs(file, words) {
|
|
265
|
+
const program = ffmpegPath();
|
|
266
|
+
const how = process.env.FFMPEG_PATH ? `Set FFMPEG_PATH to an ffmpeg, or unset it and install one (${INSTALL}).` : `Install ffmpeg (${INSTALL}) or set FFMPEG_PATH to one.`;
|
|
267
|
+
if (path.basename(program) !== program) return `musicfit needs ffmpeg, and FFMPEG_PATH points to ${file}, ${words.startsWith("where ") ? words : `which ${words}`}. ${how}`;
|
|
268
|
+
return `musicfit needs ffmpeg, and the ${program} on PATH, ${file}, ${words}. ${how}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// What `file` did when run with -version, as { code, signal, stdout,
|
|
272
|
+
// stderr }; { late: true } when it gave no answer in `limit` milliseconds
|
|
273
|
+
// and was stopped; { err } when the system did not start it.
|
|
274
|
+
function version(file, limit) {
|
|
275
|
+
return new Promise((resolve) => {
|
|
276
|
+
let child;
|
|
277
|
+
try {
|
|
278
|
+
child = spawn(file, ["-version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
resolve({ err });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
running.add(child);
|
|
284
|
+
let timer = null;
|
|
285
|
+
const end = (result) => {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
running.delete(child);
|
|
288
|
+
resolve(result);
|
|
289
|
+
};
|
|
290
|
+
child.on("error", (err) => end({ err }));
|
|
291
|
+
// With no file descriptor left Node makes no pipes, and says so a moment
|
|
292
|
+
// later.
|
|
293
|
+
if (!child.stdout || !child.stderr) return;
|
|
294
|
+
const said = { stdout: [], stderr: [] };
|
|
295
|
+
let size = 0;
|
|
296
|
+
for (const name of ["stdout", "stderr"]) {
|
|
297
|
+
child[name].on("data", (chunk) => {
|
|
298
|
+
if (size >= 1 << 16) return;
|
|
299
|
+
said[name].push(chunk);
|
|
300
|
+
size += chunk.length;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// A program that hands its output on to one that goes on running may
|
|
304
|
+
// never close it, so the answer is not waited for past the limit.
|
|
305
|
+
timer = setTimeout(() => {
|
|
306
|
+
child.kill("SIGKILL");
|
|
307
|
+
child.stdout.destroy();
|
|
308
|
+
child.stderr.destroy();
|
|
309
|
+
end({ late: true });
|
|
310
|
+
}, limit);
|
|
311
|
+
child.on("close", (code, signal) => end({ code, signal, stdout: Buffer.concat(said.stdout).toString("utf8"), stderr: Buffer.concat(said.stderr).toString("utf8") }));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The ffmpeg every call runs, as a path, found before any work as the
|
|
316
|
+
// system finds it: the path FFMPEG_PATH gives, or the first file on PATH
|
|
317
|
+
// the system would start (see places()), past what it passes over (see
|
|
318
|
+
// barred() and PASSED), and run once with -version. It is taken only when
|
|
319
|
+
// it exits with 0 and prints a line that starts with "ffmpeg version";
|
|
320
|
+
// else the run stops, with what the system said or the first line the
|
|
321
|
+
// program printed (see needs()). When the system finds none after a file
|
|
322
|
+
// it passed over, the first such file is named. One that gives no answer
|
|
323
|
+
// in time may be ffmpeg on a machine short of memory as well as a program
|
|
324
|
+
// that is not, so nothing more is said of it.
|
|
325
|
+
async function find(limit) {
|
|
326
|
+
const program = ffmpegPath();
|
|
327
|
+
let passed = null;
|
|
328
|
+
let none = null;
|
|
329
|
+
for (const file of places(program)) {
|
|
330
|
+
let seen = barred(file);
|
|
331
|
+
if (!seen) {
|
|
332
|
+
const r = await version(file, limit);
|
|
333
|
+
if (r.err && Object.hasOwn(SHORT, r.err.code)) throw unusable(`musicfit could not start ffmpeg: ${SHORT[r.err.code]}.`);
|
|
334
|
+
if (r.late) throw unusable(`musicfit could not check ffmpeg, ${file}: it gave no answer in ${limit / 1000} s when asked its version, as on a machine short of memory. Run it again.`);
|
|
335
|
+
if (!r.err && r.code === 0 && /^ffmpeg version\b/m.test(r.stdout)) return file;
|
|
336
|
+
seen = { on: r.err && PASSED.has(r.err.code) ? "passes" : "stops", words: `did not run as ffmpeg: ${r.err ? unstartable(file, r.err) : answered(r)}`, signal: r.signal };
|
|
337
|
+
}
|
|
338
|
+
if (seen.on === "stops") throw unusable(needs(file, seen.words), { stopped: Boolean(seen.signal) });
|
|
339
|
+
if (seen.on === "quietly") none = none || seen;
|
|
340
|
+
else passed = passed || { file, words: seen.words };
|
|
341
|
+
}
|
|
342
|
+
if (passed) throw unusable(needs(passed.file, passed.words));
|
|
343
|
+
if (path.basename(program) !== program) throw unusable(needs(program, none ? none.words : "where there is none"));
|
|
344
|
+
if (process.env.FFMPEG_PATH) throw unusable(`musicfit needs ffmpeg, and FFMPEG_PATH names ${program}, which is not on PATH. Set FFMPEG_PATH to an ffmpeg, or unset it and install one (${INSTALL}).`);
|
|
345
|
+
throw unusable(`musicfit needs ffmpeg. Install it (${INSTALL}) or set FFMPEG_PATH to it.`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The ffmpeg found for the FFMPEG_PATH, the PATH and the folder musicfit
|
|
349
|
+
// runs with now, found once for them (see find()). A failure is not kept,
|
|
350
|
+
// so a later call looks again.
|
|
351
|
+
let found = null;
|
|
352
|
+
|
|
353
|
+
function ready(limit = LIMIT) {
|
|
354
|
+
let here = "";
|
|
355
|
+
try {
|
|
356
|
+
here = process.cwd();
|
|
357
|
+
} catch {
|
|
358
|
+
// A folder taken away since: only PATH's relative entries read from it.
|
|
359
|
+
}
|
|
360
|
+
const key = JSON.stringify([process.env.FFMPEG_PATH, pathEntries().entries, here]);
|
|
361
|
+
if (found && found.key === key) return found.file;
|
|
362
|
+
const file = find(limit);
|
|
363
|
+
found = { key, file };
|
|
364
|
+
file.catch(() => {
|
|
365
|
+
if (found && found.file === file) found = null;
|
|
366
|
+
});
|
|
367
|
+
return file;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Whether a call can run in a folder: a start that fails there for want of
|
|
371
|
+
// the folder is no fault of ffmpeg's.
|
|
372
|
+
function enterable(folder) {
|
|
373
|
+
try {
|
|
374
|
+
fs.accessSync(folder, fs.constants.X_OK);
|
|
375
|
+
return fs.statSync(folder).isDirectory();
|
|
376
|
+
} catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
57
381
|
// What ffmpeg prints once it has given up, after the line that says why:
|
|
58
382
|
// how it stopped, which says nothing of the cause. ffmpeg 4.4 ends with the
|
|
59
383
|
// first five, newer ones with the others.
|
|
@@ -70,6 +394,11 @@ const CLOSING = [
|
|
|
70
394
|
/^Error muxing a packet$/,
|
|
71
395
|
/^Task finished with error code/,
|
|
72
396
|
/^Terminating thread with return code/,
|
|
397
|
+
// The report of what it wrote, which ffmpeg prints at -v info at the end
|
|
398
|
+
// of a run, a failed one too: "video:0kB audio:0kB ..." in 4.4 and
|
|
399
|
+
// "video:0KiB audio:0KiB ..." in newer ones. Its "muxing overhead:
|
|
400
|
+
// unknown" is no cause.
|
|
401
|
+
/^video:\S+ audio:\S+ /,
|
|
73
402
|
];
|
|
74
403
|
// The words of a line that says what went wrong.
|
|
75
404
|
const WRONG = /error|\b(invalid|unable|cannot|can't|could not|couldn't|failed|not found|no such|unknown|unrecognized|not supported|denied|no space|refused|not opened|is not|does not)\b/i;
|
|
@@ -134,25 +463,42 @@ function stopChildren() {
|
|
|
134
463
|
|
|
135
464
|
// Runs ffmpeg with `args`, and `input` on its stdin. `writes` is the file
|
|
136
465
|
// the call writes, if any, and `cwd` the folder to run it in, if not this one.
|
|
137
|
-
|
|
466
|
+
// The ffmpeg it runs is found and asked its version before the first call
|
|
467
|
+
// (see ready()), so one that cannot be used stops the run before any work.
|
|
468
|
+
function run(args, input, options = {}) {
|
|
469
|
+
return ready().then((file) => call(file, args, input, options));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function call(file, args, input, { writes = null, cwd } = {}) {
|
|
138
473
|
return new Promise((resolve, reject) => {
|
|
474
|
+
// The system short of something to start it with, the folder of the
|
|
475
|
+
// call gone, which is no fault of ffmpeg's, or an ffmpeg that ran when
|
|
476
|
+
// the run began and cannot be started now, as when it was removed or
|
|
477
|
+
// changed since.
|
|
478
|
+
const failed = (error) => {
|
|
479
|
+
if (Object.hasOwn(SHORT, error.code)) reject(unusable(`musicfit could not start ffmpeg: ${SHORT[error.code]}.`));
|
|
480
|
+
else if (cwd && !enterable(cwd)) reject(error);
|
|
481
|
+
else reject(unusable(`musicfit could not start ffmpeg, ${file}, again: ${worded(error)}. Check that it is still there and may be run, and run musicfit again.`));
|
|
482
|
+
};
|
|
139
483
|
let child;
|
|
140
484
|
try {
|
|
141
|
-
child = spawn(
|
|
485
|
+
child = spawn(file, ["-hide_banner", ...(input ? [] : ["-nostdin"]), ...args], { cwd, env: cwd ? fromHere() : process.env, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
142
486
|
} catch (err) {
|
|
143
|
-
|
|
487
|
+
failed(err);
|
|
144
488
|
return;
|
|
145
489
|
}
|
|
146
490
|
running.add(child);
|
|
491
|
+
child.on("error", (error) => {
|
|
492
|
+
running.delete(child);
|
|
493
|
+
failed(error);
|
|
494
|
+
});
|
|
495
|
+
// With no file descriptor left Node makes no pipes, and says so a moment
|
|
496
|
+
// later.
|
|
497
|
+
if (!child.stdout || !child.stderr) return;
|
|
147
498
|
const out = [];
|
|
148
499
|
const err = [];
|
|
149
500
|
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
150
501
|
child.stderr.on("data", (chunk) => err.push(chunk));
|
|
151
|
-
child.on("error", (error) => {
|
|
152
|
-
running.delete(child);
|
|
153
|
-
if (error.code === "ENOENT" && !cwd) reject(new Error("musicfit needs ffmpeg. Install it (brew install ffmpeg, sudo apt install ffmpeg or winget install ffmpeg) or set FFMPEG_PATH to it."));
|
|
154
|
-
else reject(error);
|
|
155
|
-
});
|
|
156
502
|
child.on("close", (code, signal) => {
|
|
157
503
|
running.delete(child);
|
|
158
504
|
const stderr = Buffer.concat(err).toString("utf8");
|
|
@@ -232,10 +578,11 @@ async function decode(file, { rate = RATE, channels = 2, from = null, stream = 0
|
|
|
232
578
|
if (!samples.length) throw new Error(`${file} has no audio`);
|
|
233
579
|
return samples;
|
|
234
580
|
} catch (err) {
|
|
581
|
+
if (err.unstarted) throw err;
|
|
235
582
|
// ffmpeg says why early and sums up last, so all it said is searched.
|
|
236
583
|
const said = err.stderr || err.message;
|
|
237
584
|
if (/matches no streams/.test(said)) throw new Error(`${file} has no audio stream`);
|
|
238
|
-
if (/No such file|does not exist/.test(said)) throw new Error(`Cannot read ${file}: no such file`);
|
|
585
|
+
if (/No such file|does not exist/.test(said) && gone(file)) throw new Error(`Cannot read ${file}: no such file`);
|
|
239
586
|
if (/Invalid data found/.test(said)) throw new Error(`Cannot read ${file}: ffmpeg does not know this format`);
|
|
240
587
|
throw err;
|
|
241
588
|
}
|
|
@@ -280,4 +627,4 @@ async function measureFile(file) {
|
|
|
280
627
|
return summary(stderr);
|
|
281
628
|
}
|
|
282
629
|
|
|
283
|
-
module.exports = { RATE, decode, encodeWav, fromHere, measure, measureFile, run, reason, stopChildren, where };
|
|
630
|
+
module.exports = { RATE, decode, encodeWav, fromHere, gone, measure, measureFile, quoted, ready, run, reason, stopChildren, where };
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
const fs = require("node:fs");
|
|
9
9
|
const path = require("node:path");
|
|
10
10
|
const crypto = require("node:crypto");
|
|
11
|
-
const { RATE, decode, encodeWav, measure, measureFile } = require("./audio");
|
|
11
|
+
const { RATE, decode, encodeWav, measure, measureFile, ready } = require("./audio");
|
|
12
12
|
const { probe, fits, ownSound, encodeAac, mux, mark, fpsText } = require("./video");
|
|
13
13
|
const { RATE: ANALYSIS_RATE } = require("./analyze");
|
|
14
14
|
const { analyzeInThread } = require("./thread");
|
|
@@ -110,6 +110,9 @@ async function fit(track, options) {
|
|
|
110
110
|
// Begun first, so a stop while it waits for another run in that folder is said.
|
|
111
111
|
const run = begin(dir);
|
|
112
112
|
try {
|
|
113
|
+
// An ffmpeg that cannot be used stops the run before it looks at the
|
|
114
|
+
// out folder.
|
|
115
|
+
await ready();
|
|
113
116
|
await claim(dir, writes, [track, options.voice, options.video]);
|
|
114
117
|
const o = { ...options, warnings: [...(options.warnings || [])] };
|
|
115
118
|
// The video is read before any work, so one that cannot take this fit
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const fs = require("node:fs");
|
|
15
15
|
const path = require("node:path");
|
|
16
|
-
const { RATE, run, decode, where } = require("./audio");
|
|
16
|
+
const { RATE, run, decode, gone, where } = require("./audio");
|
|
17
17
|
const { blocks } = require("./breathe");
|
|
18
18
|
|
|
19
19
|
const num = (x, digits) => String(Number(x.toFixed(digits)));
|
|
@@ -90,8 +90,9 @@ async function probe(file) {
|
|
|
90
90
|
crc = Buffer.concat(chunks).toString("utf8");
|
|
91
91
|
said = stderr;
|
|
92
92
|
} catch (err) {
|
|
93
|
+
if (err.unstarted) throw err;
|
|
93
94
|
const why = err.stderr || err.message;
|
|
94
|
-
if (/No such file|does not exist/.test(why)) throw new Error(`Cannot read ${file}: no such file`);
|
|
95
|
+
if (/No such file|does not exist/.test(why) && gone(file)) throw new Error(`Cannot read ${file}: no such file`);
|
|
95
96
|
// An MP4 or a MOV that stops before its index or inside it, as one whose
|
|
96
97
|
// recording or download was cut off; a file ffmpeg only guessed was one
|
|
97
98
|
// is not.
|
|
@@ -279,7 +280,7 @@ async function mux(file, sound, out, { picture, comment, start }) {
|
|
|
279
280
|
const { stderr } = await run(["-v", "error", "-i", name, "-map", "0", "-c", "copy", "-f", "null", "-"], null, { cwd: path.dirname(out) });
|
|
280
281
|
if (DAMAGED.some((line) => line.test(stderr))) why = "it is cut short";
|
|
281
282
|
} catch (err) {
|
|
282
|
-
if (err.stopped) throw err;
|
|
283
|
+
if (err.stopped || err.unstarted) throw err;
|
|
283
284
|
why = err.message.replace(/^ffmpeg: /, "").split(`${name}: `).join("");
|
|
284
285
|
}
|
|
285
286
|
if (why) throw new Error(`The video.mp4 this run wrote cannot be read back (${why}), so it was not put in place`);
|