comic/src/services/deepseekService.ts
2026-05-18 19:58:01 +02:00

296 lines
10 KiB
TypeScript

/**
* 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');
}