mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-28 03:40:11 +00:00
Add portraits
This commit is contained in:
parent
00012f66eb
commit
733e7d9196
17 changed files with 1221 additions and 47 deletions
5
src-tauri/migrations/012_character_portraits.sql
Normal file
5
src-tauri/migrations/012_character_portraits.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Character portrait images for visual reference and image generation
|
||||
-- Stored as base64 encoded image data (same format as embedded_images)
|
||||
-- Portraits are used as reference images when generating story illustrations
|
||||
|
||||
ALTER TABLE characters ADD COLUMN portrait TEXT DEFAULT NULL;
|
||||
|
|
@ -76,6 +76,12 @@ pub fn run() {
|
|||
sql: include_str!("../migrations/011_image_generation.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 12,
|
||||
description: "add_character_portraits",
|
||||
sql: include_str!("../migrations/012_character_portraits.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
];
|
||||
|
||||
tauri::Builder::default()
|
||||
|
|
|
|||
|
|
@ -1374,23 +1374,25 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Model -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-surface-300">
|
||||
Image Model
|
||||
</label>
|
||||
<p class="text-xs text-surface-500">The NanoGPT image model to use.</p>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm w-full bg-surface-800 border-surface-600 text-surface-100"
|
||||
value={settings.systemServicesSettings.imageGeneration.model}
|
||||
oninput={(e) => {
|
||||
settings.systemServicesSettings.imageGeneration.model = e.currentTarget.value;
|
||||
settings.saveSystemServicesSettings();
|
||||
}}
|
||||
placeholder="z-image-turbo"
|
||||
/>
|
||||
</div>
|
||||
<!-- Image Model (hidden when portrait mode is enabled) -->
|
||||
{#if !settings.systemServicesSettings.imageGeneration.portraitMode}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-surface-300">
|
||||
Image Model
|
||||
</label>
|
||||
<p class="text-xs text-surface-500">The NanoGPT image model to use.</p>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm w-full bg-surface-800 border-surface-600 text-surface-100"
|
||||
value={settings.systemServicesSettings.imageGeneration.model}
|
||||
oninput={(e) => {
|
||||
settings.systemServicesSettings.imageGeneration.model = e.currentTarget.value;
|
||||
settings.saveSystemServicesSettings();
|
||||
}}
|
||||
placeholder="z-image-turbo"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Image Style -->
|
||||
<div class="space-y-2">
|
||||
|
|
@ -1476,6 +1478,70 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Portrait Reference Mode -->
|
||||
<div class="border-t border-surface-700 pt-4 mt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-surface-200">Portrait Reference Mode</h3>
|
||||
<p class="text-xs text-surface-500">Use character portraits as reference images when generating story images.</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
class:bg-accent-600={settings.systemServicesSettings.imageGeneration.portraitMode}
|
||||
class:bg-surface-600={!settings.systemServicesSettings.imageGeneration.portraitMode}
|
||||
onclick={() => {
|
||||
settings.systemServicesSettings.imageGeneration.portraitMode = !settings.systemServicesSettings.imageGeneration.portraitMode;
|
||||
settings.saveSystemServicesSettings();
|
||||
}}
|
||||
aria-label="Toggle portrait mode"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
class:translate-x-6={settings.systemServicesSettings.imageGeneration.portraitMode}
|
||||
class:translate-x-1={!settings.systemServicesSettings.imageGeneration.portraitMode}
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if settings.systemServicesSettings.imageGeneration.portraitMode}
|
||||
<!-- Portrait Generation Model -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-surface-300">
|
||||
Portrait Generation Model
|
||||
</label>
|
||||
<p class="text-xs text-surface-500">Model used when generating character portraits from visual descriptors.</p>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm w-full bg-surface-800 border-surface-600 text-surface-100"
|
||||
value={settings.systemServicesSettings.imageGeneration.portraitModel}
|
||||
oninput={(e) => {
|
||||
settings.systemServicesSettings.imageGeneration.portraitModel = e.currentTarget.value;
|
||||
settings.saveSystemServicesSettings();
|
||||
}}
|
||||
placeholder="z-image-turbo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Reference Image Model -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-surface-300">
|
||||
Reference Image Model
|
||||
</label>
|
||||
<p class="text-xs text-surface-500">Model used for story images when a character portrait is attached as reference.</p>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm w-full bg-surface-800 border-surface-600 text-surface-100"
|
||||
value={settings.systemServicesSettings.imageGeneration.referenceModel}
|
||||
oninput={(e) => {
|
||||
settings.systemServicesSettings.imageGeneration.referenceModel = e.currentTarget.value;
|
||||
settings.saveSystemServicesSettings();
|
||||
}}
|
||||
placeholder="qwen-image"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reset Button -->
|
||||
<div class="border-t border-surface-700 pt-4 mt-4">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ import {
|
|||
readCharacterCardFile,
|
||||
type CardImportResult,
|
||||
} from '$lib/services/characterCardImporter';
|
||||
import { NanoGPTImageProvider } from '$lib/services/ai/nanoGPTImageProvider';
|
||||
import { promptService } from '$lib/services/prompts';
|
||||
import { normalizeImageDataUrl } from '$lib/utils/image';
|
||||
import type { StoryMode, POV, EntryType } from '$lib/types';
|
||||
import {
|
||||
X,
|
||||
|
|
@ -52,6 +55,8 @@ import {
|
|||
Book,
|
||||
Trash2,
|
||||
Plus,
|
||||
ImageIcon,
|
||||
ImageUp,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -62,7 +67,7 @@ import {
|
|||
|
||||
// Wizard state
|
||||
let currentStep = $state(1);
|
||||
const totalSteps = 7;
|
||||
const totalSteps = 8;
|
||||
|
||||
// Step 1: Mode
|
||||
let selectedMode = $state<StoryMode>('adventure');
|
||||
|
|
@ -103,7 +108,18 @@ import {
|
|||
let supportingCharacterTraits = $state('');
|
||||
let isElaboratingSupportingCharacter = $state(false);
|
||||
|
||||
// Step 2: Import Lorebook (optional - moved to early position)
|
||||
// Step 7: Portraits
|
||||
let protagonistVisualDescriptors = $state('');
|
||||
let protagonistPortrait = $state<string | null>(null);
|
||||
let isGeneratingProtagonistPortrait = $state(false);
|
||||
let portraitError = $state<string | null>(null);
|
||||
// Supporting character visual descriptors and portraits keyed by character NAME (not index)
|
||||
// This prevents data loss when characters are removed/reordered
|
||||
let supportingCharacterVisualDescriptors = $state<Record<string, string>>({});
|
||||
let supportingCharacterPortraits = $state<Record<string, string | null>>({});
|
||||
let generatingPortraitName = $state<string | null>(null);
|
||||
|
||||
// Step 2: Import Lorebook (optional - moved to early position)
|
||||
let importedLorebook = $state<LorebookImportResult | null>(null);
|
||||
let importedEntries = $state<ImportedEntry[]>([]);
|
||||
let isImporting = $state(false);
|
||||
|
|
@ -199,8 +215,9 @@ import {
|
|||
case 3: return selectedGenre !== 'custom' || customGenre.trim().length > 0;
|
||||
case 4: return settingSeed.trim().length > 0;
|
||||
case 5: return true; // Protagonist is optional
|
||||
case 6: return true; // Style always has defaults
|
||||
case 7: return storyTitle.trim().length > 0;
|
||||
case 6: return true; // Portraits are optional
|
||||
case 7: return true; // Style always has defaults
|
||||
case 8: return storyTitle.trim().length > 0;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -585,6 +602,23 @@ import {
|
|||
// Prepare story data
|
||||
const storyData = scenarioService.prepareStoryData(wizardData, processedOpening);
|
||||
|
||||
// Add portraits and visual descriptors to protagonist
|
||||
if (storyData.protagonist) {
|
||||
storyData.protagonist.portrait = protagonistPortrait ?? undefined;
|
||||
storyData.protagonist.visualDescriptors = protagonistVisualDescriptors
|
||||
? protagonistVisualDescriptors.split(',').map(d => d.trim()).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
// Add portraits and visual descriptors to supporting characters (keyed by name)
|
||||
storyData.characters = storyData.characters.map((char) => ({
|
||||
...char,
|
||||
portrait: supportingCharacterPortraits[char.name] ?? undefined,
|
||||
visualDescriptors: supportingCharacterVisualDescriptors[char.name]
|
||||
? supportingCharacterVisualDescriptors[char.name].split(',').map(d => d.trim()).filter(Boolean)
|
||||
: [],
|
||||
}));
|
||||
|
||||
// Create the story using the store, including any imported entries
|
||||
const newStory = await story.createStoryFromWizard({
|
||||
...storyData,
|
||||
|
|
@ -604,10 +638,262 @@ import {
|
|||
'Select a Genre',
|
||||
'Describe Your Setting',
|
||||
'Create Your Character',
|
||||
'Character Portraits (Optional)',
|
||||
'Writing Style',
|
||||
'Generate Opening',
|
||||
];
|
||||
|
||||
// Portrait generation functions
|
||||
async function generateProtagonistPortrait() {
|
||||
if (!protagonist || isGeneratingProtagonistPortrait) return;
|
||||
|
||||
const imageSettings = settings.systemServicesSettings.imageGeneration;
|
||||
if (!imageSettings.nanoGptApiKey) {
|
||||
portraitError = 'NanoGPT API key required for portrait generation';
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptors = protagonistVisualDescriptors.trim();
|
||||
if (!descriptors) {
|
||||
portraitError = 'Add appearance descriptors first';
|
||||
return;
|
||||
}
|
||||
|
||||
isGeneratingProtagonistPortrait = true;
|
||||
portraitError = null;
|
||||
|
||||
try {
|
||||
// Get style prompt
|
||||
const styleId = imageSettings.styleId;
|
||||
let stylePrompt = '';
|
||||
try {
|
||||
const promptContext = {
|
||||
mode: 'adventure' as const,
|
||||
pov: 'second' as const,
|
||||
tense: 'present' as const,
|
||||
protagonistName: '',
|
||||
};
|
||||
stylePrompt = promptService.getPrompt(styleId, promptContext) || '';
|
||||
} catch {
|
||||
stylePrompt = 'Soft cel-shaded anime illustration. Muted pastel color palette. Dreamy, airy atmosphere.';
|
||||
}
|
||||
|
||||
// Build portrait prompt
|
||||
const promptContext = {
|
||||
mode: 'adventure' as const,
|
||||
pov: 'second' as const,
|
||||
tense: 'present' as const,
|
||||
protagonistName: '',
|
||||
};
|
||||
|
||||
const portraitPrompt = promptService.renderPrompt('image-portrait-generation', promptContext, {
|
||||
imageStylePrompt: stylePrompt,
|
||||
visualDescriptors: descriptors,
|
||||
characterName: protagonist.name,
|
||||
});
|
||||
|
||||
// Generate
|
||||
const provider = new NanoGPTImageProvider(imageSettings.nanoGptApiKey);
|
||||
const response = await provider.generateImage({
|
||||
prompt: portraitPrompt,
|
||||
model: imageSettings.portraitModel || 'z-image-turbo',
|
||||
size: '1024x1024',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
|
||||
if (response.images.length === 0 || !response.images[0].b64_json) {
|
||||
throw new Error('No image data returned');
|
||||
}
|
||||
|
||||
protagonistPortrait = `data:image/png;base64,${response.images[0].b64_json}`;
|
||||
} catch (error) {
|
||||
portraitError = error instanceof Error ? error.message : 'Failed to generate portrait';
|
||||
} finally {
|
||||
isGeneratingProtagonistPortrait = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSupportingCharacterPortrait(charName: string) {
|
||||
const char = supportingCharacters.find(c => c.name === charName);
|
||||
if (!char || generatingPortraitName !== null) return;
|
||||
|
||||
const imageSettings = settings.systemServicesSettings.imageGeneration;
|
||||
if (!imageSettings.nanoGptApiKey) {
|
||||
portraitError = 'NanoGPT API key required for portrait generation';
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptors = (supportingCharacterVisualDescriptors[charName] || '').trim();
|
||||
if (!descriptors) {
|
||||
portraitError = `Add appearance descriptors for ${char.name} first`;
|
||||
return;
|
||||
}
|
||||
|
||||
generatingPortraitName = charName;
|
||||
portraitError = null;
|
||||
|
||||
try {
|
||||
// Get style prompt
|
||||
const styleId = imageSettings.styleId;
|
||||
let stylePrompt = '';
|
||||
try {
|
||||
const promptContext = {
|
||||
mode: 'adventure' as const,
|
||||
pov: 'second' as const,
|
||||
tense: 'present' as const,
|
||||
protagonistName: '',
|
||||
};
|
||||
stylePrompt = promptService.getPrompt(styleId, promptContext) || '';
|
||||
} catch {
|
||||
stylePrompt = 'Soft cel-shaded anime illustration. Muted pastel color palette. Dreamy, airy atmosphere.';
|
||||
}
|
||||
|
||||
// Build portrait prompt
|
||||
const promptContext = {
|
||||
mode: 'adventure' as const,
|
||||
pov: 'second' as const,
|
||||
tense: 'present' as const,
|
||||
protagonistName: '',
|
||||
};
|
||||
|
||||
const portraitPrompt = promptService.renderPrompt('image-portrait-generation', promptContext, {
|
||||
imageStylePrompt: stylePrompt,
|
||||
visualDescriptors: descriptors,
|
||||
characterName: char.name,
|
||||
});
|
||||
|
||||
// Generate
|
||||
const provider = new NanoGPTImageProvider(imageSettings.nanoGptApiKey);
|
||||
const response = await provider.generateImage({
|
||||
prompt: portraitPrompt,
|
||||
model: imageSettings.portraitModel || 'z-image-turbo',
|
||||
size: '1024x1024',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
|
||||
if (response.images.length === 0 || !response.images[0].b64_json) {
|
||||
throw new Error('No image data returned');
|
||||
}
|
||||
|
||||
supportingCharacterPortraits[charName] = `data:image/png;base64,${response.images[0].b64_json}`;
|
||||
supportingCharacterPortraits = { ...supportingCharacterPortraits }; // Trigger reactivity
|
||||
} catch (error) {
|
||||
portraitError = error instanceof Error ? error.message : 'Failed to generate portrait';
|
||||
} finally {
|
||||
generatingPortraitName = null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeProtagonistPortrait() {
|
||||
protagonistPortrait = null;
|
||||
portraitError = null;
|
||||
}
|
||||
|
||||
function removeSupportingCharacterPortrait(charName: string) {
|
||||
supportingCharacterPortraits[charName] = null;
|
||||
supportingCharacterPortraits = { ...supportingCharacterPortraits };
|
||||
portraitError = null;
|
||||
}
|
||||
|
||||
// Portrait upload handlers
|
||||
let isUploadingProtagonistPortrait = $state(false);
|
||||
let uploadingCharacterName = $state<string | null>(null);
|
||||
|
||||
async function handleProtagonistPortraitUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
isUploadingProtagonistPortrait = true;
|
||||
portraitError = null;
|
||||
|
||||
try {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Please select an image file');
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
throw new Error('Image must be smaller than 5MB');
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== 'string' || !result.startsWith('data:image/')) {
|
||||
reject(new Error('Failed to read image data'));
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
protagonistPortrait = dataUrl;
|
||||
} catch (error) {
|
||||
portraitError = error instanceof Error ? error.message : 'Failed to upload portrait';
|
||||
} finally {
|
||||
isUploadingProtagonistPortrait = false;
|
||||
// Reset input
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSupportingCharacterPortraitUpload(event: Event, charName: string) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
uploadingCharacterName = charName;
|
||||
portraitError = null;
|
||||
|
||||
try {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Please select an image file');
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
throw new Error('Image must be smaller than 5MB');
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== 'string' || !result.startsWith('data:image/')) {
|
||||
reject(new Error('Failed to read image data'));
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
supportingCharacterPortraits[charName] = dataUrl;
|
||||
supportingCharacterPortraits = { ...supportingCharacterPortraits }; // Trigger reactivity
|
||||
} catch (error) {
|
||||
portraitError = error instanceof Error ? error.message : 'Failed to upload portrait';
|
||||
} finally {
|
||||
uploadingCharacterName = null;
|
||||
// Reset input
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if image generation is enabled
|
||||
const imageGenerationEnabled = $derived(
|
||||
settings.systemServicesSettings.imageGeneration.enabled &&
|
||||
!!settings.systemServicesSettings.imageGeneration.nanoGptApiKey
|
||||
);
|
||||
|
||||
// Lorebook import functions
|
||||
async function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
|
@ -1593,7 +1879,218 @@ function clearImport() {
|
|||
</div>
|
||||
|
||||
{:else if currentStep === 6}
|
||||
<!-- Step 6: Writing Style -->
|
||||
<!-- Step 6: Character Portraits -->
|
||||
<div class="space-y-4">
|
||||
<p class="text-surface-400">
|
||||
Upload or generate portrait images for your characters. In portrait mode, only characters with portraits can appear in story images.
|
||||
</p>
|
||||
|
||||
{#if !imageGenerationEnabled}
|
||||
<div class="card bg-amber-500/10 border-amber-500/30 p-4">
|
||||
<p class="text-sm text-amber-400">
|
||||
Image generation is not configured. You can still upload portraits manually, or enable generation in Settings > Image Generation.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if portraitError}
|
||||
<div class="card bg-red-500/10 border-red-500/30 p-3">
|
||||
<p class="text-sm text-red-400">{portraitError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Protagonist Portrait -->
|
||||
{#if protagonist}
|
||||
<div class="card bg-surface-900 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-surface-100">{protagonist.name}</h3>
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-primary-500/20 text-primary-400">Protagonist</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<!-- Portrait Preview -->
|
||||
<div class="shrink-0">
|
||||
{#if protagonistPortrait}
|
||||
<div class="relative">
|
||||
<img
|
||||
src={normalizeImageDataUrl(protagonistPortrait) ?? ''}
|
||||
alt="{protagonist.name} portrait"
|
||||
class="w-24 h-24 rounded-lg object-cover ring-1 ring-surface-600"
|
||||
/>
|
||||
<button
|
||||
class="absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white hover:bg-red-600"
|
||||
onclick={removeProtagonistPortrait}
|
||||
title="Remove portrait"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-24 h-24 rounded-lg border-2 border-dashed border-surface-600 bg-surface-800 flex items-center justify-center">
|
||||
<User class="h-8 w-8 text-surface-600" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Appearance Input & Generate/Upload Buttons -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-surface-400">Appearance (comma-separated)</label>
|
||||
<textarea
|
||||
bind:value={protagonistVisualDescriptors}
|
||||
placeholder="e.g., long silver hair, violet eyes, fair skin, elegant dark blue coat..."
|
||||
class="input text-sm min-h-[60px] resize-none"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<label class="btn btn-secondary btn-sm flex items-center gap-1 cursor-pointer">
|
||||
{#if isUploadingProtagonistPortrait}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
Uploading...
|
||||
{:else}
|
||||
<ImageUp class="h-3 w-3" />
|
||||
Upload
|
||||
{/if}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={handleProtagonistPortraitUpload}
|
||||
disabled={isUploadingProtagonistPortrait || isGeneratingProtagonistPortrait}
|
||||
/>
|
||||
</label>
|
||||
{#if imageGenerationEnabled}
|
||||
<button
|
||||
class="btn btn-secondary btn-sm flex items-center gap-1"
|
||||
onclick={generateProtagonistPortrait}
|
||||
disabled={isGeneratingProtagonistPortrait || isUploadingProtagonistPortrait || !protagonistVisualDescriptors.trim()}
|
||||
title={!protagonistVisualDescriptors.trim() ? 'Add appearance descriptors to generate' : ''}
|
||||
>
|
||||
{#if isGeneratingProtagonistPortrait}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
Generating...
|
||||
{:else}
|
||||
<Wand2 class="h-3 w-3" />
|
||||
{protagonistPortrait ? 'Regenerate' : 'Generate'}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card bg-surface-900 border-dashed border-2 border-surface-600 p-4 text-center">
|
||||
<p class="text-surface-400 text-sm">No protagonist created. Go back to step 5 to create one.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Supporting Character Portraits -->
|
||||
{#if supportingCharacters.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium text-surface-300">Supporting Characters</h4>
|
||||
{#each supportingCharacters as char, index}
|
||||
<div class="card bg-surface-900 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-surface-100">{char.name}</h3>
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-accent-500/20 text-accent-400">{char.role}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<!-- Portrait Preview -->
|
||||
<div class="shrink-0">
|
||||
{#if supportingCharacterPortraits[char.name]}
|
||||
<div class="relative">
|
||||
<img
|
||||
src={normalizeImageDataUrl(supportingCharacterPortraits[char.name]) ?? ''}
|
||||
alt="{char.name} portrait"
|
||||
class="w-24 h-24 rounded-lg object-cover ring-1 ring-surface-600"
|
||||
/>
|
||||
<button
|
||||
class="absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white hover:bg-red-600"
|
||||
onclick={() => removeSupportingCharacterPortrait(char.name)}
|
||||
title="Remove portrait"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-24 h-24 rounded-lg border-2 border-dashed border-surface-600 bg-surface-800 flex items-center justify-center">
|
||||
<User class="h-8 w-8 text-surface-600" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Appearance Input & Generate/Upload Buttons -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-surface-400">Appearance (comma-separated)</label>
|
||||
<textarea
|
||||
value={supportingCharacterVisualDescriptors[char.name] || ''}
|
||||
oninput={(e) => {
|
||||
supportingCharacterVisualDescriptors[char.name] = e.currentTarget.value;
|
||||
supportingCharacterVisualDescriptors = { ...supportingCharacterVisualDescriptors };
|
||||
}}
|
||||
placeholder="e.g., short dark hair, green eyes, athletic build..."
|
||||
class="input text-sm min-h-[60px] resize-none"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<label class="btn btn-secondary btn-sm flex items-center gap-1 cursor-pointer">
|
||||
{#if uploadingCharacterName === char.name}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
Uploading...
|
||||
{:else}
|
||||
<ImageUp class="h-3 w-3" />
|
||||
Upload
|
||||
{/if}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={(e) => handleSupportingCharacterPortraitUpload(e, char.name)}
|
||||
disabled={uploadingCharacterName !== null || generatingPortraitName !== null}
|
||||
/>
|
||||
</label>
|
||||
{#if imageGenerationEnabled}
|
||||
<button
|
||||
class="btn btn-secondary btn-sm flex items-center gap-1"
|
||||
onclick={() => generateSupportingCharacterPortrait(char.name)}
|
||||
disabled={generatingPortraitName !== null || uploadingCharacterName !== null || !(supportingCharacterVisualDescriptors[char.name] || '').trim()}
|
||||
title={!(supportingCharacterVisualDescriptors[char.name] || '').trim() ? 'Add appearance descriptors to generate' : ''}
|
||||
>
|
||||
{#if generatingPortraitName === char.name}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
Generating...
|
||||
{:else}
|
||||
<Wand2 class="h-3 w-3" />
|
||||
{supportingCharacterPortraits[char.name] ? 'Regenerate' : 'Generate'}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !protagonist && supportingCharacters.length === 0}
|
||||
<div class="card bg-surface-900 border-dashed border-2 border-surface-600 p-6 text-center">
|
||||
<p class="text-surface-400">No characters created yet. Go back to step 5 to create characters.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-surface-500 text-center">
|
||||
Portraits are optional. You can skip this step and add portraits later from the Characters panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{:else if currentStep === 7}
|
||||
<!-- Step 7: Writing Style -->
|
||||
<div class="space-y-6">
|
||||
<p class="text-surface-400">Customize how your story will be written.</p>
|
||||
|
||||
|
|
@ -1660,8 +2157,8 @@ function clearImport() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{:else if currentStep === 7}
|
||||
<!-- Step 7: Generate Opening -->
|
||||
{:else if currentStep === 8}
|
||||
<!-- Step 8: Generate Opening -->
|
||||
<div class="space-y-4">
|
||||
<p class="text-surface-400">
|
||||
Give your story a title and generate the opening scene.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { Plus, User, Skull, UserX, Pencil, Trash2, Star } from 'lucide-svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { Plus, User, Skull, UserX, Pencil, Trash2, Star, ImageUp, Wand2, X, Loader2 } from 'lucide-svelte';
|
||||
import type { Character } from '$lib/types';
|
||||
import { NanoGPTImageProvider } from '$lib/services/ai/nanoGPTImageProvider';
|
||||
import { promptService } from '$lib/services/prompts';
|
||||
import { normalizeImageDataUrl } from '$lib/utils/image';
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let newName = $state('');
|
||||
|
|
@ -18,6 +22,13 @@
|
|||
let pendingProtagonistId = $state<string | null>(null);
|
||||
let previousRelationshipLabel = $state('');
|
||||
let swapError = $state<string | null>(null);
|
||||
|
||||
// Portrait state
|
||||
let uploadingPortraitId = $state<string | null>(null);
|
||||
let generatingPortraitId = $state<string | null>(null);
|
||||
let portraitError = $state<string | null>(null);
|
||||
let editPortrait = $state<string | null>(null);
|
||||
let expandedPortrait = $state<{ src: string; name: string } | null>(null);
|
||||
const currentProtagonistName = $derived.by(() => (
|
||||
story.characters.find(c => c.relationship === 'self')?.name ?? 'current'
|
||||
));
|
||||
|
|
@ -39,6 +50,8 @@
|
|||
editStatus = character.status;
|
||||
editTraits = character.traits.join(', ');
|
||||
editVisualDescriptors = (character.visualDescriptors ?? []).join(', ');
|
||||
editPortrait = character.portrait;
|
||||
portraitError = null;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
|
|
@ -49,6 +62,8 @@
|
|||
editTraits = '';
|
||||
editVisualDescriptors = '';
|
||||
editStatus = 'active';
|
||||
editPortrait = null;
|
||||
portraitError = null;
|
||||
}
|
||||
|
||||
async function saveEdit(character: Character) {
|
||||
|
|
@ -72,6 +87,7 @@
|
|||
status: editStatus,
|
||||
traits,
|
||||
visualDescriptors,
|
||||
portrait: editPortrait,
|
||||
});
|
||||
|
||||
cancelEdit();
|
||||
|
|
@ -126,6 +142,133 @@
|
|||
default: return 'text-surface-400';
|
||||
}
|
||||
}
|
||||
|
||||
// Portrait handling functions
|
||||
async function handlePortraitUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !editingId) return;
|
||||
|
||||
uploadingPortraitId = editingId;
|
||||
portraitError = null;
|
||||
|
||||
try {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Please select an image file');
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
throw new Error('Image must be smaller than 5MB');
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== 'string' || !result.startsWith('data:image/')) {
|
||||
reject(new Error('Failed to read image data'));
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
editPortrait = dataUrl;
|
||||
} catch (error) {
|
||||
portraitError = error instanceof Error ? error.message : 'Failed to upload portrait';
|
||||
} finally {
|
||||
uploadingPortraitId = null;
|
||||
// Reset input
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePortrait(character: Character) {
|
||||
const imageSettings = settings.systemServicesSettings.imageGeneration;
|
||||
|
||||
// Validate requirements
|
||||
if (!imageSettings.nanoGptApiKey) {
|
||||
portraitError = 'NanoGPT API key required for portrait generation';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get visual descriptors from current edit state or character
|
||||
const descriptors = editVisualDescriptors
|
||||
.split(',')
|
||||
.map(d => d.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (descriptors.length === 0) {
|
||||
portraitError = 'Add appearance descriptors first';
|
||||
return;
|
||||
}
|
||||
|
||||
generatingPortraitId = character.id;
|
||||
portraitError = null;
|
||||
|
||||
try {
|
||||
// Get the style prompt
|
||||
const styleId = imageSettings.styleId;
|
||||
let stylePrompt = '';
|
||||
try {
|
||||
const promptContext = {
|
||||
mode: 'adventure' as const,
|
||||
pov: 'second' as const,
|
||||
tense: 'present' as const,
|
||||
protagonistName: '',
|
||||
};
|
||||
stylePrompt = promptService.getPrompt(styleId, promptContext) || '';
|
||||
} catch {
|
||||
// Use default style
|
||||
stylePrompt = 'Soft cel-shaded anime illustration. Muted pastel color palette with low saturation. Dreamy, airy atmosphere.';
|
||||
}
|
||||
|
||||
// Build the portrait generation prompt using the template
|
||||
const promptContext = {
|
||||
mode: 'adventure' as const,
|
||||
pov: 'second' as const,
|
||||
tense: 'present' as const,
|
||||
protagonistName: '',
|
||||
};
|
||||
|
||||
const portraitPrompt = promptService.renderPrompt('image-portrait-generation', promptContext, {
|
||||
imageStylePrompt: stylePrompt,
|
||||
visualDescriptors: descriptors.join(', '),
|
||||
characterName: editName || character.name,
|
||||
});
|
||||
|
||||
// Create the image provider
|
||||
const provider = new NanoGPTImageProvider(imageSettings.nanoGptApiKey);
|
||||
|
||||
// Generate the image
|
||||
const response = await provider.generateImage({
|
||||
prompt: portraitPrompt,
|
||||
model: imageSettings.portraitModel || 'z-image-turbo',
|
||||
size: '1024x1024',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
|
||||
if (response.images.length === 0 || !response.images[0].b64_json) {
|
||||
throw new Error('No image data returned');
|
||||
}
|
||||
|
||||
editPortrait = `data:image/png;base64,${response.images[0].b64_json}`;
|
||||
} catch (error) {
|
||||
portraitError = error instanceof Error ? error.message : 'Failed to generate portrait';
|
||||
} finally {
|
||||
generatingPortraitId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function removePortrait() {
|
||||
editPortrait = null;
|
||||
portraitError = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
|
|
@ -183,9 +326,22 @@
|
|||
<div class="card p-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-2">
|
||||
<div class="rounded-full bg-surface-700 p-1.5 {getStatusColor(character.status)}">
|
||||
<StatusIcon class="h-4 w-4" />
|
||||
</div>
|
||||
{#if character.portrait}
|
||||
<button
|
||||
class="flex-shrink-0 cursor-pointer"
|
||||
onclick={() => expandedPortrait = { src: normalizeImageDataUrl(character.portrait) ?? '', name: character.name }}
|
||||
>
|
||||
<img
|
||||
src={normalizeImageDataUrl(character.portrait) ?? ''}
|
||||
alt="{character.name} portrait"
|
||||
class="h-10 w-10 rounded-lg object-cover ring-1 ring-surface-600 hover:ring-2 hover:ring-accent-500 transition-all"
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="rounded-full bg-surface-700 p-1.5 {getStatusColor(character.status)}">
|
||||
<StatusIcon class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="break-words font-medium text-surface-100">{character.name}</span>
|
||||
|
|
@ -324,6 +480,76 @@
|
|||
class="input text-sm"
|
||||
rows="2"
|
||||
></textarea>
|
||||
|
||||
<!-- Portrait Section -->
|
||||
<div class="rounded-md border border-surface-700/60 bg-surface-800/40 p-3">
|
||||
<div class="mb-2 text-xs font-medium text-surface-400">Portrait</div>
|
||||
<div class="flex items-start gap-3">
|
||||
{#if editPortrait}
|
||||
<div class="relative">
|
||||
<img
|
||||
src={normalizeImageDataUrl(editPortrait) ?? ''}
|
||||
alt="Portrait preview"
|
||||
class="h-20 w-20 rounded-lg object-cover ring-1 ring-surface-600"
|
||||
/>
|
||||
<button
|
||||
class="absolute -right-2 -top-2 flex h-7 w-7 items-center justify-center rounded-full bg-red-500 text-white hover:bg-red-600 sm:-right-1 sm:-top-1 sm:h-5 sm:w-5"
|
||||
onclick={removePortrait}
|
||||
title="Remove portrait"
|
||||
>
|
||||
<X class="h-4 w-4 sm:h-3 sm:w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-surface-600 bg-surface-800">
|
||||
<User class="h-8 w-8 text-surface-600" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<label class="btn btn-secondary text-xs min-h-[44px] sm:min-h-0 cursor-pointer">
|
||||
{#if uploadingPortraitId === character.id}
|
||||
<Loader2 class="h-4 w-4 sm:h-3 sm:w-3 animate-spin" />
|
||||
Uploading...
|
||||
{:else}
|
||||
<ImageUp class="h-4 w-4 sm:h-3 sm:w-3" />
|
||||
Upload
|
||||
{/if}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={handlePortraitUpload}
|
||||
disabled={uploadingPortraitId !== null || generatingPortraitId !== null}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-secondary text-xs min-h-[44px] sm:min-h-0"
|
||||
onclick={() => generatePortrait(character)}
|
||||
disabled={generatingPortraitId !== null || uploadingPortraitId !== null || !editVisualDescriptors.trim()}
|
||||
title={!editVisualDescriptors.trim() ? 'Add appearance descriptors first' : 'Generate portrait from appearance'}
|
||||
>
|
||||
{#if generatingPortraitId === character.id}
|
||||
<Loader2 class="h-4 w-4 sm:h-3 sm:w-3 animate-spin" />
|
||||
Generating...
|
||||
{:else}
|
||||
<Wand2 class="h-4 w-4 sm:h-3 sm:w-3" />
|
||||
Generate
|
||||
{/if}
|
||||
</button>
|
||||
<p class="text-xs text-surface-500">
|
||||
{#if editPortrait}
|
||||
Portrait will be used as reference for image generation
|
||||
{:else}
|
||||
Upload or generate a portrait from appearance
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if portraitError}
|
||||
<p class="mt-2 text-xs text-red-400">{portraitError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary text-xs" onclick={cancelEdit}>
|
||||
Cancel
|
||||
|
|
@ -343,3 +569,29 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Expanded Portrait Modal -->
|
||||
{#if expandedPortrait}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 cursor-pointer"
|
||||
onclick={() => expandedPortrait = null}
|
||||
onkeydown={(e) => e.key === 'Escape' && (expandedPortrait = null)}
|
||||
role="dialog"
|
||||
aria-label="Expanded portrait"
|
||||
>
|
||||
<div class="relative max-h-[80vh] max-w-[80vw]">
|
||||
<img
|
||||
src={expandedPortrait.src}
|
||||
alt="{expandedPortrait.name} portrait"
|
||||
class="max-h-[80vh] max-w-[80vw] rounded-lg object-contain"
|
||||
/>
|
||||
<button
|
||||
class="absolute -right-3 -top-3 min-h-[44px] min-w-[44px] flex items-center justify-center rounded-full bg-surface-700 text-surface-300 hover:bg-surface-600 hover:text-white sm:-right-2 sm:-top-2 sm:min-h-0 sm:min-w-0 sm:p-1.5"
|
||||
onclick={(e) => { e.stopPropagation(); expandedPortrait = null; }}
|
||||
>
|
||||
<X class="h-5 w-5 sm:h-4 sm:w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import { NanoGPTImageProvider } from './nanoGPTImageProvider';
|
|||
import { database } from '$lib/services/database';
|
||||
import { promptService } from '$lib/services/prompts';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { emitImageQueued, emitImageReady } from '$lib/services/events';
|
||||
import { normalizeImageDataUrl } from '$lib/utils/image';
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
|
|
@ -80,11 +82,21 @@ export class ImageGenerationService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if portrait mode is enabled
|
||||
const portraitMode = imageSettings.portraitMode ?? false;
|
||||
|
||||
// Build list of character names that have portraits
|
||||
const charactersWithPortraits = context.presentCharacters
|
||||
.filter(c => c.portrait)
|
||||
.map(c => c.name);
|
||||
|
||||
log('Starting image generation', {
|
||||
storyId: context.storyId,
|
||||
entryId: context.entryId,
|
||||
narrativeLength: context.narrativeResponse.length,
|
||||
presentCharacters: context.presentCharacters.length,
|
||||
portraitMode,
|
||||
charactersWithPortraits,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -112,6 +124,8 @@ export class ImageGenerationService {
|
|||
maxImages,
|
||||
chatHistory: context.chatHistory,
|
||||
lorebookContext: context.lorebookContext,
|
||||
charactersWithPortraits,
|
||||
portraitMode,
|
||||
};
|
||||
|
||||
// Identify imageable scenes
|
||||
|
|
@ -120,6 +134,7 @@ export class ImageGenerationService {
|
|||
log('Scenes identified', {
|
||||
count: scenes.length,
|
||||
types: scenes.map(s => s.sceneType),
|
||||
characters: scenes.map(s => s.character),
|
||||
});
|
||||
|
||||
if (scenes.length === 0) {
|
||||
|
|
@ -144,7 +159,8 @@ export class ImageGenerationService {
|
|||
context.storyId,
|
||||
context.entryId,
|
||||
scene,
|
||||
imageSettings
|
||||
imageSettings,
|
||||
context.presentCharacters
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -192,10 +208,52 @@ export class ImageGenerationService {
|
|||
storyId: string,
|
||||
entryId: string,
|
||||
scene: ImageableScene,
|
||||
imageSettings: typeof settings.systemServicesSettings.imageGeneration
|
||||
imageSettings: typeof settings.systemServicesSettings.imageGeneration,
|
||||
presentCharacters: Character[]
|
||||
): Promise<void> {
|
||||
// Handle portrait generation for new characters
|
||||
if (scene.generatePortrait && scene.character) {
|
||||
log('Generating portrait for new character', { character: scene.character });
|
||||
await this.generateCharacterPortrait(
|
||||
storyId,
|
||||
scene.character,
|
||||
scene.prompt,
|
||||
imageSettings,
|
||||
presentCharacters
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageId = crypto.randomUUID();
|
||||
|
||||
// Determine if we should use a reference image
|
||||
let referenceImageUrls: string[] | undefined;
|
||||
let modelToUse = imageSettings.model;
|
||||
|
||||
// If portrait mode is enabled and scene has a character, look for their portrait
|
||||
if (imageSettings.portraitMode && scene.character) {
|
||||
const character = presentCharacters.find(
|
||||
c => c.name.toLowerCase() === scene.character!.toLowerCase()
|
||||
);
|
||||
|
||||
const portraitUrl = normalizeImageDataUrl(character?.portrait);
|
||||
if (portraitUrl) {
|
||||
// Use reference model (default: qwen-image) and attach portrait
|
||||
modelToUse = imageSettings.referenceModel || 'qwen-image';
|
||||
referenceImageUrls = [portraitUrl];
|
||||
log('Using character portrait as reference', {
|
||||
character: scene.character,
|
||||
model: modelToUse,
|
||||
});
|
||||
} else {
|
||||
// In portrait mode, skip scene images for characters without portraits
|
||||
log('Skipping scene - character has no portrait in portrait mode', {
|
||||
character: scene.character,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create pending record in database
|
||||
const embeddedImage: Omit<EmbeddedImage, 'createdAt'> = {
|
||||
id: imageId,
|
||||
|
|
@ -204,7 +262,7 @@ export class ImageGenerationService {
|
|||
sourceText: scene.sourceText,
|
||||
prompt: scene.prompt,
|
||||
styleId: imageSettings.styleId,
|
||||
model: imageSettings.model,
|
||||
model: modelToUse,
|
||||
imageData: '',
|
||||
width: imageSettings.size === '1024x1024' ? 1024 : 512,
|
||||
height: imageSettings.size === '1024x1024' ? 1024 : 512,
|
||||
|
|
@ -212,17 +270,88 @@ export class ImageGenerationService {
|
|||
};
|
||||
|
||||
await database.createEmbeddedImage(embeddedImage);
|
||||
log('Created pending image record', { imageId, sourceText: scene.sourceText });
|
||||
log('Created pending image record', { imageId, sourceText: scene.sourceText, model: modelToUse });
|
||||
|
||||
// Emit queued event
|
||||
emitImageQueued(imageId, entryId);
|
||||
|
||||
// Start async generation (fire-and-forget)
|
||||
this.generateImage(imageId, scene.prompt, imageSettings, entryId).catch(error => {
|
||||
this.generateImage(imageId, scene.prompt, imageSettings, entryId, modelToUse, referenceImageUrls).catch(error => {
|
||||
log('Async image generation failed', { imageId, error });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a portrait for a character and save it to their profile
|
||||
*/
|
||||
private async generateCharacterPortrait(
|
||||
storyId: string,
|
||||
characterName: string,
|
||||
prompt: string,
|
||||
imageSettings: typeof settings.systemServicesSettings.imageGeneration,
|
||||
presentCharacters: Character[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Find the character
|
||||
const character = presentCharacters.find(
|
||||
c => c.name.toLowerCase() === characterName.toLowerCase()
|
||||
);
|
||||
|
||||
if (!character) {
|
||||
log('Character not found for portrait generation', { characterName });
|
||||
return;
|
||||
}
|
||||
|
||||
if (character.portrait) {
|
||||
log('Character already has portrait, skipping', { characterName });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get API key from settings
|
||||
const apiKey = imageSettings.nanoGptApiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error('No NanoGPT API key configured for portrait generation');
|
||||
}
|
||||
|
||||
// Create provider if needed
|
||||
if (!this.imageProvider) {
|
||||
this.imageProvider = new NanoGPTImageProvider(apiKey);
|
||||
}
|
||||
|
||||
log('Generating portrait', { characterName, model: imageSettings.portraitModel });
|
||||
|
||||
// Generate portrait using portrait model
|
||||
const response = await this.imageProvider.generateImage({
|
||||
prompt,
|
||||
model: imageSettings.portraitModel || 'z-image-turbo',
|
||||
size: '1024x1024',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
|
||||
if (response.images.length === 0 || !response.images[0].b64_json) {
|
||||
throw new Error('No image data returned for portrait');
|
||||
}
|
||||
|
||||
const portraitDataUrl = `data:image/png;base64,${response.images[0].b64_json}`;
|
||||
|
||||
// Update character with portrait
|
||||
await database.updateCharacter(character.id, {
|
||||
portrait: portraitDataUrl,
|
||||
});
|
||||
|
||||
if (story.currentStory?.id === storyId) {
|
||||
story.characters = story.characters.map(c =>
|
||||
c.id === character.id ? { ...c, portrait: portraitDataUrl } : c
|
||||
);
|
||||
}
|
||||
|
||||
log('Portrait generated and saved', { characterName, characterId: character.id });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
log('Portrait generation failed', { characterName, error: errorMessage });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single image (runs asynchronously)
|
||||
*/
|
||||
|
|
@ -230,7 +359,9 @@ export class ImageGenerationService {
|
|||
imageId: string,
|
||||
prompt: string,
|
||||
imageSettings: typeof settings.systemServicesSettings.imageGeneration,
|
||||
entryId: string
|
||||
entryId: string,
|
||||
modelOverride?: string,
|
||||
referenceImageUrls?: string[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Update status to generating
|
||||
|
|
@ -250,9 +381,10 @@ export class ImageGenerationService {
|
|||
// Generate image
|
||||
const response = await this.imageProvider.generateImage({
|
||||
prompt,
|
||||
model: imageSettings.model,
|
||||
model: modelOverride || imageSettings.model,
|
||||
size: imageSettings.size,
|
||||
response_format: 'b64_json',
|
||||
imageDataUrls: referenceImageUrls,
|
||||
});
|
||||
|
||||
if (response.images.length === 0 || !response.images[0].b64_json) {
|
||||
|
|
@ -265,7 +397,7 @@ export class ImageGenerationService {
|
|||
status: 'complete',
|
||||
});
|
||||
|
||||
log('Image generated successfully', { imageId });
|
||||
log('Image generated successfully', { imageId, hasReference: !!referenceImageUrls });
|
||||
|
||||
// Emit ready event
|
||||
emitImageReady(imageId, entryId, true);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export interface ImageableScene {
|
|||
sceneType: 'action' | 'item' | 'character' | 'environment';
|
||||
/** Priority 1-10, higher = more important */
|
||||
priority: number;
|
||||
/** Character name if this scene depicts a specific character with a portrait, or null */
|
||||
character: string | null;
|
||||
/** If true, generate a portrait for this character (portrait mode only) */
|
||||
generatePortrait: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +52,10 @@ export interface ImagePromptContext {
|
|||
chatHistory?: string;
|
||||
/** Activated lorebook entries for world context */
|
||||
lorebookContext?: string;
|
||||
/** Names of characters that have portrait images available */
|
||||
charactersWithPortraits: string[];
|
||||
/** Whether to use portrait reference mode (simplified prompts for characters with portraits) */
|
||||
portraitMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,12 +100,20 @@ export class ImagePromptService {
|
|||
*/
|
||||
async identifyScenes(context: ImagePromptContext): Promise<ImageableScene[]> {
|
||||
if (this.debug) {
|
||||
console.log('[ImagePrompt] Analyzing narrative for imageable scenes');
|
||||
console.log('[ImagePrompt] Analyzing narrative for imageable scenes', {
|
||||
portraitMode: context.portraitMode,
|
||||
charactersWithPortraits: context.charactersWithPortraits,
|
||||
});
|
||||
}
|
||||
|
||||
// Build character descriptors string
|
||||
const characterDescriptors = this.buildCharacterDescriptors(context.presentCharacters);
|
||||
|
||||
// Build list of characters with portraits for the prompt
|
||||
const charactersWithPortraitsStr = context.charactersWithPortraits.length > 0
|
||||
? context.charactersWithPortraits.join(', ')
|
||||
: 'None';
|
||||
|
||||
const promptContext = {
|
||||
mode: 'adventure' as const,
|
||||
pov: 'second' as const,
|
||||
|
|
@ -105,15 +121,21 @@ export class ImagePromptService {
|
|||
protagonistName: '',
|
||||
};
|
||||
|
||||
// Select template based on portrait mode
|
||||
const templateId = context.portraitMode
|
||||
? 'image-prompt-analysis-reference'
|
||||
: 'image-prompt-analysis';
|
||||
|
||||
// Build the system prompt with style and character info
|
||||
const systemPrompt = promptService.renderPrompt('image-prompt-analysis', promptContext, {
|
||||
const systemPrompt = promptService.renderPrompt(templateId, promptContext, {
|
||||
imageStylePrompt: context.stylePrompt,
|
||||
characterDescriptors: characterDescriptors || 'No character visual descriptors available.',
|
||||
charactersWithPortraits: charactersWithPortraitsStr,
|
||||
maxImages: context.maxImages === 0 ? '0 (unlimited)' : String(context.maxImages),
|
||||
});
|
||||
|
||||
// Build the user prompt with full context
|
||||
const userPrompt = promptService.renderUserPrompt('image-prompt-analysis', promptContext, {
|
||||
const userPrompt = promptService.renderUserPrompt(templateId, promptContext, {
|
||||
narrativeResponse: context.narrativeResponse,
|
||||
userAction: context.userAction,
|
||||
chatHistory: context.chatHistory || '',
|
||||
|
|
@ -203,6 +225,8 @@ export class ImagePromptService {
|
|||
sourceText: String(item.sourceText),
|
||||
sceneType: this.normalizeSceneType(item.sceneType),
|
||||
priority: Math.min(10, Math.max(1, Number(item.priority) || 5)),
|
||||
character: this.normalizeCharacter(item.character),
|
||||
generatePortrait: Boolean(item.generatePortrait),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
|
|
@ -212,6 +236,20 @@ export class ImagePromptService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize character field - returns character name or null.
|
||||
*/
|
||||
private normalizeCharacter(character: unknown): string | null {
|
||||
if (!character || typeof character !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = character.trim().toLowerCase();
|
||||
if (normalized === 'none' || normalized === '' || normalized === 'null') {
|
||||
return null;
|
||||
}
|
||||
return character.trim(); // Return original casing
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a parsed item has required fields.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface ImageGenerationRequest {
|
|||
n?: number;
|
||||
size?: string;
|
||||
response_format?: 'b64_json' | 'url';
|
||||
imageDataUrls?: string[]; // Reference images for image-to-image generation (e.g., character portraits)
|
||||
}
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
|
|
|
|||
|
|
@ -40,10 +40,11 @@ export class NanoGPTImageProvider implements ImageProvider {
|
|||
size: request.size,
|
||||
n: request.n,
|
||||
promptLength: request.prompt.length,
|
||||
hasReferenceImages: !!request.imageDataUrls?.length,
|
||||
});
|
||||
}
|
||||
|
||||
const body = {
|
||||
const body: Record<string, unknown> = {
|
||||
prompt: request.prompt,
|
||||
model: request.model || FALLBACK_MODEL,
|
||||
n: request.n ?? 1,
|
||||
|
|
@ -51,6 +52,11 @@ export class NanoGPTImageProvider implements ImageProvider {
|
|||
response_format: request.response_format ?? 'b64_json',
|
||||
};
|
||||
|
||||
// Add reference images if provided (for image-to-image generation with qwen-image model)
|
||||
if (request.imageDataUrls && request.imageDataUrls.length > 0) {
|
||||
body.imageDataUrls = request.imageDataUrls;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(NANOGPT_IMAGES_ENDPOINT, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -325,8 +325,8 @@ class DatabaseService {
|
|||
async addCharacter(character: Character): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
`INSERT INTO characters (id, story_id, name, description, relationship, traits, visual_descriptors, status, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO characters (id, story_id, name, description, relationship, traits, visual_descriptors, portrait, status, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
character.id,
|
||||
character.storyId,
|
||||
|
|
@ -335,6 +335,7 @@ class DatabaseService {
|
|||
character.relationship,
|
||||
JSON.stringify(character.traits),
|
||||
JSON.stringify(character.visualDescriptors || []),
|
||||
character.portrait || null,
|
||||
character.status,
|
||||
character.metadata ? JSON.stringify(character.metadata) : null,
|
||||
]
|
||||
|
|
@ -351,6 +352,7 @@ class DatabaseService {
|
|||
if (updates.relationship !== undefined) { setClauses.push('relationship = ?'); values.push(updates.relationship); }
|
||||
if (updates.traits !== undefined) { setClauses.push('traits = ?'); values.push(JSON.stringify(updates.traits)); }
|
||||
if (updates.visualDescriptors !== undefined) { setClauses.push('visual_descriptors = ?'); values.push(JSON.stringify(updates.visualDescriptors)); }
|
||||
if (updates.portrait !== undefined) { setClauses.push('portrait = ?'); values.push(updates.portrait); }
|
||||
if (updates.status !== undefined) { setClauses.push('status = ?'); values.push(updates.status); }
|
||||
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
|
||||
|
||||
|
|
@ -1113,6 +1115,7 @@ class DatabaseService {
|
|||
relationship: row.relationship,
|
||||
traits: row.traits ? JSON.parse(row.traits) : [],
|
||||
visualDescriptors: row.visual_descriptors ? JSON.parse(row.visual_descriptors) : [],
|
||||
portrait: row.portrait || null,
|
||||
status: row.status,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ export interface AventuraExport {
|
|||
// v1.2.0 - Added styleReviewState
|
||||
// v1.3.0 - Added timeTracker to story, entry metadata (timeStart/timeEnd)
|
||||
// v1.4.0 - Added embeddedImages (generated images embedded in story entries)
|
||||
// v1.5.0 - Added character portraits
|
||||
|
||||
class ExportService {
|
||||
private readonly VERSION = '1.4.0';
|
||||
private readonly VERSION = '1.5.0';
|
||||
|
||||
/**
|
||||
* Compare semantic versions. Returns:
|
||||
|
|
@ -61,6 +62,9 @@ class ExportService {
|
|||
if (this.compareVersions(importVersion, '1.4.0') < 0) {
|
||||
console.warn(`[Import] File from v${importVersion} predates embedded images (v1.4.0). Generated images will not be restored.`);
|
||||
}
|
||||
if (this.compareVersions(importVersion, '1.5.0') < 0) {
|
||||
console.warn(`[Import] File from v${importVersion} predates character portraits (v1.5.0). Character portraits will not be restored.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export to Aventura format (.avt - JSON)
|
||||
|
|
@ -315,6 +319,7 @@ class ExportService {
|
|||
status: char.status,
|
||||
metadata: char.metadata,
|
||||
visualDescriptors: char.visualDescriptors ?? [],
|
||||
portrait: char.portrait ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1633,7 +1633,7 @@ const photorealisticStyleTemplate: PromptTemplate = {
|
|||
content: `Photorealistic digital art with true-to-life rendering. Natural lighting with accurate shadows and highlights. Detailed textures on skin, fabric, and materials. Accurate human proportions and anatomy. Professional photography aesthetic with cinematic depth of field. High dynamic range with realistic contrast. Detailed environments with accurate perspective. Materials rendered with proper reflectance and subsurface scattering where appropriate. Film grain optional for cinematic feel. 8K quality, hyperrealistic detail.`,
|
||||
};
|
||||
|
||||
// Image prompt analysis service template
|
||||
// Image prompt analysis service template (legacy mode - full character descriptions)
|
||||
const imagePromptAnalysisTemplate: PromptTemplate = {
|
||||
id: 'image-prompt-analysis',
|
||||
name: 'Image Prompt Analysis',
|
||||
|
|
@ -1642,7 +1642,7 @@ const imagePromptAnalysisTemplate: PromptTemplate = {
|
|||
content: `You identify visually striking moments in narrative text for image generation.
|
||||
|
||||
## Your Task
|
||||
Analyze the narrative and identify 0-{{maxImages}} key visual moments. Create DETAILED, descriptive image prompts (aim for 500-800 characters each). **Do NOT exceed 800 characters per prompt - prompts over 800 characters will cause an error and fail to generate.**
|
||||
Analyze the narrative and identify 0-{{maxImages}} key visual moments (0 = unlimited). Create DETAILED, descriptive image prompts (aim for 500-800 characters each). **Do NOT exceed 800 characters per prompt - prompts over 800 characters will cause an error and fail to generate.**
|
||||
|
||||
## Style (MUST include in every prompt)
|
||||
{{imageStylePrompt}}
|
||||
|
|
@ -1706,6 +1706,136 @@ Return a JSON array (no markdown, just raw JSON):
|
|||
Identify the most visually striking moments and return the JSON array.`,
|
||||
};
|
||||
|
||||
// Image prompt analysis with reference images (portrait mode - simplified prompts)
|
||||
const imagePromptAnalysisReferenceTemplate: PromptTemplate = {
|
||||
id: 'image-prompt-analysis-reference',
|
||||
name: 'Image Prompt Analysis (Reference Mode)',
|
||||
category: 'service',
|
||||
description: 'Identifies imageable scenes for generation with character reference images',
|
||||
content: `You identify visually striking moments in narrative text for image generation WITH REFERENCE IMAGES.
|
||||
|
||||
## Your Task
|
||||
Analyze the narrative and identify 0-{{maxImages}} key visual moments (0 = unlimited). Create image prompts (aim for 400-600 characters each). **Do NOT exceed 600 characters per prompt.**
|
||||
|
||||
IMPORTANT: In portrait mode, ONLY characters with portraits can be depicted in images. Characters without portraits CANNOT appear in generated images until they have a portrait.
|
||||
|
||||
## Style (MUST include in every prompt)
|
||||
{{imageStylePrompt}}
|
||||
|
||||
## Characters With Portraits (CAN be depicted)
|
||||
{{charactersWithPortraits}}
|
||||
|
||||
ONLY these characters can appear in images. When depicting them, do NOT describe their appearance - the reference image provides that. Focus on action, pose, and environment.
|
||||
|
||||
## Character Visual Descriptors (for portrait generation reference)
|
||||
{{characterDescriptors}}
|
||||
|
||||
## Output Format
|
||||
Return a JSON array (no markdown, just raw JSON):
|
||||
[
|
||||
{
|
||||
"prompt": "Prompt (400-600 chars) - for characters with portraits, focus on action/pose/environment, NOT appearance",
|
||||
"sourceText": "exact phrase from narrative (3-15 words, VERBATIM)",
|
||||
"sceneType": "action|item|character|environment",
|
||||
"priority": 1-10,
|
||||
"character": "CharacterName or none",
|
||||
"generatePortrait": false
|
||||
}
|
||||
]
|
||||
|
||||
## generatePortrait Field - Creating Portraits for New Characters
|
||||
When a NEW named character is introduced who does NOT have a portrait yet (not in "Characters With Portraits" list):
|
||||
- You MAY request portrait generation by setting "generatePortrait": true
|
||||
- This will create a portrait for them so they CAN be depicted in FUTURE images
|
||||
- CRITICAL: You CANNOT use that portrait in the same output - it won't exist until the next turn
|
||||
- When generatePortrait is true: create a portrait-style prompt (head/shoulders, neutral background)
|
||||
- For portrait prompts: use the character's visual descriptors to describe their appearance
|
||||
|
||||
## Prompt Structure (for scenes WITH character reference)
|
||||
1. **"The character"** - Do NOT describe appearance, the reference image provides this
|
||||
2. **Action/pose** - what they're doing, body position, expression (if dramatic)
|
||||
3. **Setting/environment** - where they are, lighting, atmosphere, background details
|
||||
4. **Style keywords** - copy relevant phrases from the Style section
|
||||
|
||||
## Prompt Structure (for portrait generation - generatePortrait: true)
|
||||
1. **Character appearance** - use visual descriptors to describe hair, eyes, skin, features
|
||||
2. **Expression** - neutral or slight smile
|
||||
3. **Framing** - head and shoulders, portrait composition
|
||||
4. **Background** - simple, neutral (gradient or soft bokeh)
|
||||
5. **Style keywords** - from the Style section
|
||||
|
||||
## Example Outputs
|
||||
|
||||
**Scene with existing portrait:**
|
||||
{
|
||||
"prompt": "The character wielding a glowing sword in defensive stance. Rain-soaked alley at night, neon reflections. Dramatic backlighting. Semi-realistic anime style.",
|
||||
"sourceText": "gripped her sword tightly",
|
||||
"sceneType": "action",
|
||||
"priority": 8,
|
||||
"character": "Elena",
|
||||
"generatePortrait": false
|
||||
}
|
||||
|
||||
**Creating portrait for NEW character:**
|
||||
{
|
||||
"prompt": "Portrait of a tall man with short grey hair and weathered face, deep-set brown eyes with crow's feet. Strong jaw, slight stubble. Wearing dark leather armor. Head and shoulders, neutral background with soft blue gradient. Semi-realistic anime style, refined features.",
|
||||
"sourceText": "the old mercenary stepped forward",
|
||||
"sceneType": "character",
|
||||
"priority": 7,
|
||||
"character": "Marcus",
|
||||
"generatePortrait": true
|
||||
}
|
||||
|
||||
**Environment (no character):**
|
||||
{
|
||||
"prompt": "Ancient library with towering bookshelves, dust motes in shafts of golden light through stained glass windows. Soft anime style, atmospheric.",
|
||||
"sourceText": "the vast library stretched before them",
|
||||
"sceneType": "environment",
|
||||
"priority": 5,
|
||||
"character": "none",
|
||||
"generatePortrait": false
|
||||
}
|
||||
|
||||
## CRITICAL Rules
|
||||
1. **ONLY depict characters WITH portraits** - characters without portraits CANNOT appear in scene images
|
||||
2. **ONE CHARACTER PER IMAGE** - only depict a single character per prompt
|
||||
3. **For characters WITH portraits** - do NOT describe appearance, just action/pose/environment
|
||||
4. **generatePortrait** - use to CREATE portraits for new characters (cannot use same turn)
|
||||
5. **ALWAYS include style** - copy style keywords from the Style section
|
||||
6. **Stay under 600 characters**
|
||||
7. **sourceText** MUST be COPY-PASTED EXACTLY from the narrative
|
||||
8. Return empty array [] if no suitable visual moments exist
|
||||
|
||||
## Priority Guidelines
|
||||
- 8-10: Dramatic actions, combat, pivotal moments
|
||||
- 6-8: Significant items, magical effects, character introductions worth generating a portrait for
|
||||
- 5-7: Character emotions, reveals
|
||||
- 3-5: Environmental shots, atmosphere`,
|
||||
userContent: `## Story Context
|
||||
{{chatHistory}}
|
||||
|
||||
{{lorebookContext}}
|
||||
|
||||
## User Action
|
||||
{{userAction}}
|
||||
|
||||
## Narrative to Analyze
|
||||
{{narrativeResponse}}
|
||||
|
||||
Identify the most visually striking moments and return the JSON array. Remember: only characters with portraits can be depicted in scene images. Use generatePortrait to create portraits for important new characters.`,
|
||||
};
|
||||
|
||||
// Portrait generation template - direct image prompt (not LLM instructions)
|
||||
// This template is rendered and sent directly to the image generation API
|
||||
const imagePortraitGenerationTemplate: PromptTemplate = {
|
||||
id: 'image-portrait-generation',
|
||||
name: 'Portrait Generation',
|
||||
category: 'service',
|
||||
description: 'Direct image prompt template for character portraits',
|
||||
content: `Full body portrait of a character: {{visualDescriptors}}. Standing in a relaxed natural pose, facing the viewer, full body visible from head to feet. Neutral expression or slight smile. Simple gradient background, non-distracting. Portrait composition, centered framing, professional lighting. {{imageStylePrompt}}`,
|
||||
userContent: '',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMBINED PROMPT TEMPLATES
|
||||
// ============================================================================
|
||||
|
|
@ -1728,6 +1858,8 @@ export const PROMPT_TEMPLATES: PromptTemplate[] = [
|
|||
agenticRetrievalPromptTemplate,
|
||||
characterCardImportPromptTemplate,
|
||||
imagePromptAnalysisTemplate,
|
||||
imagePromptAnalysisReferenceTemplate,
|
||||
imagePortraitGenerationTemplate,
|
||||
// Wizard prompts
|
||||
settingExpansionPromptTemplate,
|
||||
protagonistGenerationPromptTemplate,
|
||||
|
|
|
|||
|
|
@ -862,6 +862,11 @@ export interface ImageGenerationServiceSettings {
|
|||
maxImagesPerMessage: number; // Max images per narrative (0 = unlimited, default: 3)
|
||||
autoGenerate: boolean; // Generate automatically after narration
|
||||
|
||||
// Portrait mode settings (character reference images)
|
||||
portraitMode: boolean; // Enable portrait reference mode (default: false)
|
||||
portraitModel: string; // Model for generating character portraits (default: 'z-image-turbo')
|
||||
referenceModel: string; // Model for image generation with reference (default: 'qwen-image')
|
||||
|
||||
// Scene analysis model settings (for identifying imageable scenes)
|
||||
promptProfileId: string | null; // API profile for scene analysis
|
||||
promptModel: string; // Model for scene analysis (empty = use profile default)
|
||||
|
|
@ -881,6 +886,9 @@ export function getDefaultImageGenerationSettings(): ImageGenerationServiceSetti
|
|||
size: '1024x1024',
|
||||
maxImagesPerMessage: 3,
|
||||
autoGenerate: true,
|
||||
portraitMode: false,
|
||||
portraitModel: 'z-image-turbo',
|
||||
referenceModel: 'qwen-image',
|
||||
promptProfileId: DEFAULT_OPENROUTER_PROFILE_ID,
|
||||
promptModel: 'deepseek/deepseek-v3.2',
|
||||
promptTemperature: 0.3,
|
||||
|
|
@ -902,6 +910,9 @@ export function getDefaultImageGenerationSettingsForProvider(provider: ProviderP
|
|||
size: '1024x1024',
|
||||
maxImagesPerMessage: 3,
|
||||
autoGenerate: true,
|
||||
portraitMode: false,
|
||||
portraitModel: 'z-image-turbo',
|
||||
referenceModel: 'qwen-image',
|
||||
promptProfileId,
|
||||
promptModel,
|
||||
promptTemperature: 0.3,
|
||||
|
|
|
|||
|
|
@ -417,6 +417,7 @@ class StoryStore {
|
|||
status: 'active',
|
||||
metadata: null,
|
||||
visualDescriptors: [],
|
||||
portrait: null,
|
||||
};
|
||||
await database.addCharacter(protagonist);
|
||||
}
|
||||
|
|
@ -705,6 +706,7 @@ class StoryStore {
|
|||
status: 'active',
|
||||
metadata: null,
|
||||
visualDescriptors: [],
|
||||
portrait: null,
|
||||
};
|
||||
|
||||
await database.addCharacter(character);
|
||||
|
|
@ -1232,6 +1234,7 @@ class StoryStore {
|
|||
visualDescriptors: newChar.visualDescriptors || [],
|
||||
status: 'active',
|
||||
metadata: { source: 'classifier' },
|
||||
portrait: null,
|
||||
};
|
||||
await database.addCharacter(character);
|
||||
this.characters = [...this.characters, character];
|
||||
|
|
@ -1853,6 +1856,7 @@ class StoryStore {
|
|||
status: snapshot.status ?? character.status,
|
||||
relationship,
|
||||
visualDescriptors: snapshot.visualDescriptors ?? [],
|
||||
portrait: snapshot.portrait,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1876,6 +1880,7 @@ class StoryStore {
|
|||
status: snapshot.status ?? character.status,
|
||||
relationship,
|
||||
visualDescriptors: snapshot.visualDescriptors ?? character.visualDescriptors,
|
||||
portrait: snapshot.portrait ?? character.portrait,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -1953,7 +1958,8 @@ class StoryStore {
|
|||
traits: data.protagonist.traits ?? [],
|
||||
status: 'active',
|
||||
metadata: { source: 'wizard' },
|
||||
visualDescriptors: [],
|
||||
visualDescriptors: data.protagonist.visualDescriptors ?? [],
|
||||
portrait: data.protagonist.portrait ?? null,
|
||||
};
|
||||
await database.addCharacter(protagonist);
|
||||
log('Added protagonist:', protagonist.name);
|
||||
|
|
@ -2003,7 +2009,8 @@ class StoryStore {
|
|||
traits: charData.traits ?? [],
|
||||
status: 'active',
|
||||
metadata: { source: 'wizard' },
|
||||
visualDescriptors: [],
|
||||
visualDescriptors: charData.visualDescriptors ?? [],
|
||||
portrait: charData.portrait ?? null,
|
||||
};
|
||||
await database.addCharacter(character);
|
||||
log('Added supporting character:', character.name);
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ class UIStore {
|
|||
status: c.status,
|
||||
relationship: c.relationship ?? null,
|
||||
visualDescriptors: [...(c.visualDescriptors ?? [])],
|
||||
portrait: c.portrait,
|
||||
}));
|
||||
|
||||
// Create new backup and store by story ID
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export interface PersistentCharacterSnapshot {
|
|||
status: 'active' | 'inactive' | 'deceased';
|
||||
relationship: string | null;
|
||||
visualDescriptors: string[];
|
||||
portrait: string | null; // Data URL (data:image/...) or legacy base64
|
||||
}
|
||||
|
||||
// Persistent style review state - saved per-story for style analysis tracking
|
||||
|
|
@ -130,6 +131,7 @@ export interface Character {
|
|||
relationship: string | null;
|
||||
traits: string[];
|
||||
visualDescriptors: string[]; // Visual appearance details for image generation (hair, clothing, features)
|
||||
portrait: string | null; // Data URL (data:image/...) for reference in image generation
|
||||
status: 'active' | 'inactive' | 'deceased';
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
|
|
|||
10
src/lib/utils/image.ts
Normal file
10
src/lib/utils/image.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function normalizeImageDataUrl(imageData: string | null | undefined): string | null {
|
||||
if (!imageData) {
|
||||
return null;
|
||||
}
|
||||
if (imageData.startsWith('data:image/')) {
|
||||
return imageData;
|
||||
}
|
||||
// Backward compatibility: stored as raw base64 without a data URL prefix.
|
||||
return `data:image/png;base64,${imageData}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue