296 lines
10 KiB
TypeScript
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');
|
|
}
|