UNPKG

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
"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;