/** * 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; // 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; 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; const panels: ComicScript['pages'][0]['panels'] = Array.isArray(p.panels) ? p.panels.map((panel: unknown, panelIndex: number) => { const pan = panel as Record; 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; 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 { 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 { 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'); }