add-music-to-video
Version:
Add background music to my videos very easily
500 lines (483 loc) • 17.4 kB
JavaScript
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
// src/index.ts
var import_cac = __toESM(require("cac"));
// src/run.ts
var import_fs4 = __toESM(require("fs"));
var import_fast_glob = __toESM(require("fast-glob"));
var import_path5 = __toESM(require("path"));
var import_ffmpeg = __toESM(require("ffmpeg"));
var import_ffmpeg_static = __toESM(require("ffmpeg-static"));
var import_child_process4 = require("child_process");
// src/utils/assert.utils.ts
function invariant(condition, message) {
if (condition) {
return;
}
const prefix = "Invariant failed";
const providedMessage = typeof message === "function" ? message() : message;
const value = providedMessage ? `${prefix}: ${providedMessage}` : prefix;
const error = new Error(value);
error.name = "AssertError";
throw error;
}
var assert = (condition, message) => {
invariant(
condition,
typeof message === "string" ? `AssertError: ${message}` : message || `AssertError: condition must be truthy`
);
const _condition = condition;
return _condition;
};
// src/core/video/index.ts
var import_dist = __toESM(require("cli-file-select/dist"));
var import_prompts2 = __toESM(require("prompts"));
// src/core/video/youtube.video.ts
var import_prompts = __toESM(require("prompts"));
var import_fs = __toESM(require("fs"));
var import_path = __toESM(require("path"));
var import_ytdl_core = __toESM(require("ytdl-core"));
var downloadFromYoutube = async (url) => {
return new Promise((resolve, reject) => {
const file = import_path.default.join(__dirname, "random-video.mp4");
const downloadStream = (0, import_ytdl_core.default)(url).pipe(import_fs.default.createWriteStream(file));
downloadStream.on("error", (error) => {
console.error(error);
downloadStream.destroy && downloadStream.destroy();
downloadStream.close && downloadStream.close();
reject();
});
downloadStream.on("close", () => {
resolve(file);
});
});
};
var downloadYoutubeVideo = async ({
getYoutubeUrl = () => (0, import_prompts.default)({
type: "text",
name: "url",
message: "Enter a valid youtube URL"
}).then((res) => res.url),
download = downloadFromYoutube
} = {}) => {
const url = await getYoutubeUrl();
console.log("Downloading:", url);
return download(url);
};
// src/core/video/index.ts
var channels = [
{
title: "Local File System",
value: "local"
},
{
title: "Youtube",
value: "youtube"
}
];
var selectChannel = async () => {
return (0, import_prompts2.default)({
type: "select",
name: "channel",
message: "Where can your video be found?",
choices: channels
}).then((res) => res.channel);
};
var selectVideoFile = async ({
select = selectChannel,
getLocalVideo = import_dist.default,
getYoutubeVideo = downloadYoutubeVideo
} = {}) => {
const channel = await select();
if (channel === "local") {
return getLocalVideo();
} else {
return getYoutubeVideo();
}
};
// src/core/music/index.ts
var import_prompts4 = __toESM(require("prompts"));
// src/core/music/random.music.ts
var import_fs2 = __toESM(require("fs"));
var import_path2 = __toESM(require("path"));
var import_get_random_music = __toESM(require("get-random-music"));
var import_child_process = require("child_process");
var expandMp3 = async (audioPath, duration) => {
console.log("Expanding Music to fit video of", duration, "seconds");
const audioRepeatCount = Math.max(Math.ceil(duration / 15), 1);
const repeatInputFile = import_path2.default.join(__dirname, "audio-repeat.txt").replace(/\\/g, "\\\\");
import_fs2.default.writeFileSync(
repeatInputFile,
`file '${import_path2.default.relative(__dirname, audioPath)}'
`.repeat(audioRepeatCount),
"utf8"
);
const audioDir = import_path2.default.dirname(audioPath);
const audioFilename = audioPath.replace(audioDir, "").replace(import_path2.default.delimiter, "").replace(/\.\w+$/, "");
const audioRepeatedFilePath = import_path2.default.join(
import_path2.default.dirname(audioPath),
`${audioFilename}_repeated_${duration}s.mp3`
);
(0, import_child_process.execSync)(
`ffmpeg -y -t ${duration} -f concat -i ${repeatInputFile} -c copy ${audioRepeatedFilePath}`,
{
cwd: import_path2.default.dirname(repeatInputFile)
}
);
return audioRepeatedFilePath;
};
var downloadRandomMusic = async (duration, {
getStream = (outFilePath) => (0, import_get_random_music.default)().then((res) => {
res.data.pipe(import_fs2.default.createWriteStream(outFilePath));
return res.data;
})
} = {}) => {
console.log("Downloading Random Music");
const musicFilePath = import_path2.default.join(__dirname, "random-music.mp3");
const downloadStream = await getStream(musicFilePath);
await new Promise((resolve, reject) => {
downloadStream.on("error", (error) => {
console.error(error);
downloadStream.destroy && downloadStream.destroy();
downloadStream.close && downloadStream.close();
reject();
});
downloadStream.on("close", () => {
resolve(musicFilePath);
});
});
return expandMp3(musicFilePath, duration);
};
// src/core/music/youtube.music.ts
var import_fs3 = __toESM(require("fs"));
var import_path3 = __toESM(require("path"));
var import_prompts3 = __toESM(require("prompts"));
var import_ytdl_core2 = __toESM(require("ytdl-core"));
var import_child_process2 = require("child_process");
var downloadFromYoutube2 = async (url) => {
return new Promise((resolve, reject) => {
const file = import_path3.default.join(__dirname, "random-music.m4a");
const downloadStream = (0, import_ytdl_core2.default)(url, {
filter: "audioonly"
}).pipe(import_fs3.default.createWriteStream(file));
downloadStream.on("error", (error) => {
console.error(error);
downloadStream.destroy && downloadStream.destroy();
downloadStream.close && downloadStream.close();
reject();
});
downloadStream.on("close", () => {
resolve(file);
});
});
};
var convertM4aToMp3 = async (m4aFile) => {
const mp3File = import_path3.default.join(__dirname, "random-music.mp3");
(0, import_child_process2.execSync)(
`ffmpeg -i "${m4aFile}" -c:v copy -c:a libmp3lame -q:a 4 "${mp3File}"`
);
return mp3File;
};
var truncateMp3 = async (mp3File, duration) => {
const truncatedMp3File = import_path3.default.join(__dirname, "truncated-random-music.mp3");
(0, import_child_process2.execSync)(`ffmpeg -i "${mp3File}" -ss ${0} -to ${duration} "${truncatedMp3File}"`);
return truncatedMp3File;
};
var downloadYoutubeMusic = async (duration, {
getYoutubeUrl = () => (0, import_prompts3.default)({
type: "text",
name: "url",
message: "Enter a valid youtube URL"
}).then((res) => res.url),
download = downloadFromYoutube2,
convert = convertM4aToMp3,
truncate = truncateMp3
} = {}) => {
const url = await getYoutubeUrl();
console.log("Downloading:", url);
const m4aFile = await download(url);
const mp3File = await convert(m4aFile);
return truncate(mp3File, duration);
};
// src/core/music/local.music.ts
var import_child_process3 = require("child_process");
var import_cli_file_select = __toESM(require("cli-file-select"));
var import_path4 = __toESM(require("path"));
var truncateMp32 = async (mp3File, duration) => {
const truncatedMp3File = import_path4.default.join(__dirname, "truncated-random-music.mp3");
(0, import_child_process3.execSync)(
`ffmpeg -i "${mp3File}" -ss ${0} -to ${duration} "${truncatedMp3File}"`
);
return truncatedMp3File;
};
var downloadLocalMusic = async (duration, { truncate = truncateMp32, getFilePath = import_cli_file_select.default } = {}) => {
const file = await getFilePath();
return truncate(file, duration);
};
// src/core/music/index.ts
var channels2 = [
{
title: "Local File System",
value: "local"
},
{
title: "Youtube",
value: "youtube"
},
{
title: "Random",
value: "random"
}
];
var selectChannel2 = async () => {
return (0, import_prompts4.default)({
type: "select",
name: "channel",
message: "Where can your music be found?",
choices: channels2
}).then((res) => res.channel);
};
var downloadMusic = async (duration, options = {}, {
select = selectChannel2,
getLocalMusic = downloadLocalMusic,
getYoutubeMusic = downloadYoutubeMusic,
getRandomMusic: getRandomMusic2 = downloadRandomMusic
} = {}) => {
const channel = options.musicSource || await select();
if (channel === "local") {
if (!options.localMusicPath) {
throw new Error(
"Local music path is required when using local music source"
);
}
return getLocalMusic(duration, {
getFilePath: async () => options.localMusicPath
});
} else if (channel === "youtube") {
if (!options.youtubeUrl) {
throw new Error(
"YouTube URL is required when using YouTube music source"
);
}
return getYoutubeMusic(duration, {
getYoutubeUrl: async () => options.youtubeUrl
});
} else {
return getRandomMusic2(duration);
}
};
// src/run.ts
import_ffmpeg.default.bin = import_ffmpeg_static.default;
var cleanupFiles = async () => {
console.log("Cleaning up files");
const files = await (0, import_fast_glob.default)(["*.mp3", "*.txt", "*.m4a"], {
cwd: __dirname
});
for (let file of files) {
import_fs4.default.unlinkSync(import_path5.default.join(__dirname, file));
console.log("Removed", import_path5.default.join(__dirname, file));
}
};
var addMusicToVideo = async (musicFilePath, videoFilePath) => {
const outDir = import_path5.default.dirname(videoFilePath);
const filename = videoFilePath.replace(outDir, "").replace(import_path5.default.delimiter, "").replace(/\.\w+$/, "");
const outVideoFilePath = import_path5.default.join(
outDir,
`${filename}_with_music.mp4`.replace(/ /g, "_")
);
(0, import_child_process4.execSync)(
`ffmpeg -y -i "${videoFilePath}" -i "${musicFilePath}" -c:v copy -map 0:v:0 -map 1:a:0 -f mp4 "${outVideoFilePath}"`
);
return outVideoFilePath;
};
var run = async (options, {
getInputVideoFilePath = selectVideoFile,
getVideoDuration = async (filePath) => {
const video = await new import_ffmpeg.default(filePath);
return assert(video.metadata.duration).seconds || 60;
},
getMusicFilePath = downloadMusic,
cleanup = cleanupFiles,
join = addMusicToVideo
} = {}) => {
const inVideoFilePath = options.inputVideoFilePath || await getInputVideoFilePath();
const allowedFormats = [".mp4"];
if (!allowedFormats.some((format) => inVideoFilePath.endsWith(format))) {
console.error("Input file not matching allowedFormats", {
allowedFormats,
file: inVideoFilePath
});
process.exit(0);
}
console.log("Input:", inVideoFilePath);
const duration = await getVideoDuration(inVideoFilePath);
console.log("Duration:", duration);
const audioFilePath = options.outputPath || await getMusicFilePath(duration, options);
const outVideoFilePath = await join(audioFilePath, inVideoFilePath);
await cleanup();
console.log("Output:", outVideoFilePath);
return outVideoFilePath;
};
var run_default = run;
// src/install.ts
var import_path6 = __toESM(require("path"));
var import_shell_context_menu = __toESM(require("shell-context-menu"));
var import_os = __toESM(require("os"));
var install = async () => {
if (import_os.default.platform() !== "win32") {
console.log("OS:", import_os.default.platform());
console.log("Not on Windows, skipping context menu installation");
return;
}
console.log("Installing to", __dirname);
await import_shell_context_menu.default.removeCommand("Add Music to Video").catch(() => {
});
await import_shell_context_menu.default.registerCommand({
name: "Add Music to Video",
icon: "C:\\WINDOWS\\system32\\cmd.exe",
command: import_path6.default.join(__dirname, "run.cmd"),
menu: "Add Music to Video"
});
console.log("Created context menu");
};
var install_default = install;
// src/uninstall.ts
var import_child_process5 = require("child_process");
var import_shell_context_menu2 = __toESM(require("shell-context-menu"));
var import_os2 = __toESM(require("os"));
var uninstall = async () => {
if (import_os2.default.platform() !== "win32") {
console.log("OS:", import_os2.default.platform());
console.log("Not on Windows, skipping context menu uninstallation");
return;
}
await import_shell_context_menu2.default.removeCommand("Add Music to Video").catch(() => {
});
console.log("Removed context menu");
(0, import_child_process5.execSync)("npm uninstall -g add-music-to-video");
};
var uninstall_default = uninstall;
// src/watch.ts
var import_chokidar = __toESM(require("chokidar"));
var import_fs5 = __toESM(require("fs"));
var canBeProcessed = (filePath) => {
return filePath.endsWith(".mp4") && !filePath.endsWith("_with_music.mp4");
};
var watch = async (options) => {
const { inputPath, ...cliOptions } = options;
console.log(`Watching ${inputPath} for new video files...`);
const watcher = import_chokidar.default.watch(inputPath, {
depth: 2,
ignoreInitial: false,
persistent: true,
ignored: ["**/*.mp4"]
});
const filesProcessed = /* @__PURE__ */ new Set();
const processingQueue = [];
let isProcessing = false;
const processNextFile = async () => {
if (isProcessing || processingQueue.length === 0) {
return;
}
isProcessing = true;
const filePath = processingQueue.shift();
try {
console.log(`Processing video: ${filePath}`);
await run_default(
{
...cliOptions,
inputVideoFilePath: filePath
},
{
getInputVideoFilePath: async () => filePath
}
);
import_fs5.default.unlinkSync(filePath);
console.log(`Deleted original file: ${filePath}`);
} catch (error) {
console.error(`Error processing ${filePath}:`, error);
} finally {
isProcessing = false;
await processNextFile();
}
};
watcher.on("all", async (event, filePath) => {
if (!canBeProcessed(filePath)) {
return;
}
if (filesProcessed.has(filePath)) {
return;
}
filesProcessed.add(filePath);
processingQueue.push(filePath);
await processNextFile();
});
watcher.on("error", (error) => {
console.error("Watch error:", error);
});
return watcher;
};
// src/index.ts
var cli = (0, import_cac.default)("add-music-to-video");
var runCmd = cli.command("[...args]", "Add music to a video");
runCmd.option("-i, --input [path]", "Input video file", {}).option("-m, --music [source]", "Music source (youtube, local, random)").option("-y, --youtube [url]", "Youtube URL").option("-l, --file-path [path]", "Local music file path").option("-o, --output [path]", "Output video file").action(
async (args, options) => {
const { input, music, youtube, filePath, output } = options;
await run_default(
{
inputVideoFilePath: input,
musicSource: music,
youtubeUrl: youtube,
localMusicPath: filePath,
outputPath: output
},
{
getInputVideoFilePath: input ? async () => input : void 0
}
);
}
);
cli.command("watch", "Watch a folder for new videos and automatically add music").option("-i, --input <path>", "Folder path to watch (required)").option("-m, --music-source <source>", "Music source (youtube, local, random)", {
default: "random"
}).option("-y, --youtube-url <url>", "Youtube URL (required for youtube source)").option("-l, --local-music-path <path>", "Local music file path (required for local source)").option("-o, --output-path <path>", "Output video file path").action((args) => {
if (!args.input) {
console.error("Error: --input option is required");
process.exit(1);
}
const options = {
inputPath: args.input,
musicSource: args.musicSource,
youtubeUrl: args.youtubeUrl,
localMusicPath: args.localMusicPath,
outputPath: args.outputPath
};
return watch(options);
});
cli.command("install", "Installs this program").action(install_default);
cli.command("uninstall", "Uninstalls this program").action(uninstall_default);
(async () => {
cli.help();
cli.parse();
})();