tracksmith 0.0.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/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/tracksmith.js +580 -0
- package/dist/tracksmith.js.map +1 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Uzair Sajid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# tracksmith
|
|
2
|
+
|
|
3
|
+
Tracksmith is a CLI for MKV files. It inspects tracks, extracts an audio track losslessly, creates short clips for checking audio/video sync, and muxes aligned audio into another MKV. It wraps FFmpeg and MKVToolNix. Audio tracks are always stream-copied, so Tracksmith never transcodes or re-encodes audio.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- [Node.js](https://nodejs.org) 22 or newer, or [Bun](https://bun.com) 1.3.14 or newer
|
|
8
|
+
- `ffmpeg` and `mkvmerge` available on `PATH`
|
|
9
|
+
|
|
10
|
+
`ffmpeg`:
|
|
11
|
+
|
|
12
|
+
```powershell
|
|
13
|
+
# Windows (choose one)
|
|
14
|
+
winget install Gyan.FFmpeg
|
|
15
|
+
scoop install ffmpeg
|
|
16
|
+
|
|
17
|
+
# macOS
|
|
18
|
+
brew install ffmpeg
|
|
19
|
+
|
|
20
|
+
# Debian/Ubuntu
|
|
21
|
+
sudo apt install ffmpeg
|
|
22
|
+
|
|
23
|
+
# Arch Linux
|
|
24
|
+
sudo pacman -S ffmpeg
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`mkvmerge`:
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
# Windows (choose one)
|
|
31
|
+
winget install MoritzBunkus.MKVToolNix
|
|
32
|
+
scoop install mkvtoolnix
|
|
33
|
+
|
|
34
|
+
# macOS
|
|
35
|
+
brew install mkvtoolnix
|
|
36
|
+
|
|
37
|
+
# Debian/Ubuntu
|
|
38
|
+
sudo apt install mkvtoolnix
|
|
39
|
+
|
|
40
|
+
# Arch Linux
|
|
41
|
+
sudo pacman -S mkvtoolnix-cli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
Run it once without installing:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
# Node.js
|
|
50
|
+
npx tracksmith --help
|
|
51
|
+
|
|
52
|
+
# Bun
|
|
53
|
+
bunx --bun tracksmith --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Install it globally:
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
npm install --global tracksmith
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Release process and approval boundaries: [docs/releasing.md](docs/releasing.md).
|
|
63
|
+
|
|
64
|
+
## Commands
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
tracksmith inspect <file>
|
|
68
|
+
tracksmith extract <file> --track <id> [--output <file>] [--force]
|
|
69
|
+
tracksmith test-clip --video <target> --audio <donor> --track <id> --start <time> [--duration 60] [--delay-ms 0] [--output <file>] [--force]
|
|
70
|
+
tracksmith mux --video <target> --audio <mka-or-mkv> --output <file> [--track <id>] [--delay-ms 0] [--language eng] [--name <title>] [--default] [--force]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`--track` is always the MKVToolNix track ID shown by `tracksmith inspect`. Times accept seconds such as `90` or `90.5`, or `HH:MM:SS[.ms]`.
|
|
74
|
+
|
|
75
|
+
## PowerShell walkthrough
|
|
76
|
+
|
|
77
|
+
```powershell
|
|
78
|
+
# 1. See what's in the donor file (note the MKVToolNix track IDs)
|
|
79
|
+
tracksmith inspect "D:\rips\Movie DonorCut.mkv"
|
|
80
|
+
|
|
81
|
+
# 2. Extract the audio track losslessly (writes Movie DonorCut.track2.mka)
|
|
82
|
+
tracksmith extract "D:\rips\Movie DonorCut.mkv" --track 2
|
|
83
|
+
|
|
84
|
+
# 3. Make a 60-second sync clip at the 10-minute mark, audio delayed 250 ms
|
|
85
|
+
tracksmith test-clip --video "D:\rips\Movie TargetCut.mkv" --audio "D:\rips\Movie DonorCut.track2.mka" --track 0 --start 00:10:00 --delay-ms 250
|
|
86
|
+
|
|
87
|
+
# 4. Once the delay is right, mux it into the target
|
|
88
|
+
tracksmith mux --video "D:\rips\Movie TargetCut.mkv" --audio "D:\rips\Movie DonorCut.track2.mka" --delay-ms=250 --name "Donor E-AC-3 7.1" --default --output "D:\rips\Movie Final.mkv"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Positive `--delay-ms` delays audio; negative values advance it (e.g. `--delay-ms -250` or `--delay-ms=-250`). Check sync near the beginning, middle, and end of the file. If the required offset changes, the files differ by frame rate or edits and one constant delay cannot fix them.
|
|
92
|
+
|
|
93
|
+
If an output already exists, an interactive terminal asks for confirmation with a y/N prompt. Pass `--force` to overwrite without prompting. Non-interactive sessions refuse to overwrite unless `--force` is provided.
|
|
94
|
+
|
|
95
|
+
## Local development
|
|
96
|
+
|
|
97
|
+
Contributors use [Bun](https://bun.com) 1.3.14 and [Vite+](https://viteplus.dev/guide/) (`vp`):
|
|
98
|
+
|
|
99
|
+
```powershell
|
|
100
|
+
vp install
|
|
101
|
+
vp run cli -- <command>
|
|
102
|
+
vp check
|
|
103
|
+
vp test run
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Vite+ delegates dependency installation to Bun. `vp check` runs Oxfmt, Oxlint, and TypeScript 7 together; use `vp check --fix` to apply safe formatting and lint fixes.
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import * as z from "zod/mini";
|
|
6
|
+
import { once } from "node:events";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
//#region src/types.ts
|
|
10
|
+
var CliError = class extends Error {
|
|
11
|
+
exitCode;
|
|
12
|
+
constructor(message, exitCode = 1) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.exitCode = exitCode;
|
|
15
|
+
this.name = "CliError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/output.ts
|
|
20
|
+
function stemPath(file) {
|
|
21
|
+
return join(dirname(file), basename(file, extname(file)));
|
|
22
|
+
}
|
|
23
|
+
function defaultExtractOutput(source, trackId) {
|
|
24
|
+
return `${stemPath(source)}.track${trackId}.mka`;
|
|
25
|
+
}
|
|
26
|
+
function defaultTestClipOutput(video) {
|
|
27
|
+
return `${stemPath(video)}.sync-test.mkv`;
|
|
28
|
+
}
|
|
29
|
+
function requireInputFile(path, exists = existsSync) {
|
|
30
|
+
if (!exists(path)) throw new CliError(`Input file not found: "${path}".`);
|
|
31
|
+
}
|
|
32
|
+
function sameFile(left, right) {
|
|
33
|
+
try {
|
|
34
|
+
const leftStat = statSync(left, { bigint: true });
|
|
35
|
+
const rightStat = statSync(right, { bigint: true });
|
|
36
|
+
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function ensureWritable(path, ctx) {
|
|
42
|
+
const aliasedInput = ctx.inputs?.find((input) => sameFile(path, input));
|
|
43
|
+
if (aliasedInput !== void 0) throw new CliError(`Output "${path}" is the same file as input "${aliasedInput}". Choose a different output path.`);
|
|
44
|
+
if (!(ctx.exists ?? existsSync)(path)) return;
|
|
45
|
+
if (ctx.force) return;
|
|
46
|
+
if (!ctx.isTTY) throw new CliError(`Output "${path}" already exists. Pass --force to overwrite (cannot prompt in a non-interactive session).`);
|
|
47
|
+
if (!await ctx.confirm(`Output "${path}" already exists. Overwrite?`)) throw new CliError("Aborted: existing output left untouched.");
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/paths.ts
|
|
51
|
+
const TOOL_OPERATOR_PREFIXES = "-+@=()[]";
|
|
52
|
+
function toolPath(path) {
|
|
53
|
+
if (isAbsolute(path) || !TOOL_OPERATOR_PREFIXES.includes(path[0] ?? "")) return path;
|
|
54
|
+
return resolve(path);
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/probe.ts
|
|
58
|
+
const mkvmergePropertiesSchema = z.object({
|
|
59
|
+
language: z.optional(z.string()),
|
|
60
|
+
track_name: z.optional(z.string()),
|
|
61
|
+
audio_channels: z.optional(z.number()),
|
|
62
|
+
default_track: z.optional(z.boolean())
|
|
63
|
+
});
|
|
64
|
+
const mkvmergeTrackSchema = z.object({
|
|
65
|
+
id: z.int(),
|
|
66
|
+
type: z.string(),
|
|
67
|
+
codec: z.string(),
|
|
68
|
+
properties: z.optional(mkvmergePropertiesSchema)
|
|
69
|
+
});
|
|
70
|
+
const mkvmergeOutputSchema = z.object({ tracks: z.array(mkvmergeTrackSchema) });
|
|
71
|
+
function parseMkvmergeJson(json) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(json);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new CliError("Could not parse mkvmerge -J output as JSON.");
|
|
77
|
+
}
|
|
78
|
+
const result = z.safeParse(mkvmergeOutputSchema, parsed);
|
|
79
|
+
if (!result.success) throw new CliError("mkvmerge -J output has an unexpected structure.");
|
|
80
|
+
return result.data.tracks.map((track) => ({
|
|
81
|
+
id: track.id,
|
|
82
|
+
type: track.type,
|
|
83
|
+
codec: track.codec,
|
|
84
|
+
language: track.properties?.language,
|
|
85
|
+
name: track.properties?.track_name,
|
|
86
|
+
channels: track.properties?.audio_channels,
|
|
87
|
+
isDefault: track.properties?.default_track ?? false
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
async function probeTracks(runner, file) {
|
|
91
|
+
const result = await runner.run([
|
|
92
|
+
"mkvmerge",
|
|
93
|
+
"-J",
|
|
94
|
+
toolPath(file)
|
|
95
|
+
]);
|
|
96
|
+
if (result.exitCode >= 2) throw new CliError(`mkvmerge could not read "${file}" (exit ${result.exitCode}):\n${result.stderr || result.stdout}`);
|
|
97
|
+
return parseMkvmergeJson(result.stdout);
|
|
98
|
+
}
|
|
99
|
+
function validAudioIds(tracks) {
|
|
100
|
+
const ids = tracks.filter((track) => track.type === "audio").map((track) => track.id);
|
|
101
|
+
return `Valid audio track IDs: ${ids.length > 0 ? ids.join(", ") : "none"}`;
|
|
102
|
+
}
|
|
103
|
+
function requireAudioTrack(tracks, id) {
|
|
104
|
+
const track = tracks.find((candidate) => candidate.id === id);
|
|
105
|
+
if (!track) throw new CliError(`Track ${id} does not exist. ${validAudioIds(tracks)}`);
|
|
106
|
+
if (track.type !== "audio") throw new CliError(`Track ${id} is ${track.type}, not audio. ${validAudioIds(tracks)}`);
|
|
107
|
+
return track;
|
|
108
|
+
}
|
|
109
|
+
function audioRelativeIndex(tracks, id) {
|
|
110
|
+
const index = tracks.filter((track) => track.type === "audio").findIndex((track) => track.id === id);
|
|
111
|
+
if (index === -1) throw new CliError(`Track ${id} is not an audio track. ${validAudioIds(tracks)}`);
|
|
112
|
+
return index;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/commands/extract.ts
|
|
116
|
+
function buildExtractArgs(opts) {
|
|
117
|
+
return [
|
|
118
|
+
"mkvmerge",
|
|
119
|
+
"-o",
|
|
120
|
+
toolPath(opts.output),
|
|
121
|
+
"--audio-tracks",
|
|
122
|
+
String(opts.track),
|
|
123
|
+
"--no-video",
|
|
124
|
+
"--no-subtitles",
|
|
125
|
+
"--no-buttons",
|
|
126
|
+
"--no-chapters",
|
|
127
|
+
"--no-attachments",
|
|
128
|
+
"--no-global-tags",
|
|
129
|
+
toolPath(opts.file)
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
async function extractCommand(opts, deps) {
|
|
133
|
+
requireInputFile(opts.file, deps.exists);
|
|
134
|
+
requireAudioTrack(await probeTracks(deps.runner, opts.file), opts.track);
|
|
135
|
+
const output = opts.output ?? defaultExtractOutput(opts.file, opts.track);
|
|
136
|
+
await ensureWritable(output, {
|
|
137
|
+
force: opts.force,
|
|
138
|
+
isTTY: deps.isTTY,
|
|
139
|
+
confirm: deps.confirm,
|
|
140
|
+
exists: deps.exists,
|
|
141
|
+
inputs: [opts.file]
|
|
142
|
+
});
|
|
143
|
+
const result = await deps.runner.run(buildExtractArgs({
|
|
144
|
+
file: opts.file,
|
|
145
|
+
track: opts.track,
|
|
146
|
+
output
|
|
147
|
+
}));
|
|
148
|
+
if (result.exitCode >= 2) throw new CliError(`mkvmerge failed (exit ${result.exitCode}):\n${result.stderr || result.stdout}`);
|
|
149
|
+
return output;
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/commands/inspect.ts
|
|
153
|
+
function formatTrackTable(tracks) {
|
|
154
|
+
const header = [
|
|
155
|
+
"ID",
|
|
156
|
+
"TYPE",
|
|
157
|
+
"CODEC",
|
|
158
|
+
"LANG",
|
|
159
|
+
"CH",
|
|
160
|
+
"DEFAULT",
|
|
161
|
+
"NAME"
|
|
162
|
+
];
|
|
163
|
+
const all = [header, ...tracks.map((track) => [
|
|
164
|
+
String(track.id),
|
|
165
|
+
track.type,
|
|
166
|
+
track.codec,
|
|
167
|
+
track.language ?? "-",
|
|
168
|
+
track.channels !== void 0 ? String(track.channels) : "-",
|
|
169
|
+
track.isDefault ? "yes" : "-",
|
|
170
|
+
track.name ?? "-"
|
|
171
|
+
])];
|
|
172
|
+
const widths = header.map((_, column) => Math.max(...all.map((row) => row[column].length)));
|
|
173
|
+
return all.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()).join("\n");
|
|
174
|
+
}
|
|
175
|
+
async function inspectCommand(file, deps) {
|
|
176
|
+
requireInputFile(file, deps.exists);
|
|
177
|
+
return formatTrackTable(await probeTracks(deps.runner, file));
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/commands/mux.ts
|
|
181
|
+
function resolveAudioTrackId(tracks, requested) {
|
|
182
|
+
if (requested !== void 0) return requireAudioTrack(tracks, requested).id;
|
|
183
|
+
const audio = tracks.filter((track) => track.type === "audio");
|
|
184
|
+
if (audio.length === 1) return audio[0].id;
|
|
185
|
+
if (audio.length === 0) throw new CliError("The audio input contains no audio tracks.");
|
|
186
|
+
throw new CliError(`The audio input contains ${audio.length} audio tracks; pass --track <id> (see: tracksmith inspect <file>).`);
|
|
187
|
+
}
|
|
188
|
+
function buildMuxArgs(a) {
|
|
189
|
+
const id = String(a.audioTrackId);
|
|
190
|
+
const args = [
|
|
191
|
+
"mkvmerge",
|
|
192
|
+
"-o",
|
|
193
|
+
toolPath(a.output),
|
|
194
|
+
toolPath(a.video),
|
|
195
|
+
"--audio-tracks",
|
|
196
|
+
id,
|
|
197
|
+
"--no-video",
|
|
198
|
+
"--no-subtitles",
|
|
199
|
+
"--no-buttons",
|
|
200
|
+
"--no-chapters",
|
|
201
|
+
"--no-attachments",
|
|
202
|
+
"--no-global-tags"
|
|
203
|
+
];
|
|
204
|
+
if (a.delayMs !== 0) args.push("--sync", `${id}:${a.delayMs}`);
|
|
205
|
+
if (a.language !== void 0) args.push("--language", `${id}:${a.language}`);
|
|
206
|
+
if (a.trackName !== void 0) args.push("--track-name", `${id}:${a.trackName}`);
|
|
207
|
+
args.push("--default-track-flag", `${id}:${a.makeDefault ? "yes" : "no"}`);
|
|
208
|
+
args.push(toolPath(a.audio));
|
|
209
|
+
return args;
|
|
210
|
+
}
|
|
211
|
+
async function muxCommand(opts, deps) {
|
|
212
|
+
requireInputFile(opts.video, deps.exists);
|
|
213
|
+
requireInputFile(opts.audio, deps.exists);
|
|
214
|
+
const audioTrackId = resolveAudioTrackId(await probeTracks(deps.runner, opts.audio), opts.track);
|
|
215
|
+
await ensureWritable(opts.output, {
|
|
216
|
+
force: opts.force,
|
|
217
|
+
isTTY: deps.isTTY,
|
|
218
|
+
confirm: deps.confirm,
|
|
219
|
+
exists: deps.exists,
|
|
220
|
+
inputs: [opts.video, opts.audio]
|
|
221
|
+
});
|
|
222
|
+
const result = await deps.runner.run(buildMuxArgs({
|
|
223
|
+
video: opts.video,
|
|
224
|
+
audio: opts.audio,
|
|
225
|
+
audioTrackId,
|
|
226
|
+
delayMs: opts.delayMs,
|
|
227
|
+
language: opts.language,
|
|
228
|
+
trackName: opts.name,
|
|
229
|
+
makeDefault: opts.makeDefault,
|
|
230
|
+
output: opts.output
|
|
231
|
+
}));
|
|
232
|
+
if (result.exitCode >= 2) throw new CliError(`mkvmerge failed (exit ${result.exitCode}):\n${result.stderr || result.stdout}`);
|
|
233
|
+
return opts.output;
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/commands/test-clip.ts
|
|
237
|
+
function parseTimeToSeconds(value) {
|
|
238
|
+
if (/^\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
239
|
+
const match = /^(\d+):([0-5]?\d):([0-5]?\d(?:\.\d+)?)$/.exec(value);
|
|
240
|
+
if (!match) throw new CliError(`Invalid time "${value}". Use seconds (90 or 90.5) or HH:MM:SS[.ms].`);
|
|
241
|
+
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
|
|
242
|
+
}
|
|
243
|
+
function buildTestClipArgs(a) {
|
|
244
|
+
return [
|
|
245
|
+
"ffmpeg",
|
|
246
|
+
"-hide_banner",
|
|
247
|
+
"-nostdin",
|
|
248
|
+
"-ss",
|
|
249
|
+
String(a.startSeconds),
|
|
250
|
+
"-i",
|
|
251
|
+
toolPath(a.video),
|
|
252
|
+
"-itsoffset",
|
|
253
|
+
String(a.delayMs / 1e3),
|
|
254
|
+
"-ss",
|
|
255
|
+
String(a.startSeconds),
|
|
256
|
+
"-i",
|
|
257
|
+
toolPath(a.audio),
|
|
258
|
+
"-map",
|
|
259
|
+
"0:v:0",
|
|
260
|
+
"-map",
|
|
261
|
+
`1:a:${a.audioStreamIndex}`,
|
|
262
|
+
"-t",
|
|
263
|
+
String(a.durationSeconds),
|
|
264
|
+
"-c:v",
|
|
265
|
+
"libx264",
|
|
266
|
+
"-preset",
|
|
267
|
+
"veryfast",
|
|
268
|
+
"-crf",
|
|
269
|
+
"18",
|
|
270
|
+
"-c:a",
|
|
271
|
+
"copy",
|
|
272
|
+
"-sn",
|
|
273
|
+
"-abort_on",
|
|
274
|
+
"empty_output_stream",
|
|
275
|
+
"-y",
|
|
276
|
+
toolPath(a.output)
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
async function testClipCommand(opts, deps) {
|
|
280
|
+
requireInputFile(opts.video, deps.exists);
|
|
281
|
+
requireInputFile(opts.audio, deps.exists);
|
|
282
|
+
const startSeconds = parseTimeToSeconds(opts.start);
|
|
283
|
+
const durationSeconds = parseTimeToSeconds(opts.duration);
|
|
284
|
+
if (durationSeconds <= 0) throw new CliError("--duration must be greater than zero.");
|
|
285
|
+
const donorTracks = await probeTracks(deps.runner, opts.audio);
|
|
286
|
+
requireAudioTrack(donorTracks, opts.track);
|
|
287
|
+
const output = opts.output ?? defaultTestClipOutput(opts.video);
|
|
288
|
+
await ensureWritable(output, {
|
|
289
|
+
force: opts.force,
|
|
290
|
+
isTTY: deps.isTTY,
|
|
291
|
+
confirm: deps.confirm,
|
|
292
|
+
exists: deps.exists,
|
|
293
|
+
inputs: [opts.video, opts.audio]
|
|
294
|
+
});
|
|
295
|
+
const result = await deps.runner.run(buildTestClipArgs({
|
|
296
|
+
video: opts.video,
|
|
297
|
+
audio: opts.audio,
|
|
298
|
+
audioStreamIndex: audioRelativeIndex(donorTracks, opts.track),
|
|
299
|
+
startSeconds,
|
|
300
|
+
durationSeconds,
|
|
301
|
+
delayMs: opts.delayMs,
|
|
302
|
+
output
|
|
303
|
+
}));
|
|
304
|
+
if (result.exitCode !== 0) throw new CliError(`ffmpeg failed (exit ${result.exitCode}):\n${result.stderr || result.stdout}`);
|
|
305
|
+
return output;
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/cli.ts
|
|
309
|
+
const USAGE = `tracksmith — inspect, extract, sync-test, and mux Matroska audio tracks
|
|
310
|
+
|
|
311
|
+
Usage:
|
|
312
|
+
tracksmith inspect <file>
|
|
313
|
+
tracksmith extract <file> --track <id> [--output <file>] [--force]
|
|
314
|
+
tracksmith test-clip --video <target> --audio <donor> --track <id> --start <time>
|
|
315
|
+
[--duration 60] [--delay-ms 0] [--output <file>] [--force]
|
|
316
|
+
tracksmith mux --video <target> --audio <mka-or-mkv> --output <file>
|
|
317
|
+
[--track <id>] [--delay-ms 0] [--language eng] [--name <title>]
|
|
318
|
+
[--default] [--force]
|
|
319
|
+
|
|
320
|
+
Notes:
|
|
321
|
+
--track is always the MKVToolNix track ID shown by "tracksmith inspect".
|
|
322
|
+
Positive --delay-ms delays audio; negative advances it (e.g. --delay-ms -250).
|
|
323
|
+
Times accept seconds (90, 90.5) or HH:MM:SS[.ms].
|
|
324
|
+
Check sync near the beginning, middle, and end; a changing offset cannot be fixed by one delay.
|
|
325
|
+
Requires ffmpeg and MKVToolNix (mkvmerge) on PATH.`;
|
|
326
|
+
function parseIntStrict(value, flag) {
|
|
327
|
+
if (value === void 0) throw new CliError(`${flag} is required.`);
|
|
328
|
+
if (!/^-?\d+$/.test(value)) throw new CliError(`${flag} must be an integer, got "${value}".`);
|
|
329
|
+
return Number(value);
|
|
330
|
+
}
|
|
331
|
+
function parseTrack(value) {
|
|
332
|
+
const track = parseIntStrict(value, "--track");
|
|
333
|
+
if (track < 0) throw new CliError(`--track must be >= 0, got ${track}.`);
|
|
334
|
+
return track;
|
|
335
|
+
}
|
|
336
|
+
function requireFilePositional(positionals, command) {
|
|
337
|
+
if (positionals.length === 0) throw new CliError(`${command} requires a file argument.`);
|
|
338
|
+
if (positionals.length > 1) throw new CliError(`${command} accepts exactly one file argument.`);
|
|
339
|
+
return positionals[0];
|
|
340
|
+
}
|
|
341
|
+
function parseOrCliError(parseFn) {
|
|
342
|
+
try {
|
|
343
|
+
return parseFn();
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new CliError(error instanceof Error ? error.message : String(error));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function normalizeDelayMs(args) {
|
|
349
|
+
const out = [];
|
|
350
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
351
|
+
const arg = args[i];
|
|
352
|
+
const next = args[i + 1];
|
|
353
|
+
if (arg === "--delay-ms" && next !== void 0 && /^-\d+$/.test(next)) {
|
|
354
|
+
out.push(`--delay-ms=${next}`);
|
|
355
|
+
i += 1;
|
|
356
|
+
} else out.push(arg);
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
async function runCli(argv, deps, stdout = console.log) {
|
|
361
|
+
const [command, ...rawRest] = argv;
|
|
362
|
+
const rest = normalizeDelayMs(rawRest);
|
|
363
|
+
try {
|
|
364
|
+
switch (command) {
|
|
365
|
+
case void 0:
|
|
366
|
+
stdout(USAGE);
|
|
367
|
+
return 1;
|
|
368
|
+
case "--help":
|
|
369
|
+
case "-h":
|
|
370
|
+
stdout(USAGE);
|
|
371
|
+
return 0;
|
|
372
|
+
case "inspect": {
|
|
373
|
+
const { positionals } = parseOrCliError(() => parseArgs({
|
|
374
|
+
args: rest,
|
|
375
|
+
options: {},
|
|
376
|
+
allowPositionals: true,
|
|
377
|
+
strict: true
|
|
378
|
+
}));
|
|
379
|
+
stdout(await inspectCommand(requireFilePositional(positionals, "inspect"), deps));
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
case "extract": {
|
|
383
|
+
const { values, positionals } = parseOrCliError(() => parseArgs({
|
|
384
|
+
args: rest,
|
|
385
|
+
options: {
|
|
386
|
+
track: { type: "string" },
|
|
387
|
+
output: { type: "string" },
|
|
388
|
+
force: {
|
|
389
|
+
type: "boolean",
|
|
390
|
+
default: false
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
allowPositionals: true,
|
|
394
|
+
strict: true
|
|
395
|
+
}));
|
|
396
|
+
stdout(`Wrote ${await extractCommand({
|
|
397
|
+
file: requireFilePositional(positionals, "extract"),
|
|
398
|
+
track: parseTrack(values.track),
|
|
399
|
+
output: values.output,
|
|
400
|
+
force: values.force ?? false
|
|
401
|
+
}, deps)}`);
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
case "test-clip": {
|
|
405
|
+
const { values } = parseOrCliError(() => parseArgs({
|
|
406
|
+
args: rest,
|
|
407
|
+
options: {
|
|
408
|
+
video: { type: "string" },
|
|
409
|
+
audio: { type: "string" },
|
|
410
|
+
track: { type: "string" },
|
|
411
|
+
start: { type: "string" },
|
|
412
|
+
duration: {
|
|
413
|
+
type: "string",
|
|
414
|
+
default: "60"
|
|
415
|
+
},
|
|
416
|
+
"delay-ms": {
|
|
417
|
+
type: "string",
|
|
418
|
+
default: "0"
|
|
419
|
+
},
|
|
420
|
+
output: { type: "string" },
|
|
421
|
+
force: {
|
|
422
|
+
type: "boolean",
|
|
423
|
+
default: false
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
strict: true
|
|
427
|
+
}));
|
|
428
|
+
if (!values.video || !values.audio) throw new CliError("test-clip requires --video and --audio.");
|
|
429
|
+
if (!values.start) throw new CliError("test-clip requires --start.");
|
|
430
|
+
stdout(`Wrote ${await testClipCommand({
|
|
431
|
+
video: values.video,
|
|
432
|
+
audio: values.audio,
|
|
433
|
+
track: parseTrack(values.track),
|
|
434
|
+
start: values.start,
|
|
435
|
+
duration: values.duration ?? "60",
|
|
436
|
+
delayMs: parseIntStrict(values["delay-ms"] ?? "0", "--delay-ms"),
|
|
437
|
+
output: values.output,
|
|
438
|
+
force: values.force ?? false
|
|
439
|
+
}, deps)}`);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
case "mux": {
|
|
443
|
+
const { values } = parseOrCliError(() => parseArgs({
|
|
444
|
+
args: rest,
|
|
445
|
+
options: {
|
|
446
|
+
video: { type: "string" },
|
|
447
|
+
audio: { type: "string" },
|
|
448
|
+
track: { type: "string" },
|
|
449
|
+
"delay-ms": {
|
|
450
|
+
type: "string",
|
|
451
|
+
default: "0"
|
|
452
|
+
},
|
|
453
|
+
language: {
|
|
454
|
+
type: "string",
|
|
455
|
+
default: "eng"
|
|
456
|
+
},
|
|
457
|
+
name: { type: "string" },
|
|
458
|
+
default: {
|
|
459
|
+
type: "boolean",
|
|
460
|
+
default: false
|
|
461
|
+
},
|
|
462
|
+
output: { type: "string" },
|
|
463
|
+
force: {
|
|
464
|
+
type: "boolean",
|
|
465
|
+
default: false
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
strict: true
|
|
469
|
+
}));
|
|
470
|
+
if (!values.video || !values.audio) throw new CliError("mux requires --video and --audio.");
|
|
471
|
+
if (!values.output) throw new CliError("mux requires --output (no default output name for the final file).");
|
|
472
|
+
stdout(`Wrote ${await muxCommand({
|
|
473
|
+
video: values.video,
|
|
474
|
+
audio: values.audio,
|
|
475
|
+
track: values.track === void 0 ? void 0 : parseTrack(values.track),
|
|
476
|
+
delayMs: parseIntStrict(values["delay-ms"] ?? "0", "--delay-ms"),
|
|
477
|
+
language: values.language,
|
|
478
|
+
name: values.name,
|
|
479
|
+
makeDefault: values.default ?? false,
|
|
480
|
+
output: values.output,
|
|
481
|
+
force: values.force ?? false
|
|
482
|
+
}, deps)}`);
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
default: throw new CliError(`Unknown command "${command}".\n\n${USAGE}`);
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (error instanceof CliError) {
|
|
489
|
+
console.error(`tracksmith: ${error.message}`);
|
|
490
|
+
return error.exitCode;
|
|
491
|
+
}
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/prompt.ts
|
|
497
|
+
function createConfirmPrompt(options = {}) {
|
|
498
|
+
const input = options.input ?? process.stdin;
|
|
499
|
+
const output = options.output ?? process.stdout;
|
|
500
|
+
return async (message) => {
|
|
501
|
+
const readline = createInterface({
|
|
502
|
+
input,
|
|
503
|
+
output
|
|
504
|
+
});
|
|
505
|
+
try {
|
|
506
|
+
const closed = once(readline, "close").then(() => ({ kind: "eof" }));
|
|
507
|
+
const answered = readline.question(`${message} [y/N] `).then((value) => ({
|
|
508
|
+
kind: "answer",
|
|
509
|
+
value
|
|
510
|
+
}), () => ({ kind: "eof" }));
|
|
511
|
+
const result = await Promise.race([answered, closed]);
|
|
512
|
+
return result.kind === "answer" && /^y(?:es)?$/i.test(result.value.trim());
|
|
513
|
+
} finally {
|
|
514
|
+
readline.close();
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/tools.ts
|
|
520
|
+
const TOOLS = ["ffmpeg", "mkvmerge"];
|
|
521
|
+
function isToolName(value) {
|
|
522
|
+
return TOOLS.some((tool) => tool === value);
|
|
523
|
+
}
|
|
524
|
+
const INSTALL_HINTS = {
|
|
525
|
+
ffmpeg: "Windows: winget install Gyan.FFmpeg or scoop install ffmpeg | macOS: brew install ffmpeg | Linux: sudo apt install ffmpeg or sudo pacman -S ffmpeg",
|
|
526
|
+
mkvmerge: "Windows: winget install MoritzBunkus.MKVToolNix or scoop install mkvtoolnix | macOS: brew install mkvtoolnix | Linux: sudo apt install mkvtoolnix or sudo pacman -S mkvtoolnix-cli"
|
|
527
|
+
};
|
|
528
|
+
function installHint(tool) {
|
|
529
|
+
return INSTALL_HINTS[tool];
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/runner.ts
|
|
533
|
+
function processStartError(program, error) {
|
|
534
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT" && isToolName(program)) return new CliError(`${program} is required but was not found on PATH. ${installHint(program)}`);
|
|
535
|
+
return new CliError(`Failed to start ${program}: ${error instanceof Error ? error.message : String(error)}`);
|
|
536
|
+
}
|
|
537
|
+
var ProcessRunner = class {
|
|
538
|
+
async run(argv) {
|
|
539
|
+
const [executable, ...args] = argv;
|
|
540
|
+
if (executable === void 0) throw new CliError("Could not start process: missing executable");
|
|
541
|
+
return await new Promise((resolve, reject) => {
|
|
542
|
+
const process = spawn(executable, args, { stdio: [
|
|
543
|
+
"ignore",
|
|
544
|
+
"pipe",
|
|
545
|
+
"pipe"
|
|
546
|
+
] });
|
|
547
|
+
let stdout = "";
|
|
548
|
+
let stderr = "";
|
|
549
|
+
process.stdout.setEncoding("utf8");
|
|
550
|
+
process.stderr.setEncoding("utf8");
|
|
551
|
+
process.stdout.on("data", (chunk) => {
|
|
552
|
+
stdout += chunk;
|
|
553
|
+
});
|
|
554
|
+
process.stderr.on("data", (chunk) => {
|
|
555
|
+
stderr += chunk;
|
|
556
|
+
});
|
|
557
|
+
process.once("error", (error) => {
|
|
558
|
+
reject(processStartError(executable, error));
|
|
559
|
+
});
|
|
560
|
+
process.once("close", (exitCode) => {
|
|
561
|
+
resolve({
|
|
562
|
+
exitCode: exitCode ?? 1,
|
|
563
|
+
stdout,
|
|
564
|
+
stderr
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/tracksmith.ts
|
|
572
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
573
|
+
runner: new ProcessRunner(),
|
|
574
|
+
isTTY: process.stdin.isTTY ?? false,
|
|
575
|
+
confirm: createConfirmPrompt()
|
|
576
|
+
});
|
|
577
|
+
//#endregion
|
|
578
|
+
export {};
|
|
579
|
+
|
|
580
|
+
//# sourceMappingURL=tracksmith.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracksmith.js","names":[],"sources":["../src/types.ts","../src/output.ts","../src/paths.ts","../src/probe.ts","../src/commands/extract.ts","../src/commands/inspect.ts","../src/commands/mux.ts","../src/commands/test-clip.ts","../src/cli.ts","../src/prompt.ts","../src/tools.ts","../src/runner.ts","../src/tracksmith.ts"],"sourcesContent":["export interface RunResult {\n exitCode: number;\n stdout: string;\n stderr: string;\n}\n\nexport interface Runner {\n run(argv: readonly string[]): Promise<RunResult>;\n}\n\nexport interface Track {\n id: number;\n type: string;\n codec: string;\n language?: string;\n name?: string;\n channels?: number;\n isDefault: boolean;\n}\n\nexport class CliError extends Error {\n constructor(\n message: string,\n readonly exitCode: number = 1\n ) {\n super(message);\n this.name = 'CliError';\n }\n}\n\nexport interface CommandDeps {\n runner: Runner;\n isTTY: boolean;\n confirm: (message: string) => Promise<boolean>;\n exists?: (path: string) => boolean;\n}\n","import { existsSync, statSync } from 'node:fs';\nimport { basename, dirname, extname, join } from 'node:path';\n\nimport { CliError } from './types.ts';\n\nexport function stemPath(file: string): string {\n return join(dirname(file), basename(file, extname(file)));\n}\n\nexport function defaultExtractOutput(source: string, trackId: number): string {\n return `${stemPath(source)}.track${trackId}.mka`;\n}\n\nexport function defaultTestClipOutput(video: string): string {\n return `${stemPath(video)}.sync-test.mkv`;\n}\n\nexport function requireInputFile(\n path: string,\n exists: (path: string) => boolean = existsSync\n): void {\n if (!exists(path)) throw new CliError(`Input file not found: \"${path}\".`);\n}\n\nexport interface OverwriteContext {\n force: boolean;\n isTTY: boolean;\n confirm: (message: string) => Promise<boolean>;\n exists?: (path: string) => boolean;\n inputs?: readonly string[];\n}\n\nfunction sameFile(left: string, right: string): boolean {\n try {\n const leftStat = statSync(left, { bigint: true });\n const rightStat = statSync(right, { bigint: true });\n return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;\n } catch {\n return false;\n }\n}\n\nexport async function ensureWritable(path: string, ctx: OverwriteContext): Promise<void> {\n const aliasedInput = ctx.inputs?.find((input) => sameFile(path, input));\n if (aliasedInput !== undefined) {\n throw new CliError(\n `Output \"${path}\" is the same file as input \"${aliasedInput}\". Choose a different output path.`\n );\n }\n\n const exists = ctx.exists ?? existsSync;\n if (!exists(path)) return;\n if (ctx.force) return;\n if (!ctx.isTTY) {\n throw new CliError(\n `Output \"${path}\" already exists. Pass --force to overwrite (cannot prompt in a non-interactive session).`\n );\n }\n const approved = await ctx.confirm(`Output \"${path}\" already exists. Overwrite?`);\n if (!approved) throw new CliError('Aborted: existing output left untouched.');\n}\n","import { isAbsolute, resolve } from 'node:path';\n\nconst TOOL_OPERATOR_PREFIXES = '-+@=()[]';\n\nexport function toolPath(path: string): string {\n if (isAbsolute(path) || !TOOL_OPERATOR_PREFIXES.includes(path[0] ?? '')) return path;\n return resolve(path);\n}\n","import type { Runner, Track } from './types.ts';\n\nimport * as z from 'zod/mini';\n\nimport { toolPath } from './paths.ts';\nimport { CliError } from './types.ts';\n\nconst mkvmergePropertiesSchema = z.object({\n language: z.optional(z.string()),\n track_name: z.optional(z.string()),\n audio_channels: z.optional(z.number()),\n default_track: z.optional(z.boolean())\n});\n\nconst mkvmergeTrackSchema = z.object({\n id: z.int(),\n type: z.string(),\n codec: z.string(),\n properties: z.optional(mkvmergePropertiesSchema)\n});\n\nconst mkvmergeOutputSchema = z.object({\n tracks: z.array(mkvmergeTrackSchema)\n});\n\nexport function parseMkvmergeJson(json: string): Track[] {\n let parsed: unknown;\n try {\n parsed = JSON.parse(json);\n } catch {\n throw new CliError('Could not parse mkvmerge -J output as JSON.');\n }\n const result = z.safeParse(mkvmergeOutputSchema, parsed);\n if (!result.success) throw new CliError('mkvmerge -J output has an unexpected structure.');\n\n return result.data.tracks.map((track) => ({\n id: track.id,\n type: track.type,\n codec: track.codec,\n language: track.properties?.language,\n name: track.properties?.track_name,\n channels: track.properties?.audio_channels,\n isDefault: track.properties?.default_track ?? false\n }));\n}\n\nexport async function probeTracks(runner: Runner, file: string): Promise<Track[]> {\n const result = await runner.run(['mkvmerge', '-J', toolPath(file)]);\n if (result.exitCode >= 2) {\n throw new CliError(\n `mkvmerge could not read \"${file}\" (exit ${result.exitCode}):\\n${result.stderr || result.stdout}`\n );\n }\n return parseMkvmergeJson(result.stdout);\n}\n\nfunction validAudioIds(tracks: Track[]): string {\n const ids = tracks.filter((track) => track.type === 'audio').map((track) => track.id);\n return `Valid audio track IDs: ${ids.length > 0 ? ids.join(', ') : 'none'}`;\n}\n\nexport function requireAudioTrack(tracks: Track[], id: number): Track {\n const track = tracks.find((candidate) => candidate.id === id);\n if (!track) throw new CliError(`Track ${id} does not exist. ${validAudioIds(tracks)}`);\n if (track.type !== 'audio')\n throw new CliError(`Track ${id} is ${track.type}, not audio. ${validAudioIds(tracks)}`);\n return track;\n}\n\nexport function audioRelativeIndex(tracks: Track[], id: number): number {\n const index = tracks\n .filter((track) => track.type === 'audio')\n .findIndex((track) => track.id === id);\n if (index === -1)\n throw new CliError(`Track ${id} is not an audio track. ${validAudioIds(tracks)}`);\n return index;\n}\n","import type { CommandDeps } from '../types.ts';\n\nimport { defaultExtractOutput, ensureWritable, requireInputFile } from '../output.ts';\nimport { toolPath } from '../paths.ts';\nimport { probeTracks, requireAudioTrack } from '../probe.ts';\nimport { CliError } from '../types.ts';\n\nexport function buildExtractArgs(opts: { file: string; track: number; output: string }): string[] {\n return [\n 'mkvmerge',\n '-o',\n toolPath(opts.output),\n '--audio-tracks',\n String(opts.track),\n '--no-video',\n '--no-subtitles',\n '--no-buttons',\n '--no-chapters',\n '--no-attachments',\n '--no-global-tags',\n toolPath(opts.file)\n ];\n}\n\nexport async function extractCommand(\n opts: { file: string; track: number; output?: string; force: boolean },\n deps: CommandDeps\n): Promise<string> {\n requireInputFile(opts.file, deps.exists);\n const tracks = await probeTracks(deps.runner, opts.file);\n requireAudioTrack(tracks, opts.track);\n const output = opts.output ?? defaultExtractOutput(opts.file, opts.track);\n await ensureWritable(output, {\n force: opts.force,\n isTTY: deps.isTTY,\n confirm: deps.confirm,\n exists: deps.exists,\n inputs: [opts.file]\n });\n const result = await deps.runner.run(\n buildExtractArgs({ file: opts.file, track: opts.track, output })\n );\n if (result.exitCode >= 2) {\n throw new CliError(\n `mkvmerge failed (exit ${result.exitCode}):\\n${result.stderr || result.stdout}`\n );\n }\n return output;\n}\n","import type { CommandDeps, Track } from '../types.ts';\n\nimport { requireInputFile } from '../output.ts';\nimport { probeTracks } from '../probe.ts';\n\nexport function formatTrackTable(tracks: Track[]): string {\n const header = ['ID', 'TYPE', 'CODEC', 'LANG', 'CH', 'DEFAULT', 'NAME'];\n const rows = tracks.map((track) => [\n String(track.id),\n track.type,\n track.codec,\n track.language ?? '-',\n track.channels !== undefined ? String(track.channels) : '-',\n track.isDefault ? 'yes' : '-',\n track.name ?? '-'\n ]);\n const all = [header, ...rows];\n const widths = header.map((_, column) => Math.max(...all.map((row) => row[column]!.length)));\n return all\n .map((row) =>\n row\n .map((cell, column) => cell.padEnd(widths[column]!))\n .join(' ')\n .trimEnd()\n )\n .join('\\n');\n}\n\nexport async function inspectCommand(file: string, deps: CommandDeps): Promise<string> {\n requireInputFile(file, deps.exists);\n return formatTrackTable(await probeTracks(deps.runner, file));\n}\n","import type { CommandDeps, Track } from '../types.ts';\n\nimport { ensureWritable, requireInputFile } from '../output.ts';\nimport { toolPath } from '../paths.ts';\nimport { probeTracks, requireAudioTrack } from '../probe.ts';\nimport { CliError } from '../types.ts';\n\nexport function resolveAudioTrackId(tracks: Track[], requested: number | undefined): number {\n if (requested !== undefined) return requireAudioTrack(tracks, requested).id;\n const audio = tracks.filter((track) => track.type === 'audio');\n if (audio.length === 1) return audio[0]!.id;\n if (audio.length === 0) throw new CliError('The audio input contains no audio tracks.');\n throw new CliError(\n `The audio input contains ${audio.length} audio tracks; pass --track <id> (see: tracksmith inspect <file>).`\n );\n}\n\nexport function buildMuxArgs(a: {\n video: string;\n audio: string;\n audioTrackId: number;\n delayMs: number;\n language?: string;\n trackName?: string;\n makeDefault: boolean;\n output: string;\n}): string[] {\n const id = String(a.audioTrackId);\n const args = [\n 'mkvmerge',\n '-o',\n toolPath(a.output),\n toolPath(a.video),\n '--audio-tracks',\n id,\n '--no-video',\n '--no-subtitles',\n '--no-buttons',\n '--no-chapters',\n '--no-attachments',\n '--no-global-tags'\n ];\n if (a.delayMs !== 0) args.push('--sync', `${id}:${a.delayMs}`);\n if (a.language !== undefined) args.push('--language', `${id}:${a.language}`);\n if (a.trackName !== undefined) args.push('--track-name', `${id}:${a.trackName}`);\n args.push('--default-track-flag', `${id}:${a.makeDefault ? 'yes' : 'no'}`);\n args.push(toolPath(a.audio));\n return args;\n}\n\nexport async function muxCommand(\n opts: {\n video: string;\n audio: string;\n track?: number;\n delayMs: number;\n language?: string;\n name?: string;\n makeDefault: boolean;\n output: string;\n force: boolean;\n },\n deps: CommandDeps\n): Promise<string> {\n requireInputFile(opts.video, deps.exists);\n requireInputFile(opts.audio, deps.exists);\n const audioTracks = await probeTracks(deps.runner, opts.audio);\n const audioTrackId = resolveAudioTrackId(audioTracks, opts.track);\n await ensureWritable(opts.output, {\n force: opts.force,\n isTTY: deps.isTTY,\n confirm: deps.confirm,\n exists: deps.exists,\n inputs: [opts.video, opts.audio]\n });\n const result = await deps.runner.run(\n buildMuxArgs({\n video: opts.video,\n audio: opts.audio,\n audioTrackId,\n delayMs: opts.delayMs,\n language: opts.language,\n trackName: opts.name,\n makeDefault: opts.makeDefault,\n output: opts.output\n })\n );\n if (result.exitCode >= 2) {\n throw new CliError(\n `mkvmerge failed (exit ${result.exitCode}):\\n${result.stderr || result.stdout}`\n );\n }\n return opts.output;\n}\n","import type { CommandDeps } from '../types.ts';\n\nimport { defaultTestClipOutput, ensureWritable, requireInputFile } from '../output.ts';\nimport { toolPath } from '../paths.ts';\nimport { audioRelativeIndex, probeTracks, requireAudioTrack } from '../probe.ts';\nimport { CliError } from '../types.ts';\n\nexport function parseTimeToSeconds(value: string): number {\n if (/^\\d+(\\.\\d+)?$/.test(value)) return Number(value);\n const match = /^(\\d+):([0-5]?\\d):([0-5]?\\d(?:\\.\\d+)?)$/.exec(value);\n if (!match)\n throw new CliError(`Invalid time \"${value}\". Use seconds (90 or 90.5) or HH:MM:SS[.ms].`);\n return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);\n}\n\nexport function buildTestClipArgs(a: {\n video: string;\n audio: string;\n audioStreamIndex: number;\n startSeconds: number;\n durationSeconds: number;\n delayMs: number;\n output: string;\n}): string[] {\n // -y is safe: our own overwrite policy (ensureWritable) has already run.\n return [\n 'ffmpeg',\n '-hide_banner',\n '-nostdin',\n '-ss',\n String(a.startSeconds),\n '-i',\n toolPath(a.video),\n '-itsoffset',\n String(a.delayMs / 1000),\n '-ss',\n String(a.startSeconds),\n '-i',\n toolPath(a.audio),\n '-map',\n '0:v:0',\n '-map',\n `1:a:${a.audioStreamIndex}`,\n '-t',\n String(a.durationSeconds),\n '-c:v',\n 'libx264',\n '-preset',\n 'veryfast',\n '-crf',\n '18',\n '-c:a',\n 'copy',\n '-sn',\n '-abort_on',\n 'empty_output_stream',\n '-y',\n toolPath(a.output)\n ];\n}\n\nexport async function testClipCommand(\n opts: {\n video: string;\n audio: string;\n track: number;\n start: string;\n duration: string;\n delayMs: number;\n output?: string;\n force: boolean;\n },\n deps: CommandDeps\n): Promise<string> {\n requireInputFile(opts.video, deps.exists);\n requireInputFile(opts.audio, deps.exists);\n const startSeconds = parseTimeToSeconds(opts.start);\n const durationSeconds = parseTimeToSeconds(opts.duration);\n if (durationSeconds <= 0) throw new CliError('--duration must be greater than zero.');\n const donorTracks = await probeTracks(deps.runner, opts.audio);\n requireAudioTrack(donorTracks, opts.track);\n const output = opts.output ?? defaultTestClipOutput(opts.video);\n await ensureWritable(output, {\n force: opts.force,\n isTTY: deps.isTTY,\n confirm: deps.confirm,\n exists: deps.exists,\n inputs: [opts.video, opts.audio]\n });\n const result = await deps.runner.run(\n buildTestClipArgs({\n video: opts.video,\n audio: opts.audio,\n audioStreamIndex: audioRelativeIndex(donorTracks, opts.track),\n startSeconds,\n durationSeconds,\n delayMs: opts.delayMs,\n output\n })\n );\n if (result.exitCode !== 0) {\n throw new CliError(\n `ffmpeg failed (exit ${result.exitCode}):\\n${result.stderr || result.stdout}`\n );\n }\n return output;\n}\n","import { parseArgs } from 'node:util';\n\nimport type { CommandDeps } from './types.ts';\n\nimport { extractCommand } from './commands/extract.ts';\nimport { inspectCommand } from './commands/inspect.ts';\nimport { muxCommand } from './commands/mux.ts';\nimport { testClipCommand } from './commands/test-clip.ts';\nimport { CliError } from './types.ts';\n\nconst USAGE = `tracksmith — inspect, extract, sync-test, and mux Matroska audio tracks\n\nUsage:\n tracksmith inspect <file>\n tracksmith extract <file> --track <id> [--output <file>] [--force]\n tracksmith test-clip --video <target> --audio <donor> --track <id> --start <time>\n [--duration 60] [--delay-ms 0] [--output <file>] [--force]\n tracksmith mux --video <target> --audio <mka-or-mkv> --output <file>\n [--track <id>] [--delay-ms 0] [--language eng] [--name <title>]\n [--default] [--force]\n\nNotes:\n --track is always the MKVToolNix track ID shown by \"tracksmith inspect\".\n Positive --delay-ms delays audio; negative advances it (e.g. --delay-ms -250).\n Times accept seconds (90, 90.5) or HH:MM:SS[.ms].\n Check sync near the beginning, middle, and end; a changing offset cannot be fixed by one delay.\n Requires ffmpeg and MKVToolNix (mkvmerge) on PATH.`;\n\nfunction parseIntStrict(value: string | undefined, flag: string): number {\n if (value === undefined) throw new CliError(`${flag} is required.`);\n if (!/^-?\\d+$/.test(value)) throw new CliError(`${flag} must be an integer, got \"${value}\".`);\n return Number(value);\n}\n\nfunction parseTrack(value: string | undefined): number {\n const track = parseIntStrict(value, '--track');\n if (track < 0) throw new CliError(`--track must be >= 0, got ${track}.`);\n return track;\n}\n\nfunction requireFilePositional(positionals: string[], command: 'inspect' | 'extract'): string {\n if (positionals.length === 0) throw new CliError(`${command} requires a file argument.`);\n if (positionals.length > 1) throw new CliError(`${command} accepts exactly one file argument.`);\n return positionals[0]!;\n}\n\n// Wraps a parseArgs call so parse failures become CliErrors. Takes a thunk\n// (rather than the options object) so parseArgs keeps its precise generic\n// inference over each command's inline options literal.\nfunction parseOrCliError<T>(parseFn: () => T): T {\n try {\n return parseFn();\n } catch (error) {\n throw new CliError(error instanceof Error ? error.message : String(error));\n }\n}\n\n// parseArgs treats a leading-dash value token (\"-250\") as an option, so fold\n// `--delay-ms -250` into the equals form it does accept.\nfunction normalizeDelayMs(args: string[]): string[] {\n const out: string[] = [];\n for (let i = 0; i < args.length; i += 1) {\n const arg = args[i]!;\n const next = args[i + 1];\n if (arg === '--delay-ms' && next !== undefined && /^-\\d+$/.test(next)) {\n out.push(`--delay-ms=${next}`);\n i += 1;\n } else {\n out.push(arg);\n }\n }\n return out;\n}\n\nexport async function runCli(\n argv: string[],\n deps: CommandDeps,\n stdout: (line: string) => void = console.log\n): Promise<number> {\n const [command, ...rawRest] = argv;\n const rest = normalizeDelayMs(rawRest);\n try {\n switch (command) {\n case undefined: {\n stdout(USAGE);\n return 1;\n }\n case '--help':\n case '-h': {\n stdout(USAGE);\n return 0;\n }\n case 'inspect': {\n const { positionals } = parseOrCliError(() =>\n parseArgs({ args: rest, options: {}, allowPositionals: true, strict: true })\n );\n const file = requireFilePositional(positionals, 'inspect');\n stdout(await inspectCommand(file, deps));\n return 0;\n }\n case 'extract': {\n const { values, positionals } = parseOrCliError(() =>\n parseArgs({\n args: rest,\n options: {\n track: { type: 'string' },\n output: { type: 'string' },\n force: { type: 'boolean', default: false }\n },\n allowPositionals: true,\n strict: true\n })\n );\n const file = requireFilePositional(positionals, 'extract');\n const output = await extractCommand(\n {\n file,\n track: parseTrack(values.track),\n output: values.output,\n force: values.force ?? false\n },\n deps\n );\n stdout(`Wrote ${output}`);\n return 0;\n }\n case 'test-clip': {\n const { values } = parseOrCliError(() =>\n parseArgs({\n args: rest,\n options: {\n video: { type: 'string' },\n audio: { type: 'string' },\n track: { type: 'string' },\n start: { type: 'string' },\n duration: { type: 'string', default: '60' },\n 'delay-ms': { type: 'string', default: '0' },\n output: { type: 'string' },\n force: { type: 'boolean', default: false }\n },\n strict: true\n })\n );\n if (!values.video || !values.audio)\n throw new CliError('test-clip requires --video and --audio.');\n if (!values.start) throw new CliError('test-clip requires --start.');\n const output = await testClipCommand(\n {\n video: values.video,\n audio: values.audio,\n track: parseTrack(values.track),\n start: values.start,\n duration: values.duration ?? '60',\n delayMs: parseIntStrict(values['delay-ms'] ?? '0', '--delay-ms'),\n output: values.output,\n force: values.force ?? false\n },\n deps\n );\n stdout(`Wrote ${output}`);\n return 0;\n }\n case 'mux': {\n const { values } = parseOrCliError(() =>\n parseArgs({\n args: rest,\n options: {\n video: { type: 'string' },\n audio: { type: 'string' },\n track: { type: 'string' },\n 'delay-ms': { type: 'string', default: '0' },\n language: { type: 'string', default: 'eng' },\n name: { type: 'string' },\n default: { type: 'boolean', default: false },\n output: { type: 'string' },\n force: { type: 'boolean', default: false }\n },\n strict: true\n })\n );\n if (!values.video || !values.audio) throw new CliError('mux requires --video and --audio.');\n if (!values.output)\n throw new CliError('mux requires --output (no default output name for the final file).');\n const output = await muxCommand(\n {\n video: values.video,\n audio: values.audio,\n track: values.track === undefined ? undefined : parseTrack(values.track),\n delayMs: parseIntStrict(values['delay-ms'] ?? '0', '--delay-ms'),\n language: values.language,\n name: values.name,\n makeDefault: values.default ?? false,\n output: values.output,\n force: values.force ?? false\n },\n deps\n );\n stdout(`Wrote ${output}`);\n return 0;\n }\n default:\n throw new CliError(`Unknown command \"${command}\".\\n\\n${USAGE}`);\n }\n } catch (error) {\n if (error instanceof CliError) {\n console.error(`tracksmith: ${error.message}`);\n return error.exitCode;\n }\n throw error;\n }\n}\n","import { once } from 'node:events';\nimport { createInterface } from 'node:readline/promises';\n\nimport type { Readable, Writable } from 'node:stream';\n\nexport interface ConfirmPromptOptions {\n input?: Readable;\n output?: Writable;\n}\n\nexport function createConfirmPrompt(\n options: ConfirmPromptOptions = {}\n): (message: string) => Promise<boolean> {\n const input = options.input ?? process.stdin;\n const output = options.output ?? process.stdout;\n\n return async (message: string): Promise<boolean> => {\n const readline = createInterface({ input, output });\n\n try {\n const closed = once(readline, 'close').then(() => ({ kind: 'eof' }) as const);\n const answered = readline.question(`${message} [y/N] `).then(\n (value) => ({ kind: 'answer', value }) as const,\n () => ({ kind: 'eof' }) as const\n );\n const result = await Promise.race([answered, closed]);\n\n return result.kind === 'answer' && /^y(?:es)?$/i.test(result.value.trim());\n } finally {\n readline.close();\n }\n };\n}\n","export const TOOLS = ['ffmpeg', 'mkvmerge'] as const;\n\nexport type ToolName = (typeof TOOLS)[number];\n\nexport function isToolName(value: string): value is ToolName {\n return TOOLS.some((tool) => tool === value);\n}\n\nconst INSTALL_HINTS = {\n ffmpeg:\n 'Windows: winget install Gyan.FFmpeg or scoop install ffmpeg | macOS: brew install ffmpeg | Linux: sudo apt install ffmpeg or sudo pacman -S ffmpeg',\n mkvmerge:\n 'Windows: winget install MoritzBunkus.MKVToolNix or scoop install mkvtoolnix | macOS: brew install mkvtoolnix | Linux: sudo apt install mkvtoolnix or sudo pacman -S mkvtoolnix-cli'\n} satisfies Record<ToolName, string>;\n\nexport function installHint(tool: ToolName): string {\n return INSTALL_HINTS[tool];\n}\n","import { spawn } from 'node:child_process';\n\nimport type { Runner, RunResult } from './types.ts';\n\nimport { installHint, isToolName } from './tools.ts';\nimport { CliError } from './types.ts';\n\nexport function processStartError(program: string, error: unknown): CliError {\n if (\n typeof error === 'object' &&\n error !== null &&\n 'code' in error &&\n error.code === 'ENOENT' &&\n isToolName(program)\n ) {\n return new CliError(\n `${program} is required but was not found on PATH. ${installHint(program)}`\n );\n }\n\n const message = error instanceof Error ? error.message : String(error);\n return new CliError(`Failed to start ${program}: ${message}`);\n}\n\nexport class ProcessRunner implements Runner {\n async run(argv: readonly string[]): Promise<RunResult> {\n const [executable, ...args] = argv;\n if (executable === undefined) {\n throw new CliError('Could not start process: missing executable');\n }\n\n return await new Promise((resolve, reject) => {\n const process = spawn(executable, args, {\n stdio: ['ignore', 'pipe', 'pipe']\n });\n let stdout = '';\n let stderr = '';\n\n process.stdout.setEncoding('utf8');\n process.stderr.setEncoding('utf8');\n process.stdout.on('data', (chunk: string) => {\n stdout += chunk;\n });\n process.stderr.on('data', (chunk: string) => {\n stderr += chunk;\n });\n process.once('error', (error) => {\n reject(processStartError(executable, error));\n });\n process.once('close', (exitCode) => {\n resolve({ exitCode: exitCode ?? 1, stdout, stderr });\n });\n });\n }\n}\n","#!/usr/bin/env node\n\nimport { runCli } from './cli.ts';\nimport { createConfirmPrompt } from './prompt.ts';\nimport { ProcessRunner } from './runner.ts';\n\nprocess.exitCode = await runCli(process.argv.slice(2), {\n runner: new ProcessRunner(),\n isTTY: process.stdin.isTTY ?? false,\n confirm: createConfirmPrompt()\n});\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tracksmith",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "CLI for inspecting, extracting, syncing, and muxing Matroska audio tracks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tracksmith": "./dist/tracksmith.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"packageManager": "bun@1.3.14",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/UzEE/tracksmith.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/UzEE/tracksmith/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/UzEE/tracksmith#readme",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"matroska",
|
|
29
|
+
"mkv",
|
|
30
|
+
"audio",
|
|
31
|
+
"ffmpeg",
|
|
32
|
+
"mkvtoolnix",
|
|
33
|
+
"cli"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org/",
|
|
38
|
+
"tag": "latest"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"cli": "bun run src/tracksmith.ts",
|
|
42
|
+
"build": "bun run scripts/build-package.ts",
|
|
43
|
+
"package:smoke": "bun run scripts/package-smoke.ts",
|
|
44
|
+
"release": "bun run scripts/release.ts",
|
|
45
|
+
"check:changeset": "bun run scripts/check-changeset.ts",
|
|
46
|
+
"changeset": "changeset",
|
|
47
|
+
"version": "changeset version",
|
|
48
|
+
"test": "vp test run",
|
|
49
|
+
"check": "vp check",
|
|
50
|
+
"check:fix": "vp check --fix",
|
|
51
|
+
"lint": "vp lint",
|
|
52
|
+
"format": "vp fmt",
|
|
53
|
+
"typecheck": "vp exec tsc --noEmit"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"zod": "^4"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@changesets/changelog-github": "1.0.0",
|
|
60
|
+
"@changesets/cli": "3.0.1",
|
|
61
|
+
"@types/node": "^22.20.1",
|
|
62
|
+
"typescript": "^7.0.0",
|
|
63
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.6",
|
|
64
|
+
"vite-plus": "0.2.6"
|
|
65
|
+
},
|
|
66
|
+
"overrides": {
|
|
67
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.6"
|
|
68
|
+
}
|
|
69
|
+
}
|