Add portraits

This commit is contained in:
Kurvaz 2026-01-11 16:48:52 -07:00
parent 00012f66eb
commit 733e7d9196
17 changed files with 1221 additions and 47 deletions

View 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;

View file

@ -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()

View file

@ -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

View file

@ -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 &gt; 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.

View file

@ -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}

View file

@ -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);

View file

@ -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.
*/

View file

@ -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 {

View file

@ -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',

View file

@ -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,
};

View file

@ -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,
});
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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

View file

@ -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
View 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}`;
}