@henteko/kumiki
Version:
A video generation tool that creates videos from JSON configurations
281 lines • 9.99 kB
JavaScript
import { existsSync } from 'node:fs';
import path from 'node:path';
import { BaseScene } from './base.js';
import { ImageSceneRenderer } from './image.js';
import { TextSceneRenderer } from './text.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 CompositeSceneRenderer extends BaseScene {
/**
* Render static image with multiple layers
*/
async renderStatic() {
const outputPath = this.getStaticOutputPath();
const { width, height } = this.parseResolution();
logger.info('Rendering composite scene to static image', {
sceneId: this.scene.id,
layers: this.scene.layers.length,
});
// Start with the background
const backgroundHtml = this.getBackgroundHtml();
// Sort layers by z-index (default to 0 if not specified)
const sortedLayers = [...this.scene.layers].sort((a, b) => {
const aZIndex = this.getZIndex(a);
const bZIndex = this.getZIndex(b);
return aZIndex - bZIndex;
});
// Build layers HTML
const layersHtml = await Promise.all(sortedLayers.map((layer) => this.renderLayer(layer)));
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 0;
width: ${width}px;
height: ${height}px;
overflow: hidden;
position: relative;
${backgroundHtml}
}
.layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
${layersHtml.join('\n')}
</body>
</html>
`;
const puppeteer = PuppeteerService.getInstance();
await puppeteer.screenshot(html, {
width,
height,
outputPath,
});
logger.info('Composite scene rendered to static image', {
sceneId: this.scene.id,
outputPath,
});
return outputPath;
}
/**
* Get z-index for a layer
*/
getZIndex(layer) {
if (layer.type === 'text') {
return layer.zIndex ?? 0;
}
if (layer.type === 'image') {
return layer.zIndex ?? 0;
}
return 0;
}
/**
* Render a single layer
*/
async renderLayer(layer) {
if (layer.type === 'text') {
return this.renderTextLayer(layer);
}
if (layer.type === 'image') {
return this.renderImageLayer(layer);
}
// This should never happen due to TypeScript's exhaustive check
throw new Error(`Unknown layer type`);
}
/**
* Render text layer
*/
renderTextLayer(layer) {
const { content, zIndex = 0, opacity = 1 } = layer;
const { text, style, position } = content;
const { width, height } = this.parseResolution();
// Use TextSceneRenderer's static method to generate text element
const textElement = TextSceneRenderer.generateTextElement(text, style, position, width, height);
// Wrap with layer div and apply opacity
return `
<div class="layer" style="z-index: ${zIndex}; opacity: ${opacity};">
${textElement}
</div>
`;
}
/**
* Render image layer
*/
async renderImageLayer(layer) {
const { content, zIndex = 0, opacity = 1 } = layer;
const { src, fit, position } = content;
const { width, height } = this.parseResolution();
// Handle generate URLs
let actualSrc;
if (isGenerateUrl(src)) {
// Resolve generate URL to actual image path
actualSrc = await this.resolveGenerateUrl(src);
}
else {
actualSrc = src;
}
// Use ImageSceneRenderer's static method to generate image element
const imageElement = await ImageSceneRenderer.generateImageElement(actualSrc, fit, position, width, height);
// Wrap with layer div and apply opacity
return `
<div class="layer" style="z-index: ${zIndex}; opacity: ${opacity};">
${imageElement}
</div>
`;
}
/**
* Get background HTML styles
*/
getBackgroundHtml() {
const background = this.scene.background;
if (!background) {
return 'background: #000000;';
}
switch (background.type) {
case 'color':
return `background: ${background.value};`;
case 'gradient':
return `background: ${background.value};`;
case 'image':
return `background: url("${background.value}") center/cover;`;
default:
return 'background: #000000;';
}
}
/**
* Render to video
*/
async renderVideo() {
logger.info('Rendering composite scene to video', {
sceneId: this.scene.id,
duration: this.scene.duration,
hasNarration: !!this.narrationPath,
});
// First render to static image
const imagePath = await this.renderStatic();
let outputPath = this.getVideoOutputPath();
// Convert static image to video with specified duration
const ffmpeg = FFmpegService.getInstance();
await ffmpeg.imageToVideo({
input: imagePath,
output: outputPath,
duration: this.scene.duration,
fps: this.options.fps,
resolution: this.options.resolution
});
// Add narration if available
if (this.narrationPath && this.scene.narration) {
const narrationVideoPath = outputPath.replace('.mp4', '_narrated.mp4');
await ffmpeg.addNarrationTrack(outputPath, 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,
});
outputPath = narrationVideoPath;
}
logger.info('Composite scene rendered to video', {
sceneId: this.scene.id,
outputPath,
hasNarration: !!this.narrationPath,
});
return outputPath;
}
/**
* Validate the scene
*/
validate() {
if (!this.scene.layers || this.scene.layers.length === 0) {
logger.error('Composite scene must have at least one layer', {
sceneId: this.scene.id,
});
return false;
}
for (const layer of this.scene.layers) {
if (layer.type === 'image') {
const src = layer.content.src;
// Skip validation for generate URLs
if (isGenerateUrl(src)) {
continue;
}
const imagePath = path.resolve(process.cwd(), src);
if (!existsSync(imagePath)) {
logger.error('Image file not found', {
sceneId: this.scene.id,
path: imagePath,
});
return false;
}
}
}
return true;
}
/**
* 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 in composite layer', {
sceneId: this.scene.id,
prompt: params.prompt,
cachedPath,
});
return cachedPath;
}
// Generate new image
logger.info('Generating image for composite layer', {
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 for composite layer', {
sceneId: this.scene.id,
prompt: params.prompt,
path: imagePath,
});
return imagePath;
}
catch (error) {
logger.error('Failed to generate image for composite layer', {
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=composite.js.map