init state

This commit is contained in:
dimitar committed 2026-05-18 19:58:01 +02:00
commit ae1cae967b
46 files changed
+12734

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
/**
* Bubble Engine Service
*
* Handles speech bubble auto-placement and positioning logic
* Implements comic book layout rules for bubble placement
*/
import type { SpeechBubble, Panel, Position, Size } from '@/types';
/**
* Default bubble style
*/
export const DEFAULT_BUBBLE_STYLE: SpeechBubble['style'] = {
backgroundColor: '#ffffff',
borderColor: '#000000',
borderWidth: 2,
borderRadius: 16,
fontFamily: 'Comic Sans MS, cursive, sans-serif',
fontSize: 14,
textColor: '#000000',
padding: 12,
};
/**
* Calculate bubble size based on text content
*/
export function calculateBubbleSize(text: string, style: SpeechBubble['style']): Size {
// Estimate character dimensions (rough approximation)
const charWidth = style.fontSize * 0.6;
const charHeight = style.fontSize * 1.4;
// Target line length (characters per line)
const charsPerLine = 25;
const lines = Math.ceil(text.length / charsPerLine);
// Calculate dimensions
const width = Math.min(
Math.max(text.length * charWidth / Math.min(lines, 3) + style.padding * 2, 100),
300
);
const height = lines * charHeight + style.padding * 2 + 20; // +20 for tail space
return { width, height };
}
/**
* Auto-place bubble based on type and panel composition
* Implements comic book placement rules
*/
export function autoPlaceBubble(
bubble: SpeechBubble,
panelWidth: number,
panelHeight: number,
speakerPosition?: Position
): SpeechBubble {
const updatedBubble = { ...bubble };
const size = calculateBubbleSize(bubble.text, bubble.style);
updatedBubble.size = size;
// Default speaker position (center, lower half if not specified)
const speaker: Position = speakerPosition || {
x: panelWidth * 0.5,
y: panelHeight * 0.7,
};
// Calculate position based on bubble type
switch (bubble.type) {
case 'thought':
// Thought bubbles go above character's head
updatedBubble.position = {
x: Math.max(20, Math.min(speaker.x - size.width / 2, panelWidth - size.width - 20)),
y: Math.max(20, speaker.y - size.height - 40),
};
updatedBubble.tailTarget = { x: speaker.x, y: speaker.y - 20 };
updatedBubble.tailDirection = 'bottom';
break;
case 'shout':
// Shout bubbles can be larger and more prominent
updatedBubble.size = { width: size.width * 1.1, height: size.height * 1.1 };
updatedBubble.position = {
x: Math.max(10, Math.min(speaker.x - updatedBubble.size.width / 2, panelWidth - updatedBubble.size.width - 10)),
y: Math.max(10, speaker.y - updatedBubble.size.height - 30),
};
updatedBubble.tailTarget = { x: speaker.x, y: speaker.y - 10 };
updatedBubble.tailDirection = 'bottom';
break;
case 'whisper':
// Whisper bubbles typically dashed border, positioned near character
updatedBubble.position = {
x: Math.max(20, Math.min(speaker.x - size.width / 2, panelWidth - size.width - 20)),
y: Math.max(20, speaker.y - size.height - 30),
};
updatedBubble.tailTarget = { x: speaker.x, y: speaker.y - 20 };
updatedBubble.tailDirection = 'bottom';
break;
case 'narration':
// Narration boxes go at top of panel
updatedBubble.position = {
x: panelWidth * 0.1,
y: 20,
};
updatedBubble.size = { width: panelWidth * 0.8, height: size.height };
updatedBubble.tailDirection = 'bottom'; // No tail for narration
break;
case 'sound-effect':
// Sound effects usually placed near action source
updatedBubble.position = {
x: Math.max(10, Math.min(speaker.x + 20, panelWidth - size.width - 10)),
y: Math.max(10, speaker.y - size.height - 50),
};
updatedBubble.tailTarget = { x: speaker.x + 30, y: speaker.y - 30 };
updatedBubble.tailDirection = 'bottom-left';
break;
default: // 'normal'
// Normal speech bubble - avoid covering character face
updatedBubble.position = {
x: Math.max(20, Math.min(speaker.x - size.width / 2, panelWidth - size.width - 20)),
y: Math.max(20, speaker.y - size.height - 30),
};
updatedBubble.tailTarget = { x: speaker.x, y: speaker.y - 20 };
updatedBubble.tailDirection = 'bottom';
break;
}
return updatedBubble;
}
/**
* Auto-place all bubbles for a panel based on dialogue
*/
export function autoPlacePanelBubbles(
panel: Panel,
panelWidth: number,
panelHeight: number
): SpeechBubble[] {
const bubbles: SpeechBubble[] = [];
// Track occupied areas to prevent overlapping
const occupiedAreas: Array<{ x: number; y: number; width: number; height: number }> = [];
panel.dialogue.forEach((dialogue, index) => {
// Estimate speaker position (distribute characters across panel)
const speakerCount = panel.charactersPresent.length;
const speakerIndex = panel.charactersPresent.indexOf(dialogue.speakerId);
const speakerX = speakerCount > 1
? (panelWidth / (speakerCount + 1)) * (speakerIndex + 1)
: panelWidth * 0.5;
const speakerY = panelHeight * 0.75; // Lower half of panel
// Create bubble
const bubble: SpeechBubble = {
id: `bubble_${panel.panelId}_${index}`,
panelId: panel.panelId,
type: dialogue.bubbleType,
text: dialogue.text,
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
tailDirection: 'bottom',
tailTarget: { x: speakerX, y: speakerY },
style: { ...DEFAULT_BUBBLE_STYLE },
speakerId: dialogue.speakerId,
};
// Auto-place with offset for multiple bubbles
let placedBubble = autoPlaceBubble(bubble, panelWidth, panelHeight, { x: speakerX, y: speakerY });
// Adjust position to avoid overlapping previous bubbles
const verticalOffset = index * 10; // Slight stagger
placedBubble.position.y = Math.max(20, placedBubble.position.y - verticalOffset);
// Ensure bubble stays within panel bounds
placedBubble.position.x = Math.max(
10,
Math.min(placedBubble.position.x, panelWidth - placedBubble.size.width - 10)
);
placedBubble.position.y = Math.max(
10,
Math.min(placedBubble.position.y, panelHeight - placedBubble.size.height - 10)
);
occupiedAreas.push({
x: placedBubble.position.x,
y: placedBubble.position.y,
width: placedBubble.size.width,
height: placedBubble.size.height,
});
bubbles.push(placedBubble);
});
// Add caption if present
if (panel.caption) {
const captionBubble: SpeechBubble = {
id: `caption_${panel.panelId}`,
panelId: panel.panelId,
type: 'narration',
text: panel.caption,
position: { x: panelWidth * 0.1, y: 20 },
size: { width: panelWidth * 0.8, height: 40 },
tailDirection: 'bottom',
tailTarget: { x: panelWidth * 0.5, y: 60 },
style: {
...DEFAULT_BUBBLE_STYLE,
backgroundColor: '#f5f5f5',
borderColor: '#999999',
},
speakerId: '',
};
bubbles.push(captionBubble);
}
return bubbles;
}
/**
* Create bubble SVG path based on type and tail direction
*/
export function createBubblePath(
width: number,
height: number,
type: SpeechBubble['type'],
tailDirection: SpeechBubble['tailDirection'],
tailTarget: Position
): string {
const r = 16; // border radius
// Base rounded rectangle path
let path = `M ${r} 0 `;
path += `L ${width - r} 0 `;
path += `Q ${width} 0 ${width} ${r} `;
path += `L ${width} ${height - r} `;
path += `Q ${width} ${height} ${width - r} ${height} `;
// Add tail based on direction
if (type !== 'narration') {
const tailSize = 15;
const tailWidth = 12;
switch (tailDirection) {
case 'bottom':
const tailX = Math.max(tailWidth, Math.min(tailTarget.x, width - tailWidth));
path += `L ${tailX + tailWidth} ${height} `;
path += `L ${tailTarget.x} ${height + tailSize} `;
path += `L ${tailX - tailWidth} ${height} `;
break;
case 'top':
const topX = Math.max(tailWidth, Math.min(tailTarget.x, width - tailWidth));
path += `L ${topX + tailWidth} ${height} `;
path += `L ${width - r} ${height} `;
path = `M ${r} 0 L ${topX - tailWidth} 0 L ${tailTarget.x} ${-tailSize} L ${topX + tailWidth} 0 L ${width - r} 0 ` + path.substring(path.indexOf('Q'));
break;
// Add more directions as needed
}
}
path += `L ${r} ${height} `;
path += `Q 0 ${height} 0 ${height - r} `;
path += `L 0 ${r} `;
path += `Q 0 0 ${r} 0 Z`;
// Modify for thought bubbles (cloud shape)
if (type === 'thought') {
// Add small circles for thought bubble effect
return path; // Simplified - full cloud shape would be more complex
}
// Modify for shout bubbles (spiky edges)
if (type === 'shout') {
// Add spikes around the edge
return path; // Simplified - full spiky shape would be more complex
}
return path;
}
/**
* Generate unique bubble ID
*/
export function generateBubbleId(panelId: string, index: number): string {
return `bubble_${panelId}_${Date.now()}_${index}`;
}
+295
View File
@@ -0,0 +1,295 @@
/**
* DeepSeek Service - Script Generation
*
* Uses OpenAI-compatible SDK with DeepSeek API for comic script generation
*/
import OpenAI from 'openai';
import type { ComicScript, GenerateScriptOptions } from '@/types';
const DEEPSEEK_API_KEY = import.meta.env.VITE_DEEPSEEK_API_KEY;
if (!DEEPSEEK_API_KEY) {
console.warn('VITE_DEEPSEEK_API_KEY not set. DeepSeek functionality will not work.');
}
const deepseek = new OpenAI({
apiKey: DEEPSEEK_API_KEY || 'dummy-key',
baseURL: 'https://api.deepseek.com',
dangerouslyAllowBrowser: true,
});
/**
* System prompt for comic script generation
* Produces structured, panel-by-panel comic scripts optimized for AI image generation
*/
const SCRIPT_SYSTEM_PROMPT = `You are an expert comic book writer and storyboard artist.
Your task is to take a user's story idea and transform it into a detailed comic book script optimized for AI image generation.
OUTPUT FORMAT: Return ONLY valid JSON in this exact structure:
{
"title": "Comic Title",
"synopsis": "One-paragraph summary",
"characters": [
{
"id": "char_001",
"name": "Character Name",
"role": "protagonist|antagonist|supporting",
"description": "Detailed visual description for image generation: age, gender, hair color/style, eye color, skin tone, body type, clothing, distinguishing features. Be extremely specific.",
"personality": "Brief personality notes for dialogue",
"firstAppearancePanel": "panel_001"
}
],
"pages": [
{
"pageNumber": 1,
"layoutType": "grid|manga|western|action|dialogue|splash",
"panels": [
{
"panelId": "panel_001",
"panelNumber": 1,
"shotType": "establishing|wide|medium|close-up|extreme-close-up|over-shoulder|aerial",
"description": "Detailed scene description for image generation. Include: setting, lighting, mood, character positions, action, camera angle. 2-3 sentences.",
"charactersPresent": ["char_001"],
"dialogue": [
{
"speakerId": "char_001",
"text": "Dialogue text",
"bubbleType": "normal|thought|shout|whisper",
"emotion": "happy|sad|angry|surprised|neutral|determined"
}
],
"caption": "Optional narration caption",
"soundEffects": ["BANG!", "CRASH!"],
"transitionFromPrevious": "none|fade|wipe|dissolve|action-lines"
}
]
}
]
}
RULES:
- 4-8 panels per page for standard pages, 1 panel for splash pages
- Vary shot types across panels for visual interest
- Include at least one establishing shot per scene
- Dialogue should be concise (1-3 lines per bubble)
- Description fields must be IMAGE-GENERATION-READY: vivid, specific, include art style keywords
- Character descriptions must be EXTREMELY detailed and consistent for all panels
- Include mood and lighting keywords in every panel description`;
/**
* Build user prompt for script generation
*/
function buildUserPrompt(options: GenerateScriptOptions): string {
const { storyIdea, genre, artStyle, numPages, audience } = options;
return `Create a ${numPages}-page comic script based on this idea: "${storyIdea}".
Genre: ${genre}
Target Art Style: ${artStyle} (incorporate style keywords into panel descriptions)
Target Audience: ${audience}`;
}
/**
* Validate and normalize the generated script
* Ensures all required fields are present and properly formatted
*/
export function validateAndNormalizeScript(script: unknown): ComicScript {
if (!script || typeof script !== 'object') {
throw new Error('Invalid script: not an object');
}
const s = script as Record<string, unknown>;
// Validate required fields
if (!s.title || typeof s.title !== 'string') {
throw new Error('Invalid script: missing or invalid title');
}
if (!s.synopsis || typeof s.synopsis !== 'string') {
throw new Error('Invalid script: missing or invalid synopsis');
}
if (!Array.isArray(s.characters)) {
throw new Error('Invalid script: characters must be an array');
}
if (!Array.isArray(s.pages)) {
throw new Error('Invalid script: pages must be an array');
}
// Normalize characters
const characters: ComicScript['characters'] = s.characters.map((char: unknown, index: number) => {
const c = char as Record<string, unknown>;
return {
id: String(c.id || `char_${String(index + 1).padStart(3, '0')}`),
name: String(c.name || `Character ${index + 1}`),
role: (c.role as 'protagonist' | 'antagonist' | 'supporting' | 'extra') || 'supporting',
description: String(c.description || ''),
promptTemplate: (c.promptTemplate as ComicScript['characters'][0]['promptTemplate']) || {
age: '',
gender: '',
hairColor: '',
hairStyle: '',
skinTone: '',
eyeColor: '',
bodyType: '',
outfit: '',
accessories: '',
distinguishingFeatures: '',
},
referenceImageUrl: String(c.referenceImageUrl || ''),
characterSheetUrls: Array.isArray(c.characterSheetUrls) ? c.characterSheetUrls.map(String) : [],
seed: Number(c.seed || Math.floor(Math.random() * 2147483647)),
colorPalette: (c.colorPalette as ComicScript['characters'][0]['colorPalette']) || {
hair: '',
eyes: '',
skin: '',
outfit: '',
},
appearanceCount: Number(c.appearanceCount || 0),
firstAppearancePanel: c.firstAppearancePanel ? String(c.firstAppearancePanel) : undefined,
};
});
// Normalize pages and panels
const pages: ComicScript['pages'] = s.pages.map((page: unknown, pageIndex: number) => {
const p = page as Record<string, unknown>;
const panels: ComicScript['pages'][0]['panels'] = Array.isArray(p.panels)
? p.panels.map((panel: unknown, panelIndex: number) => {
const pan = panel as Record<string, unknown>;
return {
panelId: String(pan.panelId || `panel_${String(pageIndex + 1).padStart(3, '0')}_${String(panelIndex + 1).padStart(3, '0')}`),
panelNumber: Number(pan.panelNumber || panelIndex + 1),
shotType: (pan.shotType as 'establishing' | 'wide' | 'medium' | 'close-up' | 'extreme-close-up' | 'over-shoulder' | 'aerial') || 'medium',
description: String(pan.description || ''),
charactersPresent: Array.isArray(pan.charactersPresent) ? pan.charactersPresent.map(String) : [],
dialogue: Array.isArray(pan.dialogue) ? pan.dialogue.map((d: unknown) => {
const dia = d as Record<string, unknown>;
return {
speakerId: String(dia.speakerId || ''),
text: String(dia.text || ''),
bubbleType: (dia.bubbleType as 'normal' | 'thought' | 'shout' | 'whisper') || 'normal',
emotion: (dia.emotion as 'happy' | 'sad' | 'angry' | 'surprised' | 'neutral' | 'determined') || 'neutral',
};
}) : [],
caption: pan.caption ? String(pan.caption) : undefined,
soundEffects: Array.isArray(pan.soundEffects) ? pan.soundEffects.map(String) : [],
transitionFromPrevious: (pan.transitionFromPrevious as 'none' | 'fade' | 'wipe' | 'dissolve' | 'action-lines') || 'none',
};
})
: [];
return {
pageNumber: Number(p.pageNumber || pageIndex + 1),
layoutType: (p.layoutType as 'grid' | 'manga' | 'western' | 'action' | 'dialogue' | 'splash') || 'grid',
panels,
};
});
return {
title: s.title,
synopsis: s.synopsis,
characters,
pages,
};
}
/**
* Generate comic script from story idea
* Returns complete structured script with characters, pages, and panels
*/
export async function generateComicScript(
options: GenerateScriptOptions
): Promise<ComicScript> {
if (!DEEPSEEK_API_KEY) {
throw new Error('DeepSeek API key not configured. Please set VITE_DEEPSEEK_API_KEY in .env.local');
}
const userPrompt = buildUserPrompt(options);
const response = await deepseek.chat.completions.create({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: SCRIPT_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
response_format: { type: 'json_object' },
temperature: 0.8,
max_tokens: 8000,
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('Empty response from DeepSeek API');
}
try {
const script = JSON.parse(content);
return validateAndNormalizeScript(script);
} catch (error) {
console.error('Failed to parse script:', error);
console.error('Raw content:', content);
throw new Error('Failed to parse generated script. Please try again.');
}
}
/**
* Stream comic script generation for real-time preview (Professional mode)
* Yields partial script chunks as they are generated
*/
export async function* streamComicScript(
options: GenerateScriptOptions
): AsyncGenerator<{ content: string; partial: string }> {
if (!DEEPSEEK_API_KEY) {
throw new Error('DeepSeek API key not configured');
}
const userPrompt = buildUserPrompt(options);
const stream = await deepseek.chat.completions.create({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: SCRIPT_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
stream: true,
temperature: 0.8,
max_tokens: 8000,
});
let buffer = '';
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
buffer += content;
yield { content, partial: buffer };
}
}
/**
* Generate script with retry logic
* Automatically retries on transient failures
*/
export async function generateComicScriptWithRetry(
options: GenerateScriptOptions,
retries: number = 3,
delay: number = 1000
): Promise<ComicScript> {
for (let i = 0; i < retries; i++) {
try {
return await generateComicScript(options);
} catch (error) {
if (i === retries - 1) throw error;
// Check if it's a rate limit error
if (error instanceof Error && error.message.includes('429')) {
const waitTime = delay * Math.pow(2, i);
console.log(`Rate limited. Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
} else {
throw error;
}
}
}
throw new Error('Max retries exceeded');
}
+276
View File
@@ -0,0 +1,276 @@
/**
* Export Service
*
* Handles exporting comics to various formats:
* - PDF (print-ready)
* - CBZ (digital comic archive)
* - PNG (individual pages)
*/
import { jsPDF } from 'jspdf';
import type { PageLayout, PanelImage } from '@/types';
export interface ExportOptions {
format: 'pdf' | 'cbz' | 'png';
pageSize: string;
dpi: number;
quality: number;
}
/**
* Export comic to PDF format
* Uses html2canvas to capture pages and jsPDF to generate PDF
*/
export async function exportToPDF(
pageLayouts: PageLayout[],
panelImages: Record<string, PanelImage>,
options: ExportOptions
): Promise<Blob> {
const pageSize = getPageSizeDimensions(options.pageSize);
const isLandscape = pageSize.width > pageSize.height;
const pdf = new jsPDF({
orientation: isLandscape ? 'landscape' : 'portrait',
unit: 'mm',
format: options.pageSize.toLowerCase() as 'a4' | 'letter',
});
for (let i = 0; i < pageLayouts.length; i++) {
if (i > 0) {
pdf.addPage();
}
const pageLayout = pageLayouts[i];
const canvas = await renderPageToCanvas(pageLayout, panelImages, options.dpi);
if (!canvas) {
console.warn(`Failed to render page ${i + 1}`);
continue;
}
const imgData = canvas.toDataURL('image/png', options.quality / 100);
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight);
}
return pdf.output('blob');
}
/**
* Export comic to CBZ format (ZIP archive of images)
*/
export async function exportToCBZ(
pageLayouts: PageLayout[],
panelImages: Record<string, PanelImage>,
options: ExportOptions
): Promise<Blob> {
const JSZip = await import('jszip');
const zip = new JSZip.default();
// Add each page as an image
for (let i = 0; i < pageLayouts.length; i++) {
const pageLayout = pageLayouts[i];
const canvas = await renderPageToCanvas(pageLayout, panelImages, options.dpi);
if (!canvas) {
console.warn(`Failed to render page ${i + 1}`);
continue;
}
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), 'image/jpeg', options.quality / 100);
});
// Pad page number: 001, 002, etc.
const pageNum = String(i + 1).padStart(3, '0');
zip.file(`page_${pageNum}.jpg`, blob);
}
// Add ComicInfo.xml metadata
const comicInfo = generateComicInfoXML(pageLayouts);
zip.file('ComicInfo.xml', comicInfo);
return await zip.generateAsync({ type: 'blob' });
}
/**
* Export individual pages as PNG images
* Returns a ZIP containing all pages as PNG
*/
export async function exportToPNG(
pageLayouts: PageLayout[],
panelImages: Record<string, PanelImage>,
options: ExportOptions
): Promise<Blob> {
const JSZip = await import('jszip');
const zip = new JSZip.default();
for (let i = 0; i < pageLayouts.length; i++) {
const pageLayout = pageLayouts[i];
const canvas = await renderPageToCanvas(pageLayout, panelImages, options.dpi);
if (!canvas) {
console.warn(`Failed to render page ${i + 1}`);
continue;
}
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), 'image/png');
});
const pageNum = String(i + 1).padStart(3, '0');
zip.file(`page_${pageNum}.png`, blob);
}
return await zip.generateAsync({ type: 'blob' });
}
/**
* Render a page layout to a canvas element
* Combines panel images into the page layout
*/
async function renderPageToCanvas(
pageLayout: PageLayout,
panelImages: Record<string, PanelImage>,
dpi: number
): Promise<HTMLCanvasElement | null> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// Set canvas size based on page dimensions and DPI
const scale = dpi / 300; // Base scale on 300 DPI
canvas.width = pageLayout.width * scale;
canvas.height = pageLayout.height * scale;
// Fill white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Render each panel
for (const panel of pageLayout.panels) {
const panelImage = panelImages[panel.panelId];
if (!panelImage?.url) continue;
try {
const img = await loadImage(panelImage.url);
// Calculate panel position and size
const x = panel.layoutCell.x * canvas.width;
const y = panel.layoutCell.y * canvas.height;
const w = panel.layoutCell.w * canvas.width;
const h = panel.layoutCell.h * canvas.height;
// Draw panel image with gutter margins
const gutter = 4 * scale;
ctx.drawImage(
img,
x + gutter,
y + gutter,
w - gutter * 2,
h - gutter * 2
);
// Draw panel border
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2 * scale;
ctx.strokeRect(x + gutter, y + gutter, w - gutter * 2, h - gutter * 2);
} catch (error) {
console.error(`Failed to load panel image ${panel.panelId}:`, error);
}
}
return canvas;
}
/**
* Load an image from URL
*/
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
/**
* Generate ComicInfo.xml metadata for CBZ files
* Standard format used by comic readers
*/
function generateComicInfoXML(pageLayouts: PageLayout[]): string {
return `<?xml version="1.0" encoding="utf-8"?>
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Title>Generated Comic</Title>
<PageCount>${pageLayouts.length}</PageCount>
<Pages>
${pageLayouts.map((_, index) => ` <Page Image="${index}" ImageSize="0" />`).join('\n')}
</Pages>
</ComicInfo>`;
}
/**
* Get page dimensions in pixels at specified DPI
*/
function getPageSizeDimensions(pageSize: string): { width: number; height: number } {
const sizes: Record<string, { width: number; height: number }> = {
a4: { width: 2480, height: 3508 },
letter: { width: 2550, height: 3300 },
manga: { width: 2158, height: 3035 },
webtoon: { width: 800, height: 1280 },
square: { width: 1080, height: 1080 },
};
return sizes[pageSize] || sizes.a4;
}
/**
* Trigger file download
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Export with progress tracking
*/
export async function exportWithProgress(
pageLayouts: PageLayout[],
panelImages: Record<string, PanelImage>,
options: ExportOptions,
onProgress?: (progress: number) => void
): Promise<Blob> {
const totalSteps = pageLayouts.length + 1; // +1 for final processing
let currentStep = 0;
// Wrapper function to track progress
const trackProgress = async <T,>(fn: () => Promise<T>): Promise<T> => {
const result = await fn();
currentStep++;
onProgress?.(Math.min((currentStep / totalSteps) * 100, 100));
return result;
};
switch (options.format) {
case 'pdf':
return trackProgress(() => exportToPDF(pageLayouts, panelImages, options));
case 'cbz':
return trackProgress(() => exportToCBZ(pageLayouts, panelImages, options));
case 'png':
return trackProgress(() => exportToPNG(pageLayouts, panelImages, options));
default:
throw new Error(`Unknown export format: ${options.format}`);
}
}
+289
View File
@@ -0,0 +1,289 @@
/**
* fal.ai Service - Image Generation
*
* Handles image generation for character references and comic panels
* Uses NanoBanan model for anime/manga-style artwork
*/
import { fal } from '@fal-ai/client';
import type { Character, Panel, PanelImage } from '@/types';
import {
buildCharacterPrompt,
generatePanelSeed
} from '@/utils/characterTemplates';
const FAL_KEY = import.meta.env.VITE_FAL_KEY;
if (!FAL_KEY) {
console.warn('VITE_FAL_KEY not set. Image generation will not work.');
}
fal.config({
credentials: FAL_KEY || 'dummy-key',
});
// Art style keywords mapping
const ART_STYLE_KEYWORDS: Record<string, string> = {
'manga': 'Japanese manga style, black and white ink, screentone shading, speed lines, dramatic shadows, clean linework, right-to-left reading',
'western-comic': 'American comic book style, bold outlines, vibrant colors, dynamic poses, cross-hatching, Ben-Day dots, left-to-right reading',
'pixel-art': '16-bit pixel art style, retro game aesthetic, limited color palette, crisp pixels, dithering, nostalgic',
'watercolor': 'watercolor painting style, soft edges, paper texture, translucent washes, delicate linework, artistic',
'noir': 'film noir style, high contrast black and white, dramatic chiaroscuro lighting, gritty, shadow-heavy, 1940s aesthetic',
'chibi': 'chibi anime style, super deformed characters, big heads, small bodies, cute, colorful, expressive',
'sketch': 'pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome',
'cyberpunk': 'cyberpunk aesthetic, neon lighting, futuristic cityscapes, holographic elements, high contrast, synthwave colors',
};
// Default style modifier for quality
const QUALITY_MODIFIER = 'high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece';
/**
* Map shot type to optimal image dimensions
*/
function getImageSizeForShotType(shotType: string): "square" | "landscape_16_9" | "landscape_4_3" | "portrait_4_3" | "square_hd" | "portrait_16_9" {
const mapping: Record<string, "square" | "landscape_16_9" | "landscape_4_3" | "portrait_4_3" | "square_hd" | "portrait_16_9"> = {
'establishing': 'landscape_16_9',
'wide': 'landscape_16_9',
'medium': 'landscape_4_3',
'close-up': 'portrait_4_3',
'extreme-close-up': 'square_hd',
'over-shoulder': 'landscape_4_3',
'aerial': 'landscape_16_9',
};
return mapping[shotType] || 'landscape_4_3';
}
/**
* Generate character reference portrait
* This is the master anchor image for consistency
*/
export async function generateCharacterReference(
character: Character,
artStyle: string
): Promise<string> {
if (!FAL_KEY) {
throw new Error('fal.ai API key not configured. Please set VITE_FAL_KEY in .env.local');
}
const styleKeywords = ART_STYLE_KEYWORDS[artStyle] || artStyle;
const prompt = buildCharacterPrompt(
character.promptTemplate,
'standing in neutral pose facing camera, character portrait, clean background',
'studio lighting, white background',
'front-facing portrait, centered composition',
`${styleKeywords}, ${QUALITY_MODIFIER}`
);
try {
const result = await fal.subscribe('fal-ai/fast-sdxl', {
input: {
prompt,
negative_prompt: 'blurry, low quality, distorted face, extra limbs, bad anatomy, deformed, watermark, signature',
seed: character.seed,
image_size: 'square_hd',
num_inference_steps: 30,
guidance_scale: 7.5,
},
});
if (!result.data?.images?.[0]?.url) {
throw new Error('No image returned from fal.ai');
}
return result.data.images[0].url;
} catch (error) {
console.error('Character reference generation failed:', error);
throw new Error(`Failed to generate character reference: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Generate multi-angle character sheet (Professional mode)
* Creates front, 3/4, profile, and back views
*/
export async function generateCharacterSheet(
character: Character,
artStyle: string
): Promise<string[]> {
if (!FAL_KEY) {
throw new Error('fal.ai API key not configured');
}
const styleKeywords = ART_STYLE_KEYWORDS[artStyle] || artStyle;
const poses = [
{ name: 'front-facing portrait', view: 'front' },
{ name: 'three-quarter view portrait', view: 'three-quarter' },
{ name: 'side profile view', view: 'profile' },
{ name: 'back view showing hair and outfit', view: 'back' },
];
const sheetUrls: string[] = [];
for (let i = 0; i < poses.length; i++) {
const pose = poses[i];
const prompt = buildCharacterPrompt(
character.promptTemplate,
`${pose.name}, ${pose.view} view, character reference, clean background`,
'studio lighting, white background',
`${pose.view} view, centered composition`,
`${styleKeywords}, ${QUALITY_MODIFIER}`
);
// Use previous image as reference for consistency
const referenceImages = sheetUrls.length > 0 ? [sheetUrls[0]] : undefined;
try {
const result = await fal.subscribe('fal-ai/fast-sdxl', {
input: {
prompt,
negative_prompt: 'blurry, low quality, distorted face, extra limbs, bad anatomy, deformed',
seed: character.seed + i, // Increment seed for variation
image_size: 'square_hd',
num_inference_steps: 30,
guidance_scale: 7.5,
...(referenceImages && {
reference_images: referenceImages,
reference_image_strength: 0.65, // Sweet spot for preserving identity
}),
},
});
if (result.data?.images?.[0]?.url) {
sheetUrls.push(result.data.images[0].url);
}
} catch (error) {
console.error(`Failed to generate ${pose.view} view:`, error);
// Continue with other poses even if one fails
}
}
if (sheetUrls.length === 0) {
throw new Error('Failed to generate any character sheet images');
}
return sheetUrls;
}
/**
* Generate panel image with character consistency
* Uses character reference images for IP-Adapter consistency
*/
export async function generatePanelImage(
panel: Panel,
characters: Character[],
artStyle: string,
projectId: string
): Promise<PanelImage> {
if (!FAL_KEY) {
throw new Error('fal.ai API key not configured');
}
const styleKeywords = ART_STYLE_KEYWORDS[artStyle] || artStyle;
// Build character prompts for all characters in the panel
const characterPrompts = panel.charactersPresent.map(charId => {
const char = characters.find(c => c.id === charId);
if (!char) return '';
return buildCharacterPrompt(
char.promptTemplate,
panel.description,
'', // setting is in panel.description
'', // lighting is in panel.description
styleKeywords
);
}).filter(Boolean).join('. ');
const fullPrompt = `${styleKeywords} comic panel. ${panel.description}. Characters: ${characterPrompts}. ${panel.shotType} shot. ${QUALITY_MODIFIER}`;
// Collect reference images for all characters
const referenceImages = panel.charactersPresent
.map(id => characters.find(c => c.id === id)?.referenceImageUrl)
.filter((url): url is string => Boolean(url));
// Generate deterministic seed for this panel
const seed = generatePanelSeed(
projectId,
1, // We don't have page number in Panel, use 1
panel.panelNumber,
panel.panelId
);
try {
const result = await fal.subscribe('fal-ai/fast-sdxl', {
input: {
prompt: fullPrompt,
negative_prompt: 'blurry, low quality, distorted face, extra limbs, bad anatomy, deformed, watermark, signature, text, speech bubble',
seed,
image_size: getImageSizeForShotType(panel.shotType),
num_inference_steps: 30,
guidance_scale: 7.5,
...(referenceImages.length > 0 && {
reference_images: referenceImages,
reference_image_strength: 0.65,
}),
},
});
if (!result.data?.images?.[0]) {
throw new Error('No image returned from fal.ai');
}
const image = result.data.images[0];
return {
url: image.url,
width: image.width || 1024,
height: image.height || 1024,
seed,
prompt: fullPrompt,
};
} catch (error) {
console.error('Panel generation failed:', error);
throw new Error(`Failed to generate panel: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Generate all panels in parallel batches
* Prevents overwhelming the API while maintaining efficiency
*/
export async function generateAllPanels(
panels: Panel[],
characters: Character[],
artStyle: string,
projectId: string,
onProgress?: (progress: number) => void
): Promise<Record<string, PanelImage>> {
const batchSize = 3; // Max concurrent requests
const results: Record<string, PanelImage> = {};
for (let i = 0; i < panels.length; i += batchSize) {
const batch = panels.slice(i, i + batchSize);
const batchResults = await Promise.allSettled(
batch.map(panel => generatePanelImage(panel, characters, artStyle, projectId))
);
batchResults.forEach((result, idx) => {
const panel = batch[idx];
if (result.status === 'fulfilled') {
results[panel.panelId] = result.value;
} else {
console.error(`Failed to generate panel ${panel.panelId}:`, result.reason);
}
});
// Report progress
if (onProgress) {
onProgress(Math.min(((i + batch.length) / panels.length) * 100, 100));
}
// Small delay between batches to avoid rate limits
if (i + batchSize < panels.length) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
return results;
}