UNPKG

@henteko/kumiki

Version:

A video generation tool that creates videos from JSON configurations

211 lines 7.42 kB
import { BaseScene } from '../scenes/base.js'; import { FFmpegService } from '../services/ffmpeg.js'; import { PuppeteerService } from '../services/puppeteer.js'; import { RenderError } from '../utils/errors.js'; import { logger } from '../utils/logger.js'; export class TextSceneRenderer extends BaseScene { /** * Validate text scene configuration */ validate() { if (!this.scene.content.text) { throw new RenderError('Text content is required', 'MISSING_TEXT_CONTENT', { sceneId: this.scene.id }); } if (!this.scene.content.style) { throw new RenderError('Text style is required', 'MISSING_TEXT_STYLE', { sceneId: this.scene.id }); } return true; } /** * Render text scene to static image */ async renderStatic() { this.validate(); await this.ensureOutputDirectory(); logger.info('Rendering text scene to static image', { sceneId: this.scene.id, text: this.scene.content.text.substring(0, 50), }); const { width, height } = this.parseResolution(); const outputPath = this.getStaticOutputPath(); // Generate HTML content const html = this.generateHTML(width, height); // Take screenshot const puppeteer = PuppeteerService.getInstance(); await puppeteer.screenshot(html, { width, height, outputPath, }); logger.info('Text scene rendered to static image', { sceneId: this.scene.id, outputPath, }); return outputPath; } /** * Render text scene to video */ async renderVideo() { this.validate(); logger.info('Rendering text scene to video', { sceneId: this.scene.id, duration: this.scene.duration, hasNarration: !!this.narrationPath, }); // First render static image const imagePath = await this.renderStatic(); // Convert image to video using FFmpeg let videoPath = this.getVideoOutputPath(); const ffmpeg = FFmpegService.getInstance(); await ffmpeg.imageToVideo({ input: imagePath, output: videoPath, duration: this.scene.duration, fps: this.options.fps, resolution: this.options.resolution, }); // Add narration if available if (this.narrationPath && this.scene.narration) { const narrationVideoPath = videoPath.replace('.mp4', '_narrated.mp4'); await ffmpeg.addNarrationTrack(videoPath, this.narrationPath, narrationVideoPath, { narrationVolume: this.scene.narration.voice?.volumeGainDb ? Math.pow(10, this.scene.narration.voice.volumeGainDb / 20) : 0.8, delay: this.scene.narration.timing?.delay || 0, fadeIn: this.scene.narration.timing?.fadeIn || 0, fadeOut: this.scene.narration.timing?.fadeOut || 0, }); videoPath = narrationVideoPath; } logger.info('Text scene rendered to video', { sceneId: this.scene.id, outputPath: videoPath, }); return videoPath; } /** * Generate text element HTML (for reuse in CompositeSceneRenderer) */ static generateTextElement(text, style, position, width, height) { // Calculate text position const textX = TextSceneRenderer.calculatePositionStatic(position.x, width, 0); const textY = TextSceneRenderer.calculatePositionStatic(position.y, height, 0); const positionStyles = []; if (position.x === 'center') { positionStyles.push('left: 50%', 'transform: translateX(-50%)'); } else { positionStyles.push(`left: ${textX}px`); } if (position.y === 'center') { positionStyles.push('top: 50%'); if (position.x === 'center') { // Replace the transform to handle both X and Y positionStyles[positionStyles.indexOf('transform: translateX(-50%)')] = 'transform: translate(-50%, -50%)'; } else { positionStyles.push('transform: translateY(-50%)'); } } else { positionStyles.push(`top: ${textY}px`); } const textStyleParts = [ 'position: absolute', ...positionStyles, `font-family: '${style.fontFamily}', sans-serif`, `font-size: ${style.fontSize}px`, `color: ${style.color}`, `font-weight: ${style.fontWeight || 'normal'}`, `text-align: ${style.textAlign || 'left'}`, 'line-height: 1.5', 'white-space: pre-wrap', 'word-wrap: break-word', 'max-width: 90%' ]; const textStyles = textStyleParts.join('; '); return `<div style="${textStyles}">${TextSceneRenderer.escapeHtmlStatic(text)}</div>`; } /** * Calculate position value (static version for reuse) */ static calculatePositionStatic(value, dimension, padding) { if (value === 'center') { return dimension / 2; } return value + padding; } /** * Escape HTML special characters (static version for reuse) */ static escapeHtmlStatic(text) { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', }; return text .replace(/[&<>"']/g, (m) => map[m] || m) .replace(/\n/g, '<br>'); } /** * Generate HTML for text scene */ generateHTML(width, height) { const { text, style, position } = this.scene.content; const background = this.scene.background; // Generate background styles const backgroundStyles = this.getBackgroundStyles(background); // Generate styles const styles = ` ${backgroundStyles} `; // Generate HTML content using the static method const textElement = TextSceneRenderer.generateTextElement(text, style, position, width, height); const content = ` <div class="scene-background"></div> ${textElement} `; const puppeteer = PuppeteerService.getInstance(); return puppeteer.generateHTML(content, styles); } /** * Get background styles */ getBackgroundStyles(background) { if (!background) { return ` .scene-background { position: absolute; width: 100%; height: 100%; background: #000000; } `; } let backgroundValue = ''; switch (background.type) { case 'color': backgroundValue = background.value; break; case 'gradient': backgroundValue = background.value; break; case 'image': backgroundValue = `url("${background.value}") center/cover`; break; } return ` .scene-background { position: absolute; width: 100%; height: 100%; background: ${backgroundValue}; } `; } } //# sourceMappingURL=text.js.map