UNPKG

@henteko/kumiki

Version:

A video generation tool that creates videos from JSON configurations

320 lines 12 kB
import { existsSync } from 'node:fs'; import { copyFile, readFile } from 'node:fs/promises'; import path from 'node:path'; import { BaseScene } from '../scenes/base.js'; import { FFmpegService } from '../services/ffmpeg.js'; import { geminiImageService } from '../services/gemini.js'; import { imageCache, generateCacheKey } from '../services/image-cache.js'; import { PuppeteerService } from '../services/puppeteer.js'; import { RenderError } from '../utils/errors.js'; import { isGenerateUrl, parseGenerateUrl } from '../utils/generate-url-parser.js'; import { logger } from '../utils/logger.js'; export class ImageSceneRenderer extends BaseScene { /** * Generate image element HTML (for reuse in CompositeSceneRenderer) */ static async generateImageElement(src, fit, position, _width, _height) { let imagePath; let imageBuffer; // Check if this is a generate URL if (isGenerateUrl(src)) { // This should have been resolved before calling this method throw new Error('generate:// URLs should be resolved before rendering'); } else { // Get absolute image path imagePath = path.resolve(process.cwd(), src); // Read image as base64 imageBuffer = await readFile(imagePath); } const imageBase64 = imageBuffer.toString('base64'); // Detect image format from file extension const ext = path.extname(imagePath).toLowerCase(); const mimeType = ImageSceneRenderer.getMimeTypeStatic(ext); const imageUrl = `data:${mimeType};base64,${imageBase64}`; // Build position styles const positionStyles = []; positionStyles.push('position: absolute'); if (position.x === 'center') { positionStyles.push('left: 50%', 'transform: translateX(-50%)'); } else { positionStyles.push(`left: ${position.x}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: ${position.y}px`); } // Get fit styles const fitStyles = ImageSceneRenderer.getImageFitStylesStatic(fit); const imageStyleParts = [ ...positionStyles, ...fitStyles, 'max-width: 100%', 'max-height: 100%' ]; const imageStyles = imageStyleParts.join('; '); return `<img src="${imageUrl}" style="${imageStyles}" alt="" />`; } /** * Get MIME type from file extension (static version for reuse) */ static getMimeTypeStatic(ext) { switch (ext.toLowerCase()) { case '.jpg': case '.jpeg': return 'image/jpeg'; case '.png': return 'image/png'; case '.gif': return 'image/gif'; case '.webp': return 'image/webp'; default: return 'image/png'; } } /** * Get image fit styles (static version for reuse) */ static getImageFitStylesStatic(fit) { switch (fit) { case 'cover': return ['width: 100%', 'height: 100%', 'object-fit: cover']; case 'contain': return ['object-fit: contain']; case 'fill': return ['width: 100%', 'height: 100%', 'object-fit: fill']; default: return []; } } /** * Validate image scene configuration */ validate() { if (!this.scene.content.src) { throw new RenderError('Image source is required', 'MISSING_IMAGE_SOURCE', { sceneId: this.scene.id }); } // Skip file existence check for generate URLs if (isGenerateUrl(this.scene.content.src)) { return true; } // Check if image file exists const src = this.scene.content.src; const imagePath = path.resolve(process.cwd(), src); if (!existsSync(imagePath)) { throw new RenderError(`Image file not found: ${src}`, 'IMAGE_NOT_FOUND', { sceneId: this.scene.id, src: src }); } return true; } /** * Render image scene to static image */ async renderStatic() { this.validate(); await this.ensureOutputDirectory(); logger.info('Rendering image scene to static image', { sceneId: this.scene.id, src: this.scene.content.src, }); // Handle generate URLs let actualSrc; if (isGenerateUrl(this.scene.content.src)) { actualSrc = await this.resolveGenerateUrl(this.scene.content.src); } else { actualSrc = this.scene.content.src; } const { width, height } = this.parseResolution(); const outputPath = this.getStaticOutputPath(); // If no background and fit is 'fill', we can just copy the image if (!this.scene.background && this.scene.content.fit === 'fill') { const imagePath = path.resolve(process.cwd(), actualSrc); await copyFile(imagePath, outputPath); logger.info('Image scene rendered (direct copy)', { sceneId: this.scene.id, outputPath, }); return outputPath; } // Otherwise, use Puppeteer to render with proper positioning and background const html = await this.generateHTML(width, height, actualSrc); const puppeteer = PuppeteerService.getInstance(); await puppeteer.screenshot(html, { width, height, outputPath, }); logger.info('Image scene rendered to static image', { sceneId: this.scene.id, outputPath, }); return outputPath; } /** * Render image scene to video */ async renderVideo() { this.validate(); logger.info('Rendering image 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('Image scene rendered to video', { sceneId: this.scene.id, outputPath: videoPath, hasNarration: !!this.narrationPath, }); return videoPath; } /** * Generate HTML for image scene */ async generateHTML(width, height, actualSrc) { const { src, fit, position } = this.scene.content; const background = this.scene.background; // Use actual source if provided (for resolved generate URLs) const imageSrc = actualSrc || (typeof src === 'string' ? src : ''); // Generate background styles const backgroundStyles = this.getBackgroundStyles(background); // Generate styles const styles = ` ${backgroundStyles} `; // Use static method to generate image element const imageElement = await ImageSceneRenderer.generateImageElement(imageSrc, fit, position, width, height); // Generate HTML content const content = ` <div class="scene-background"></div> ${imageElement} `; const puppeteer = PuppeteerService.getInstance(); const html = puppeteer.generateHTML(content, styles); return html; } /** * 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}; } `; } /** * Resolve generate:// URL to actual image path */ async resolveGenerateUrl(src) { // Initialize cache if needed await imageCache.initialize(); // Parse generate URL const params = parseGenerateUrl(src); // Generate cache key const cacheKey = generateCacheKey(params); // Check cache first const cachedPath = await imageCache.get(cacheKey); if (cachedPath) { logger.info('Using cached generated image', { sceneId: this.scene.id, prompt: params.prompt, cachedPath, }); logger.info('To replace: Change src to your actual image path in the JSON file'); return cachedPath; } // Generate new image logger.info('Generating image for scene', { sceneId: this.scene.id, prompt: params.prompt, style: params.style, aspectRatio: params.aspectRatio, }); try { const imageData = await geminiImageService.generateImage(params); // Save to cache const imagePath = await imageCache.save(cacheKey, imageData, params); logger.info('Generated image saved', { sceneId: this.scene.id, prompt: params.prompt, path: imagePath, }); logger.info('To replace: Change src to your actual image path in the JSON file'); return imagePath; } catch (error) { logger.error('Failed to generate image', { sceneId: this.scene.id, error: error instanceof Error ? error.message : String(error), errorDetails: error, }); throw new RenderError(error instanceof Error ? error.message : 'Failed to generate image', 'IMAGE_GENERATION_FAILED', { sceneId: this.scene.id, prompt: params.prompt, error: error instanceof Error ? error.message : String(error), }); } } } //# sourceMappingURL=image.js.map