short-video-maker
Version:
Creates short videos for TikTok, Instagram Reels, and YouTube Shorts using the Model Context Protocol (MCP) and a REST API.
202 lines (201 loc) • 7.49 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ShortCreator = void 0;
const shorts_1 = require("./../types/shorts");
/* eslint-disable @remotion/deterministic-randomness */
const fs_extra_1 = __importDefault(require("fs-extra"));
const cuid_1 = __importDefault(require("cuid"));
const path_1 = __importDefault(require("path"));
const logger_1 = require("../logger");
class ShortCreator {
config;
remotion;
kokoro;
whisper;
ffmpeg;
pexelsApi;
musicManager;
queue = [];
constructor(config, remotion, kokoro, whisper, ffmpeg, pexelsApi, musicManager) {
this.config = config;
this.remotion = remotion;
this.kokoro = kokoro;
this.whisper = whisper;
this.ffmpeg = ffmpeg;
this.pexelsApi = pexelsApi;
this.musicManager = musicManager;
}
status(id) {
const videoPath = this.getVideoPath(id);
if (this.queue.find((item) => item.id === id)) {
return "processing";
}
if (fs_extra_1.default.existsSync(videoPath)) {
return "ready";
}
return "failed";
}
addToQueue(sceneInput, config) {
// todo add mutex lock
const id = (0, cuid_1.default)();
this.queue.push({
sceneInput,
config,
id,
});
if (this.queue.length === 1) {
this.processQueue();
}
return id;
}
async processQueue() {
// todo add a semaphore
if (this.queue.length === 0) {
return;
}
const { sceneInput, config, id } = this.queue[0];
logger_1.logger.debug({ sceneInput, config, id }, "Processing video item in the queue");
try {
await this.createShort(id, sceneInput, config);
logger_1.logger.debug({ id }, "Video created successfully");
}
catch (error) {
logger_1.logger.error(error, "Error creating video");
}
finally {
this.queue.shift();
this.processQueue();
}
}
async createShort(videoId, inputScenes, config) {
logger_1.logger.debug({
inputScenes,
config,
}, "Creating short video");
const scenes = [];
let totalDuration = 0;
const excludeVideoIds = [];
const tempFiles = [];
const orientation = config.orientation || shorts_1.OrientationEnum.portrait;
let index = 0;
for (const scene of inputScenes) {
const audio = await this.kokoro.generate(scene.text, config.voice ?? "af_heart");
let { audioLength } = audio;
const { audio: audioStream } = audio;
// add the paddingBack in seconds to the last scene
if (index + 1 === inputScenes.length && config.paddingBack) {
audioLength += config.paddingBack / 1000;
}
const tempId = (0, cuid_1.default)();
const tempWavFileName = `${tempId}.wav`;
const tempMp3FileName = `${tempId}.mp3`;
const tempWavPath = path_1.default.join(this.config.tempDirPath, tempWavFileName);
const tempMp3Path = path_1.default.join(this.config.tempDirPath, tempMp3FileName);
tempFiles.push(tempWavPath, tempMp3Path);
await this.ffmpeg.saveNormalizedAudio(audioStream, tempWavPath);
const captions = await this.whisper.CreateCaption(tempWavPath);
await this.ffmpeg.saveToMp3(audioStream, tempMp3Path);
const video = await this.pexelsApi.findVideo(scene.searchTerms, audioLength, excludeVideoIds, orientation);
excludeVideoIds.push(video.id);
scenes.push({
captions,
video: video.url,
audio: {
url: `http://localhost:${this.config.port}/api/tmp/${tempMp3FileName}`,
duration: audioLength,
},
});
totalDuration += audioLength;
index++;
}
if (config.paddingBack) {
totalDuration += config.paddingBack / 1000;
}
const selectedMusic = this.findMusic(totalDuration, config.music);
logger_1.logger.debug({ selectedMusic }, "Selected music for the video");
await this.remotion.render({
music: selectedMusic,
scenes,
config: {
durationMs: totalDuration * 1000,
paddingBack: config.paddingBack,
...{
captionBackgroundColor: config.captionBackgroundColor,
captionPosition: config.captionPosition,
},
musicVolume: config.musicVolume,
},
}, videoId, orientation);
for (const file of tempFiles) {
fs_extra_1.default.removeSync(file);
}
return videoId;
}
getVideoPath(videoId) {
return path_1.default.join(this.config.videosDirPath, `${videoId}.mp4`);
}
deleteVideo(videoId) {
const videoPath = this.getVideoPath(videoId);
fs_extra_1.default.removeSync(videoPath);
logger_1.logger.debug({ videoId }, "Deleted video file");
}
getVideo(videoId) {
const videoPath = this.getVideoPath(videoId);
if (!fs_extra_1.default.existsSync(videoPath)) {
throw new Error(`Video ${videoId} not found`);
}
return fs_extra_1.default.readFileSync(videoPath);
}
findMusic(videoDuration, tag) {
const musicFiles = this.musicManager.musicList().filter((music) => {
if (tag) {
return music.mood === tag;
}
return true;
});
return musicFiles[Math.floor(Math.random() * musicFiles.length)];
}
ListAvailableMusicTags() {
const tags = new Set();
this.musicManager.musicList().forEach((music) => {
tags.add(music.mood);
});
return Array.from(tags.values());
}
listAllVideos() {
const videos = [];
// Check if videos directory exists
if (!fs_extra_1.default.existsSync(this.config.videosDirPath)) {
return videos;
}
// Read all files in the videos directory
const files = fs_extra_1.default.readdirSync(this.config.videosDirPath);
// Filter for MP4 files and extract video IDs
for (const file of files) {
if (file.endsWith(".mp4")) {
const videoId = file.replace(".mp4", "");
let status = "ready";
const inQueue = this.queue.find((item) => item.id === videoId);
if (inQueue) {
status = "processing";
}
videos.push({ id: videoId, status });
}
}
// Add videos that are in the queue but not yet rendered
for (const queueItem of this.queue) {
const existingVideo = videos.find((v) => v.id === queueItem.id);
if (!existingVideo) {
videos.push({ id: queueItem.id, status: "processing" });
}
}
return videos;
}
ListAvailableVoices() {
return this.kokoro.listAvailableVoices();
}
}
exports.ShortCreator = ShortCreator;