UNPKG

pupcaps

Version:

PupCaps! : A script to add stylish captions to your videos.

851 lines (820 loc) 28.1 kB
'use strict'; var fs = require('fs'); var require$$0 = require('commander'); var path = require('path'); var cliProgress = require('cli-progress'); var tmp = require('tmp'); var pngjs = require('pngjs'); var ffmpeg = require('fluent-ffmpeg'); var puppeteer = require('puppeteer'); var stream = require('stream'); var httpServer = require('http-server'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path); var cliProgress__namespace = /*#__PURE__*/_interopNamespaceDefault(cliProgress); var tmp__namespace = /*#__PURE__*/_interopNamespaceDefault(tmp); var puppeteer__namespace = /*#__PURE__*/_interopNamespaceDefault(puppeteer); function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var extraTypings = {exports: {}}; var hasRequiredExtraTypings; function requireExtraTypings () { if (hasRequiredExtraTypings) return extraTypings.exports; hasRequiredExtraTypings = 1; (function (module, exports) { const commander = require$$0; exports = module.exports = {}; // Return a different global program than commander, // and don't also return it as default export. exports.program = new commander.Command(); /** * Expose classes. The FooT versions are just types, so return Commander original implementations! */ exports.Argument = commander.Argument; exports.Command = commander.Command; exports.CommanderError = commander.CommanderError; exports.Help = commander.Help; exports.InvalidArgumentError = commander.InvalidArgumentError; exports.InvalidOptionArgumentError = commander.InvalidArgumentError; // Deprecated exports.Option = commander.Option; // In Commander, the create routines end up being aliases for the matching // methods on the global program due to the (deprecated) legacy default export. // Here we roll our own, the way Commander might in future. exports.createCommand = (name) => new commander.Command(name); exports.createOption = (flags, description) => new commander.Option(flags, description); exports.createArgument = (name, description) => new commander.Argument(name, description); } (extraTypings, extraTypings.exports)); return extraTypings.exports; } var extraTypingsExports = requireExtraTypings(); var extraTypingsCommander = /*@__PURE__*/getDefaultExportFromCjs(extraTypingsExports); // wrapper to provide named exports for ESM. const { program: program$1, createCommand, createArgument, createOption, CommanderError, InvalidArgumentError, InvalidOptionArgumentError, // deprecated old name Command, Argument, Option, Help, } = extraTypingsCommander; var name = "pupcaps"; var version = "1.0.0-alpha2"; var description = "PupCaps! : A script to add stylish captions to your videos."; var author = "Alexei KLENIN <alexei.klenin@gmail.com> (https://github.com/hosuaby)"; var license = "Apache-2.0"; var main = "dist/script/index.js"; var bin = { pupcaps: "./pupcaps" }; var repository = { type: "git", url: "git+https://github.com/hosuaby/PupCaps.git" }; var bugs = { url: "https://github.com/hosuaby/PupCaps/issues" }; var homepage = "https://github.com/hosuaby/PupCaps#readme"; var keywords = [ "subtitles", "captions", "caps", "video" ]; var dependencies = { "@fortawesome/fontawesome-free": "^6.7.1", bulma: "^1.0.2", "cli-progress": "^3.12.0", commander: "^12.1.0", "file-saver": "^2.0.5", "fluent-ffmpeg": "^2.1.3", "get-port": "^7.1.0", "http-server": "^14.1.1", open: "^10.1.0", pngjs: "^7.0.0", puppeteer: "^23.9.0", tmp: "^0.2.3", vue: "^3.5.13" }; var optionalDependencies = { "@ffmpeg-installer/ffmpeg": "^1.1.0" }; var devDependencies = { "@commander-js/extra-typings": "^12.1.0", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@types/chai": "^5.0.1", "@types/cli-progress": "^3.11.6", "@types/file-saver": "^2.0.7", "@types/fluent-ffmpeg": "^2.1.27", "@types/http-server": "^0.12.4", "@types/mocha": "^10.0.10", "@types/node": "^22.9.1", "@types/pngjs": "^6.0.5", "@types/tmp": "^0.2.6", "browser-env": "^3.3.0", chai: "^5.1.2", mocha: "^10.8.2", rollup: "^4.27.3", "rollup-plugin-copy": "^3.5.0", "rollup-plugin-typescript2": "^0.36.0", "rollup-plugin-vue": "^6.0.0", tsx: "^4.19.2", typescript: "^5.7.2" }; var scripts = { build: "rollup -c", test: "mocha" }; var packageJson = { name: name, version: version, description: description, author: author, license: license, main: main, bin: bin, repository: repository, bugs: bugs, homepage: homepage, keywords: keywords, dependencies: dependencies, optionalDependencies: optionalDependencies, devDependencies: devDependencies, scripts: scripts }; const assetsFolder = path__namespace.join(__dirname, '..', '..', 'assets'); const defaultStylesCss = path__namespace.join(assetsFolder, 'captions.css'); const indexHtml = path__namespace.join(assetsFolder, 'index.html'); const indexJs = path__namespace.join(__dirname, '..', 'player', 'index.js'); const nodeModules = path__namespace.join(__dirname, '..', '..', 'node_modules'); function parseIntAndAssert(...assertions) { return (value) => { const int = parseInt(value, 10); assertions.forEach(assertion => assertion(int)); return int; }; } function assertPositive(option) { return (value) => { if (value < 0) { throw new Error(`${option} should be positive!`); } }; } function assertMinMax(option, min, max) { return (value) => { if (value < min || value > max) { throw new Error(`${option} should be between ${min} and ${max}!`); } }; } function assertFileExtension(ext) { return (value) => { if (!value.endsWith(ext)) { throw new Error(`File should have extension ${ext}!`); } return value; }; } const program = new Command(); program .name('pupcaps') .description('Tool to add stylish captions to your video.') .version(packageJson.version) .argument('<file>', 'Path to the input SubRip Subtitle (.srt) file.', assertFileExtension('.srt')) .option('-o, --output <file>', 'Full or relative path where the created Films Apple QuickTime (MOV) file should be written. ' + 'By default, it will be saved in the same directory as the input subtitle file.', assertFileExtension('.mov')) .option('-w, --width <number>', 'Width of the video in pixels.', parseIntAndAssert(assertPositive('Width')), 1080) .option('-h, --height <number>', 'Height of the video in pixels.', parseIntAndAssert(assertPositive('Height')), 1920) .option('-r, --fps <number>', 'Specifies the frame rate (FPS) of the output video. Valid values are between 1 and 60.', parseIntAndAssert(assertMinMax('FPS', 1, 60)), 30) .option('-s, --style <file>', 'Full or relative path to the styles .css file. ' + 'If not provided, default styles for captions will be used.', assertFileExtension('.css')) .option('-a, --animate', 'Records captions with CSS3 animations. ' + 'Note: The recording will run for the entire duration of the video. ' + 'Use this option only if your captions involve CSS3 animations.') .option('--preview', 'Prevents the script from generating a video file. ' + 'Instead, captions are displayed in the browser for debugging and preview purposes.') .action((inputFile, options) => { if (!options.output) { const fileBasename = inputFile.slice(0, -4); options.output = `${fileBasename}.mov`; } if (!options.style) { options.style = defaultStylesCss; } else { options.style = path__namespace.resolve(options.style); } }); function parseArgs() { program.parse(); const opts = program.opts(); return { srtInputFile: program.args[0], movOutputFile: opts.output, videoWidth: opts.width, videoHeight: opts.height, fps: opts.fps, styleFile: opts.style, css3Animations: opts.animate, isPreview: opts.preview, }; } function printArgs(args) { const styles = args.styleFile === defaultStylesCss ? '(Default)' : args.styleFile; const srt = ` Output: ${args.movOutputFile} Width: ${args.videoWidth} px Height: ${args.videoHeight} px FPS: ${args.fps} Styles: ${styles} Animations: ${args.css3Animations ? 'yes' : 'no'} `; console.log(srt); } function createProgressBar() { return new cliProgress__namespace.SingleBar({ format: 'Progress |{bar}| {percentage}% || {value}/{total} Captions', barCompleteChar: '\u2588', barIncompleteChar: '\u2591', hideCursor: true, }, cliProgress__namespace.Presets.shades_classic); } class WorkDir { captions; args; workDir = tmp__namespace.dirSync({ template: 'pupcaps-XXXXXX' }); constructor(captions, args) { this.captions = captions; this.args = args; } setup() { const index = path__namespace.join(this.workDir.name, 'index.html'); fs.symlinkSync(indexHtml, index); fs.symlinkSync(indexJs, path__namespace.join(this.workDir.name, 'index.js')); fs.symlinkSync(this.args.styleFile, path__namespace.join(this.workDir.name, 'captions.css')); fs.symlinkSync(nodeModules, path__namespace.join(this.workDir.name, 'node_modules')); this.setupCaptions(); this.setupPlayerArgs(); this.setupVideoSizeCss(); fs.mkdirSync(this.screenShotsDir); return index; } clear() { fs.rmSync(this.workDir.name, { recursive: true, force: true }); } get screenShotsDir() { return path__namespace.join(this.workDir.name, 'screenshots'); } get rootDir() { return this.workDir.name; } setupVideoSizeCss() { const css = `#video { width: ${this.args.videoWidth}px; height: ${this.args.videoHeight}px; }`; const videoSizeFile = path__namespace.join(this.workDir.name, 'video.size.css'); fs.writeFileSync(videoSizeFile, css); } setupCaptions() { const captionsJs = 'window.captions = ' + JSON.stringify(this.captions, null, 2); const captionsJsFile = path__namespace.join(this.workDir.name, 'captions.js'); fs.writeFileSync(captionsJsFile, captionsJs); } setupPlayerArgs() { const playerArgs = { isPreview: this.args.isPreview, }; const argsJs = 'window.playerArgs = ' + JSON.stringify(playerArgs, null, 2); const argsJsFile = path__namespace.join(this.workDir.name, 'player.args.js'); fs.writeFileSync(argsJsFile, argsJs); } } class StatsPrinter { statsPrinted = false; print(stats) { const lines = Object .entries(stats) .map(([key, value]) => `${key}: ${value}`); if (this.statsPrinted) { process.stdout.write(`\x1b[${lines.length}A`); // Move up N lines } lines.forEach((line) => { process.stdout.write(`\r${line.padEnd(40)}\n`); // Ensure the line is fully overwritten }); this.statsPrinted = true; } } (() => { try { const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg'); ffmpeg.setFfmpegPath(ffmpegInstaller.path); } catch (error) { console.warn('Impossible to install FFMpeg. Use system-provided ffmpeg.'); } })(); class AbstractRenderer { args; constructor(args) { this.args = args; } baseFfmpegCommand() { return ffmpeg() .outputOptions([ '-c:v prores_ks', // codec for Films Apple QuickTime (MOV) '-profile:v 4444', // enable the best quality '-pix_fmt yuva444p10le', // lossless setting '-q:v 0', // lossless setting '-vendor ap10' // ensures the output MOV file is compatible with Apple QuickTime ]) .output(this.args.movOutputFile); } } class StepRenderer extends AbstractRenderer { workDir; framesFileName; emptyFrameFileName; constructor(args, workDir) { super(args); this.workDir = workDir; this.framesFileName = path__namespace.join(workDir.screenShotsDir, 'frames.txt'); this.emptyFrameFileName = path__namespace.join(workDir.screenShotsDir, 'empty.png'); } startEncoding() { const empty = new pngjs.PNG({ width: this.args.videoWidth, height: this.args.videoHeight, colorType: 6, }); fs.writeFileSync(this.emptyFrameFileName, pngjs.PNG.sync.write(empty)); } addEmptyFrame(durationMs) { let frameDef = `file '${this.emptyFrameFileName}'\n`; if (durationMs) { const durationSec = durationMs / 1000; frameDef += `duration ${durationSec}\n`; } fs.appendFileSync(this.framesFileName, frameDef, 'utf8'); } addFrame(caption, png) { const screenShotFileName = path__namespace.join(this.workDir.screenShotsDir, `screenshot_${caption.index}.png`); fs.writeFileSync(screenShotFileName, pngjs.PNG.sync.write(png)); const durationSec = (caption.endTimeMs - caption.startTimeMs) / 1000; fs.appendFileSync(this.framesFileName, `file '${screenShotFileName}'\nduration ${durationSec}\n`, 'utf8'); } async endEncoding() { console.log(`Encoding ${this.args.movOutputFile}...\n`); const statsPrinter = new StatsPrinter(); await new Promise((resolve, reject) => { this.baseFfmpegCommand() .input(this.framesFileName) .inputOptions([ '-f concat', // concat frames from the frame list '-safe 0' // to prevent errors related to unsafe filenames ]) .outputOptions([ `-vf fps=fps=${this.args.fps}`, // Framerate ]) .on('progress', (progress) => { statsPrinter.print(progress); }) .on('end', () => { console.log(`${this.args.movOutputFile} encoded`); resolve(this.args.movOutputFile); }) .on('error', (err) => { reject(err); }) .run(); }); } } class AbstractRecorder { args; browser = null; page = null; constructor(args) { this.args = args; } async launchBrowser(indexHtml) { this.browser = await puppeteer__namespace.launch({ args: [ '--disable-web-security', // Disable CORS '--allow-file-access-from-files', // Allow file access ], headless: true, }); this.page = await this.browser.newPage(); await this.page.goto(`file://${indexHtml}`); await this.page.setViewport({ width: this.args.videoWidth, height: this.args.videoHeight, }); await this.page.evaluate(() => { return window.ready; }); return this.page.$('#video'); } } class RealTimeRecorder extends AbstractRecorder { videoRenderer; constructor(args, videoRenderer) { super(args); this.videoRenderer = videoRenderer; } async recordCaptionsVideo(indexHtml) { try { await this.launchBrowser(indexHtml); const cdpSession = await this.page.createCDPSession(); await cdpSession.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }); await cdpSession.send('Animation.setPlaybackRate', { playbackRate: 1, }); cdpSession.on('Page.screencastFrame', (frame) => this.handleScreenCastFrame(cdpSession, frame)); this.videoRenderer.startEncoding(); await cdpSession.send('Page.startScreencast', { everyNthFrame: 1, format: 'png', quality: 100, }); await this.page.evaluate(() => { return new Promise((resolve) => { window.Player.onStop = resolve; window.Player.play(); }); }); await cdpSession.send('Page.stopScreencast'); this.videoRenderer.endEncoding(); } catch (error) { console.error('Error during Puppeteer operation:', error); } finally { await this.browser?.close(); } } async handleScreenCastFrame(cdpSession, frame) { const { sessionId, data } = frame; await cdpSession.send('Page.screencastFrameAck', { sessionId }); const frameBuffer = Buffer.from(data, 'base64'); this.videoRenderer.addFrame(frameBuffer); } } class FPSTicker { interval; lastTime = 0; onTick = () => { }; timeoutId = null; constructor(fps) { this.interval = 1000 / fps; } start(onTick = () => { }) { this.onTick = onTick; this.lastTime = Date.now(); this.tick(); } stop() { clearTimeout(this.timeoutId); } tick() { const now = Date.now(); const deltaTime = now - this.lastTime; if (deltaTime >= this.interval) { this.lastTime = now - (deltaTime % this.interval); // Adjust for drift this.onTick(deltaTime); } this.timeoutId = setTimeout(() => this.tick(), this.interval - (Date.now() - this.lastTime)); } } class RealTimeRenderer extends AbstractRenderer { inputStream = null; lastFrame; ticker; constructor(args) { super(args); const empty = new pngjs.PNG({ width: this.args.videoWidth, height: this.args.videoHeight, colorType: 6, }); this.lastFrame = pngjs.PNG.sync.write(empty); this.ticker = new FPSTicker(args.fps); } startEncoding() { this.inputStream = new stream.PassThrough(); const statsPrinter = new StatsPrinter(); const command = this.baseFfmpegCommand() .input(this.inputStream) .inputOptions([ '-f image2pipe', // Format of input frames '-pix_fmt yuva444p10le', // Lossless setting `-s ${this.args.videoWidth}x${this.args.videoHeight}`, // Frame size `-r ${this.args.fps}`, // Framerate ]) .outputOptions([ `-vf fps=fps=${this.args.fps}`, // Framerate ]) .on('start', () => { console.log('FFmpeg process started.'); }) .on('progress', (progress) => { statsPrinter.print(progress); }) .on('end', () => { console.log('FFmpeg process completed.'); }) .on('error', (err) => { console.error('An error occurred:', err.message); }); command.run(); // Produce frames in required rate this.ticker.start(() => { this.inputStream.write(this.lastFrame); }); } addFrame(frame) { this.lastFrame = frame; } endEncoding() { this.ticker.stop(); this.inputStream.end(); } } class StepRecorder extends AbstractRecorder { captions; renderer; progressBar; constructor(args, captions, renderer, progressBar) { super(args); this.captions = captions; this.renderer = renderer; this.progressBar = progressBar; } async recordCaptionsVideo(indexHtml) { this.progressBar.start(this.captions.length, 0); try { const videoElem = await this.launchBrowser(indexHtml); this.renderer.startEncoding(); // Add empty frame before captions starts const beginningTime = this.captions[0].startTimeMs; this.renderer.addEmptyFrame(beginningTime); for (let i = 0; i < this.captions.length; i++) { const caption = this.captions[i]; await this.nextStep(); const screenShot = await this.takeScreenShot(videoElem); this.renderer.addFrame(caption, screenShot); // Add delay before the next frame if (i < this.captions.length - 1) { const idleDelay = this.captions[i + 1].startTimeMs - caption.endTimeMs; if (idleDelay) { this.renderer.addEmptyFrame(idleDelay); } } this.progressBar.increment(); } this.progressBar.stop(); await this.renderer.endEncoding(); } catch (error) { console.error('Error during Puppeteer operation:', error); } finally { await this.browser?.close(); } } async nextStep() { await this.page.evaluate(() => { window.Player.next(); }); } async takeScreenShot(elem) { const screenshotBuffer = await elem.screenshot({ encoding: 'binary', omitBackground: true, }); return pngjs.PNG.sync.read(Buffer.from(screenshotBuffer)); } } function toMillis(timecodes) { const parts = timecodes.split(/[:,]/).map(Number); const hours = parts[0]; const minutes = parts[1]; const seconds = parts[2]; const milliseconds = parts[3]; return hours * 3_600_000 // hours to millis + minutes * 60_000 // minutes to millis + seconds * 1000 // second to millis + milliseconds; } const indexLinePattern = /^\d+$/; const timecodesLinePattern = /^(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})$/; const highlightedWordPattern = /^\[(.+)](?:\((\w+)\))?$/; function readCaptions(srtContent) { const lines = srtContent.split('\n'); const captions = []; let index = 0; let timecodesStart = null; let timecodesEnd = null; for (const line of lines) { let match; if ((match = line.match(indexLinePattern))) { index = Number(line); } else if ((match = line.match(timecodesLinePattern))) { timecodesStart = match[1]; timecodesEnd = match[2]; } else if (line.length) { const start = toMillis(timecodesStart); const end = toMillis(timecodesEnd); const words = readWords(line); captions.push({ index, words, startTimeMs: start, endTimeMs: end, }); } } return captions; } function readWords(text) { const words = splitText(text); const highlightedIndex = words.findIndex(word => word.match(highlightedWordPattern)); const res = []; for (let i = 0; i < words.length; i++) { const word = words[i]; const match = word.match(highlightedWordPattern); const rawWord = match ? match[1] : word; const highlightClass = match && match[2] ? match[2] : null; const isHighlighted = Boolean(match); const isBeforeHighlighted = Boolean(~highlightedIndex && !isHighlighted && i < highlightedIndex); const isAfterHighlighted = Boolean(~highlightedIndex && !isHighlighted && i > highlightedIndex); const wordObject = { rawWord, isHighlighted, isBeforeHighlighted, isAfterHighlighted, }; if (highlightClass) { wordObject.highlightClass = highlightClass; } res.push(wordObject); } return res; } function splitText(text) { const words = []; let currentWord = ''; let isCurrentHighlighted = false; for (let i = 0; i < text.length; i++) { const char = text[i]; const isWhitespace = /^\s$/.test(char); const isPunctuation = /[,.!?]/.test(char); if (!isWhitespace) { if (!isPunctuation) { currentWord += char; switch (char) { case '[': case '(': isCurrentHighlighted = true; break; case ']': case ')': isCurrentHighlighted = false; break; } } else { if (currentWord) { currentWord += char; } else { // Attach punctuation mark to the previous word words[words.length - 1] += ' ' + char; } } } else { // char is a whitespace if (isCurrentHighlighted) { currentWord += char; } else if (currentWord) { words.push(currentWord); currentWord = ''; } } } if (currentWord) { words.push(currentWord); } return words; } class WebServer { rootDir; constructor(rootDir) { this.rootDir = rootDir; } async start(relativePath = '') { return new Promise(async (resolve, reject) => { try { const server = httpServer.createServer({ root: this.rootDir }); const port = await WebServer.getFreePort(); server.listen(port, async () => { try { const childProcess = await WebServer.openUrl(`http://127.0.0.1:${port}${relativePath}`); childProcess.on('close', () => { server.close(() => { resolve(); }); }); } catch (error) { reject(error); } }); } catch (error) { reject(error); } }); } static async getFreePort() { const { default: getPort } = await import('get-port'); return getPort(); } static async openUrl(url) { const { default: open } = await import('open'); return open(url, { wait: true }); } } function parseCaptions(srtCaptionsFile) { const captionsSrc = fs.readFileSync(srtCaptionsFile, 'utf-8'); return readCaptions(captionsSrc); } function createRecorder(args, captions, workDir) { if (args.css3Animations) { const realTimeRenderer = new RealTimeRenderer(args); return new RealTimeRecorder(args, realTimeRenderer); } else { const progressBar = createProgressBar(); const stepRenderer = new StepRenderer(args, workDir); return new StepRecorder(args, captions, stepRenderer, progressBar); } } const cliArgs = parseArgs(); const captions = parseCaptions(cliArgs.srtInputFile); const workDir = new WorkDir(captions, cliArgs); (async () => { try { const indexHtml = workDir.setup(); printArgs(cliArgs); if (!cliArgs.isPreview) { const recorder = createRecorder(cliArgs, captions, workDir); await recorder.recordCaptionsVideo(indexHtml); } else { console.log('Launching preview server...'); const previewServer = new WebServer(workDir.rootDir); await previewServer.start(); } console.log('Done!'); } catch (err) { console.error('Error occurred:', err); } finally { workDir.clear(); } })(); //# sourceMappingURL=index.js.map