init state
This commit is contained in:
commit
ae1cae967b
46 files changed
+12734
No files matched your search
@@ -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}`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user