summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/comic/artist.go95
-rw-r--r--internal/comic/comic_test.go56
-rw-r--r--internal/comic/generator.go33
-rw-r--r--internal/comic/image_validation.go36
-rw-r--r--internal/comic/localization.go61
-rw-r--r--internal/comic/localization_test.go34
-rw-r--r--internal/comic/narrator.go8
-rw-r--r--internal/comic/types.go10
-rw-r--r--internal/config/config_test.go12
-rw-r--r--internal/image/gemini.go13
-rw-r--r--prompts/back_cover_prompt.md10
-rw-r--r--prompts/bible_system.md3
-rw-r--r--prompts/blurb_system.md2
-rw-r--r--prompts/cover_prompt.md12
-rw-r--r--prompts/gallery_page_prompt.md4
-rw-r--r--prompts/narrator_cinematic.md8
-rw-r--r--prompts/narrator_conclusion_system.md4
-rw-r--r--prompts/narrator_intro_system.md4
-rw-r--r--prompts/panel_script_prompt.md24
-rw-r--r--prompts/story_full_prompt.md53
-rw-r--r--prompts/story_page_prompt.md12
-rw-r--r--prompts/story_prompt.md9
-rw-r--r--prompts/story_system.md3
23 files changed, 383 insertions, 123 deletions
diff --git a/internal/comic/artist.go b/internal/comic/artist.go
index 53f43f8..c58afa7 100644
--- a/internal/comic/artist.go
+++ b/internal/comic/artist.go
@@ -44,6 +44,10 @@ type Artist struct {
initErr error
}
+type referenceImageGenerator interface {
+ GenerateImageWithReferences(context.Context, string, string, [][]byte) error
+}
+
var sleep = time.Sleep
// NewArtist creates an Artist.
@@ -107,34 +111,44 @@ func (a *Artist) DrawComicPages(ctx context.Context, storyText, bible, titleSlug
}
var paths []string
- if p, err := a.renderPage(ctx, titleSlug+"_cover", coverPromptTemplate, a.coverPromptData(storyText, style, resolvedBible), "cover page"); err != nil {
+ var recentRefs [][]byte
+ if p, err := a.renderPage(ctx, titleSlug+"_cover", coverPromptTemplate, a.coverPromptData(storyText, style, resolvedBible), "cover page", nil); err != nil {
return nil, err
} else if p != "" {
paths = append(paths, p)
+ if coverBytes, readErr := os.ReadFile(p); readErr == nil {
+ recentRefs = appendRef(recentRefs, coverBytes)
+ }
}
sections := splitIntoSections(storyText, a.storyPages)
for i, section := range sections {
pageNum := i + 1
data := a.storyPagePromptData(section, pageNum, style, resolvedBible, entries, panelScript)
- if p, err := a.renderPage(ctx, fmt.Sprintf("%s_page_%d", titleSlug, pageNum), storyPagePromptTemplate, data, fmt.Sprintf("story page %d", pageNum)); err != nil {
+ if p, err := a.renderPage(ctx, fmt.Sprintf("%s_page_%d", titleSlug, pageNum), storyPagePromptTemplate, data, fmt.Sprintf("story page %d", pageNum), recentRefs); err != nil {
return nil, err
} else if p != "" {
paths = append(paths, p)
+ if pageBytes, readErr := os.ReadFile(p); readErr == nil {
+ recentRefs = appendRef(recentRefs, pageBytes)
+ }
}
}
for i := 0; i < a.galleryPages; i++ {
galleryNum := i + 1
data := a.galleryPromptData(style, resolvedBible, galleryNum)
- if p, err := a.renderPage(ctx, fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum), galleryPagePromptTemplate, data, fmt.Sprintf("gallery page %d/%d", galleryNum, a.galleryPages)); err != nil {
+ if p, err := a.renderPage(ctx, fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum), galleryPagePromptTemplate, data, fmt.Sprintf("gallery page %d/%d", galleryNum, a.galleryPages), recentRefs); err != nil {
return nil, err
} else if p != "" {
paths = append(paths, p)
+ if galleryBytes, readErr := os.ReadFile(p); readErr == nil {
+ recentRefs = appendRef(recentRefs, galleryBytes)
+ }
}
}
- if p, err := a.renderPage(ctx, titleSlug+"_back", backCoverPromptTemplate, a.backPromptData(storyText, style, resolvedBible, blurb), "back cover"); err != nil {
+ if p, err := a.renderPage(ctx, titleSlug+"_back", backCoverPromptTemplate, a.backPromptData(storyText, style, resolvedBible, blurb), "back cover", recentRefs); err != nil {
return nil, err
} else if p != "" {
paths = append(paths, p)
@@ -152,7 +166,7 @@ func (a *Artist) ready() error {
return nil
}
-func (a *Artist) renderPage(ctx context.Context, fileName, templateName string, data map[string]any, label string) (string, error) {
+func (a *Artist) renderPage(ctx context.Context, fileName, templateName string, data map[string]any, label string, refs [][]byte) (string, error) {
path := filepath.Join(a.outputDir, fileName+".png")
if _, err := os.Stat(path); err == nil {
fmt.Printf(" Skipping %s (already exists)\n", filepath.Base(path))
@@ -162,19 +176,19 @@ func (a *Artist) renderPage(ctx context.Context, fileName, templateName string,
if err != nil {
return "", fmt.Errorf("render %s prompt: %w", label, err)
}
- if err := a.generateWithRetry(ctx, prompt, path, label); err != nil {
+ if err := a.generateWithRetry(ctx, prompt, path, label, refs); err != nil {
return "", err
}
return path, nil
}
-func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, label string) error {
+func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, label string, refs [][]byte) error {
attempts := pageMaxRetries
for attempt := 1; attempt <= attempts; attempt++ {
callCtx, cancel := withTimeout(ctx, helperTimeout)
- err := a.imageProvider.GenerateImage(callCtx, prompt, outputFile)
+ err := a.generateImage(callCtx, prompt, outputFile, refs)
if err == nil {
- if leakErr := validateImagePromptLeakageFn(callCtx, outputFile, label); leakErr != nil {
+ if leakErr := validateImagePromptLeakageFn(callCtx, outputFile, label, a.script); leakErr != nil {
_ = os.Remove(outputFile)
err = leakErr
}
@@ -194,6 +208,26 @@ func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, labe
return nil
}
+func (a *Artist) generateImage(ctx context.Context, prompt, outputFile string, refs [][]byte) error {
+ if len(refs) > 0 {
+ if withRefs, ok := a.imageProvider.(referenceImageGenerator); ok {
+ return withRefs.GenerateImageWithReferences(ctx, prompt, outputFile, refs)
+ }
+ }
+ return a.imageProvider.GenerateImage(ctx, prompt, outputFile)
+}
+
+func appendRef(refs [][]byte, imgBytes []byte) [][]byte {
+ if len(imgBytes) == 0 {
+ return refs
+ }
+ refs = append(refs, imgBytes)
+ if len(refs) > 2 {
+ refs = [][]byte{refs[0], refs[len(refs)-1]}
+ }
+ return refs
+}
+
func (a *Artist) resolveHelperTexts(ctx context.Context, storyText, prebuiltBible string) (string, string, error) {
bible := strings.TrimSpace(prebuiltBible)
if bible != "" {
@@ -204,9 +238,11 @@ func (a *Artist) resolveHelperTexts(ctx context.Context, storyText, prebuiltBibl
return bible, blurb, nil
}
systemPrompt, err := a.prompts.RenderPrompt(blurbSystemTemplate, map[string]any{
- "StoryText": storyText,
- "Language": a.language,
- "Script": a.script,
+ "StoryText": storyText,
+ "Language": a.language,
+ "LanguageName": localizedLanguageName(a.language, a.script),
+ "Script": a.script,
+ "ScriptName": localizedScriptName(a.script),
})
if err != nil {
return "", "", fmt.Errorf("render blurb prompt: %w", err)
@@ -235,8 +271,10 @@ func (a *Artist) resolveHelperTexts(ctx context.Context, storyText, prebuiltBibl
func (a *Artist) coverPromptData(storyText, style, bible string) map[string]any {
return map[string]any{
"Language": a.language,
+ "LanguageName": localizedLanguageName(a.language, a.script),
"Script": a.script,
- "Style": style,
+ "ScriptName": localizedScriptName(a.script),
+ "Style": localizedStylePrompt(style, a.language, a.script),
"Bible": bible,
"Subtitle": localizedBrandName(a.language, a.script),
"StoryText": storyText,
@@ -248,8 +286,10 @@ func (a *Artist) coverPromptData(storyText, style, bible string) map[string]any
func (a *Artist) storyPagePromptData(section string, pageNum int, style, bible string, entries []WordEntry, panelScript [][]string) map[string]any {
return map[string]any{
"Language": a.language,
+ "LanguageName": localizedLanguageName(a.language, a.script),
"Script": a.script,
- "Style": style,
+ "ScriptName": localizedScriptName(a.script),
+ "Style": localizedStylePrompt(style, a.language, a.script),
"Bible": bible,
"Words": buildWordList(entries, ""),
"PageNum": pageNum,
@@ -263,8 +303,10 @@ func (a *Artist) storyPagePromptData(section string, pageNum int, style, bible s
func (a *Artist) galleryPromptData(style, bible string, galleryNum int) map[string]any {
return map[string]any{
"Language": a.language,
+ "LanguageName": localizedLanguageName(a.language, a.script),
"Script": a.script,
- "Style": style,
+ "ScriptName": localizedScriptName(a.script),
+ "Style": localizedStylePrompt(style, a.language, a.script),
"Bible": bible,
"Pose": galleryPoses[(galleryNum-1)%len(galleryPoses)],
"RenderingRequirement": a.renderingRequirement(),
@@ -275,8 +317,10 @@ func (a *Artist) galleryPromptData(style, bible string, galleryNum int) map[stri
func (a *Artist) backPromptData(storyText, style, bible, blurb string) map[string]any {
return map[string]any{
"Language": a.language,
+ "LanguageName": localizedLanguageName(a.language, a.script),
"Script": a.script,
- "Style": style,
+ "ScriptName": localizedScriptName(a.script),
+ "Style": localizedStylePrompt(style, a.language, a.script),
"Bible": bible,
"BlurbBox": blurbBoxInstruction(blurb),
"SeriesTitle": localizedBrandName(a.language, a.script),
@@ -314,13 +358,14 @@ func pageScriptForPage(panelScript [][]string, idx int) []string {
}
func buildPanelLayout(section string, pagePanels []string) string {
- labels := [4]string{"TOP-LEFT", "TOP-RIGHT", "BOTTOM-LEFT", "BOTTOM-RIGHT"}
if len(pagePanels) == 4 && pagePanels[0] != "" && pagePanels[1] != "" && pagePanels[2] != "" && pagePanels[3] != "" {
var sb strings.Builder
- sb.WriteString("ЗАДЪЛЖИТЕЛНО ОФОРМЛЕНИЕ НА ПАНЕЛИТЕ — раздели изображението точно на 4 панела в решетка 2×2.\n")
- sb.WriteString("Оформи всеки панел ТОЧНО както е описано по-долу:\n")
- for i, label := range labels {
- sb.WriteString(fmt.Sprintf(" • %s панел: %s\n", label, pagePanels[i]))
+ sb.WriteString("Раздели изображението точно на 4 различни панела в решетка 2×2. Панелите трябва да разказват сцената последователно и да останат ясно различни един от друг.\n")
+ for i, panel := range pagePanels {
+ if panel == "" {
+ continue
+ }
+ sb.WriteString(fmt.Sprintf("Панел %d: %s\n", i+1, panel))
}
return sb.String()
}
@@ -333,12 +378,8 @@ func buildPanelLayout(section string, pagePanels []string) string {
}
excerpt += "…"
}
- return "ЗАДЪЛЖИТЕЛНО ОФОРМЛЕНИЕ НА ПАНЕЛИТЕ — раздели изображението точно на 4 панела в решетка 2×2:\n" +
- " • TOP-LEFT панел: сцена 1 от откъса\n" +
- " • TOP-RIGHT панел: сцена 2 от откъса\n" +
- " • BOTTOM-LEFT панел: сцена 3 от откъса\n" +
- " • BOTTOM-RIGHT панел: сцена 4 от откъса\n" +
- "Откъс от историята (всички панели трябва да илюстрират САМО този откъс):\n\n" + excerpt + "\n"
+ return "Раздели изображението точно на 4 различни панела в решетка 2×2. Панелите трябва да разказват историята последователно от начало към край и да останат ясно различни.\n" +
+ "Откъс от историята:\n\n" + excerpt + "\n"
}
func blurbBoxInstruction(blurb string) string {
diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go
index 3b89871..319c7b2 100644
--- a/internal/comic/comic_test.go
+++ b/internal/comic/comic_test.go
@@ -143,7 +143,7 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) {
t.Parallel()
originalLeakValidation := validateImagePromptLeakageFn
- validateImagePromptLeakageFn = func(context.Context, string, string) error { return nil }
+ validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil }
t.Cleanup(func() {
validateImagePromptLeakageFn = originalLeakValidation
})
@@ -201,7 +201,7 @@ func TestRunnerPropagatesRenderFailures(t *testing.T) {
originalSleep := sleep
sleep = func(time.Duration) {}
originalLeakValidation := validateImagePromptLeakageFn
- validateImagePromptLeakageFn = func(context.Context, string, string) error { return nil }
+ validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil }
t.Cleanup(func() {
sleep = originalSleep
validateImagePromptLeakageFn = originalLeakValidation
@@ -237,7 +237,7 @@ func TestRunnerPropagatesRenderFailures(t *testing.T) {
func TestDrawComicPagesUsesOneStyleAcrossTheWholePDF(t *testing.T) {
originalLeakValidation := validateImagePromptLeakageFn
- validateImagePromptLeakageFn = func(context.Context, string, string) error { return nil }
+ validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil }
t.Cleanup(func() {
validateImagePromptLeakageFn = originalLeakValidation
})
@@ -278,6 +278,38 @@ func TestDrawComicPagesUsesOneStyleAcrossTheWholePDF(t *testing.T) {
}
}
+func TestDrawComicPagesChainsReferenceImages(t *testing.T) {
+ originalLeakValidation := validateImagePromptLeakageFn
+ validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil }
+ t.Cleanup(func() {
+ validateImagePromptLeakageFn = originalLeakValidation
+ })
+
+ provider := &refTrackingImageProvider{t: t}
+ artist := NewArtist(&ArtistConfig{
+ ImageProvider: provider,
+ Prompts: fakePromptRenderer{},
+ OutputDir: t.TempDir(),
+ UltraRealistic: false,
+ })
+
+ if _, err := artist.DrawComicPages(context.Background(), "история", "библия", "slug", []WordEntry{{Word: "ябълка"}}, nil); err != nil {
+ t.Fatalf("DrawComicPages() error = %v", err)
+ }
+ if len(provider.referenceCounts) < 3 {
+ t.Fatalf("referenceCounts = %v, want multiple page generations", provider.referenceCounts)
+ }
+ if provider.referenceCounts[0] != 0 {
+ t.Fatalf("cover refs = %d, want 0", provider.referenceCounts[0])
+ }
+ if provider.referenceCounts[1] != 1 {
+ t.Fatalf("first story page refs = %d, want 1", provider.referenceCounts[1])
+ }
+ if provider.referenceCounts[2] < 1 {
+ t.Fatalf("second page refs = %d, want chained references", provider.referenceCounts[2])
+ }
+}
+
func TestConvertToStereoFallsBackToCopyWhenFFmpegMissing(t *testing.T) {
originalLookPath := lookPath
lookPath = func(string) (string, error) {
@@ -406,6 +438,24 @@ func (f fakeImageProvider) GenerateImage(_ context.Context, _ string, outputFile
return nil
}
+type refTrackingImageProvider struct {
+ t *testing.T
+ referenceCounts []int
+}
+
+func (p *refTrackingImageProvider) Name() string { return "ref-tracking-image" }
+func (p *refTrackingImageProvider) IsAvailable() error { return nil }
+func (p *refTrackingImageProvider) GenerateImage(ctx context.Context, prompt, outputFile string) error {
+ return p.GenerateImageWithReferences(ctx, prompt, outputFile, nil)
+}
+func (p *refTrackingImageProvider) GenerateImageWithReferences(_ context.Context, _ string, outputFile string, refs [][]byte) error {
+ p.referenceCounts = append(p.referenceCounts, len(refs))
+ if err := os.WriteFile(outputFile, []byte("png"), 0o644); err != nil {
+ p.t.Fatal(err)
+ }
+ return nil
+}
+
type failingImageProvider struct{}
func (failingImageProvider) Name() string { return "failing-image" }
diff --git a/internal/comic/generator.go b/internal/comic/generator.go
index b3be2ef..89591c8 100644
--- a/internal/comic/generator.go
+++ b/internal/comic/generator.go
@@ -128,7 +128,9 @@ func (g *Generator) renderStoryPrompt(templateName string, entries []WordEntry)
genre := resolveGenre(g.theme, g.genres)
data := map[string]any{
"Language": g.language,
+ "LanguageName": localizedLanguageName(g.language, g.script),
"Script": g.script,
+ "ScriptName": localizedScriptName(g.script),
"Genre": genre,
"Words": buildWordList(entries, ""),
"StoryBibleSeparator": storyBibleSeparator,
@@ -137,8 +139,10 @@ func (g *Generator) renderStoryPrompt(templateName string, entries []WordEntry)
}
systemPrompt, err := g.prompts.RenderPrompt(storySystemPromptTemplate, map[string]any{
- "Language": g.language,
- "Script": g.script,
+ "Language": g.language,
+ "LanguageName": localizedLanguageName(g.language, g.script),
+ "Script": g.script,
+ "ScriptName": localizedScriptName(g.script),
})
if err != nil {
return "", err
@@ -152,32 +156,41 @@ func (g *Generator) renderStoryPrompt(templateName string, entries []WordEntry)
func validateGeneratedResult(result GenerateResult, script string) error {
if err := validateTextScript("story text", result.StoryText, script); err != nil {
- return err
+ return fmt.Errorf("%w (story=%q)", err, compactTextSnippet(result.StoryText))
}
if err := validateNoPromptLeakage("story text", result.StoryText); err != nil {
- return err
+ return fmt.Errorf("%w (story=%q)", err, compactTextSnippet(result.StoryText))
}
if err := validateTextScript("character bible", result.Bible, script); err != nil {
- return err
+ return fmt.Errorf("%w (bible=%q)", err, compactTextSnippet(result.Bible))
}
if err := validateNoPromptLeakage("character bible", result.Bible); err != nil {
- return err
+ return fmt.Errorf("%w (bible=%q)", err, compactTextSnippet(result.Bible))
}
if err := validateTextScript("comic title", result.Title, script); err != nil {
- return err
+ return fmt.Errorf("%w (title=%q)", err, compactTextSnippet(result.Title))
}
if err := validateNoPromptLeakage("comic title", result.Title); err != nil {
- return err
+ return fmt.Errorf("%w (title=%q)", err, compactTextSnippet(result.Title))
}
for pageIdx, page := range result.PanelScript {
for panelIdx, panel := range page {
if err := validateTextScript(fmt.Sprintf("panel script P%d-%c", pageIdx+1, 'A'+panelIdx), panel, script); err != nil {
- return err
+ return fmt.Errorf("%w (panel=%q)", err, compactTextSnippet(panel))
}
if err := validateNoPromptLeakage(fmt.Sprintf("panel script P%d-%c", pageIdx+1, 'A'+panelIdx), panel); err != nil {
- return err
+ return fmt.Errorf("%w (panel=%q)", err, compactTextSnippet(panel))
}
}
}
return nil
}
+
+func compactTextSnippet(text string) string {
+ text = strings.TrimSpace(text)
+ text = strings.Join(strings.Fields(text), " ")
+ if len(text) > 220 {
+ text = text[:220] + "..."
+ }
+ return text
+}
diff --git a/internal/comic/image_validation.go b/internal/comic/image_validation.go
index 475cb96..8148c15 100644
--- a/internal/comic/image_validation.go
+++ b/internal/comic/image_validation.go
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os/exec"
+ "regexp"
"strings"
)
@@ -31,9 +32,16 @@ var imageLeakMarkers = []string{
"no text of any kind",
}
+var englishLeakWords = []string{
+ "page", "comic", "photograph", "photography", "panel", "panels",
+ "cover", "title", "subtitle", "blurb", "story", "gallery",
+ "rendering", "language", "mandatory", "speech", "bubble", "caption",
+ "close-up", "medium-long", "ultra-realistic", "illustration", "text",
+}
+
var validateImagePromptLeakageFn = validateImagePromptLeakage
-func validateImagePromptLeakage(ctx context.Context, outputFile, label string) error {
+func validateImagePromptLeakage(ctx context.Context, outputFile, label, script string) error {
if ctx == nil {
ctx = context.Background()
}
@@ -53,7 +61,12 @@ func validateImagePromptLeakage(ctx context.Context, outputFile, label string) e
ocr := strings.ToLower(out.String())
if marker, ok := findImageLeakMarker(ocr); ok {
- return fmt.Errorf("%s contains prompt leakage marker %q", label, marker)
+ return fmt.Errorf("%s contains prompt leakage marker %q (ocr=%q)", label, marker, compactOCRSnippet(ocr))
+ }
+ if strings.EqualFold(script, "Cyrillic") {
+ if word, ok := findEnglishLeakWord(ocr); ok {
+ return fmt.Errorf("%s contains English leakage word %q despite %s script (ocr=%q)", label, word, script, compactOCRSnippet(ocr))
+ }
}
return nil
}
@@ -66,3 +79,22 @@ func findImageLeakMarker(text string) (string, bool) {
}
return "", false
}
+
+func findEnglishLeakWord(text string) (string, bool) {
+ for _, word := range englishLeakWords {
+ pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(word) + `\b`)
+ if pattern.MatchString(text) {
+ return word, true
+ }
+ }
+ return "", false
+}
+
+func compactOCRSnippet(text string) string {
+ text = strings.TrimSpace(text)
+ text = strings.Join(strings.Fields(text), " ")
+ if len(text) > 240 {
+ text = text[:240] + "..."
+ }
+ return text
+}
diff --git a/internal/comic/localization.go b/internal/comic/localization.go
index 2578feb..6eb2a0d 100644
--- a/internal/comic/localization.go
+++ b/internal/comic/localization.go
@@ -9,3 +9,64 @@ func localizedBrandName(language, script string) string {
}
return "ComicForge Adventures"
}
+
+func localizedScriptName(script string) string {
+ switch {
+ case strings.EqualFold(script, "Cyrillic"):
+ return "кирилица"
+ case strings.EqualFold(script, "Latin"):
+ return "латиница"
+ default:
+ return script
+ }
+}
+
+func localizedLanguageName(language, script string) string {
+ if strings.EqualFold(script, "Cyrillic") || strings.EqualFold(language, "Bulgarian") {
+ return "български"
+ }
+ switch {
+ case strings.EqualFold(language, "English"):
+ return "английски"
+ case strings.EqualFold(language, "German"):
+ return "немски"
+ case strings.EqualFold(language, "French"):
+ return "френски"
+ default:
+ return language
+ }
+}
+
+func localizedStylePrompt(style, language, script string) string {
+ if !strings.EqualFold(script, "Cyrillic") && !strings.EqualFold(language, "Bulgarian") {
+ return style
+ }
+ switch style {
+ case "classic American comic book with bold ink outlines, halftone dots, and primary colors":
+ return "класически американски комикс с дебели мастилени контури, халф-тонови точки и основни цветове"
+ case "Japanese manga with clean linework, expressive eyes, and speed lines":
+ return "японска манга с чисти линии, изразителни очи и скоростни линии"
+ case "retro 1960s pop art in the style of Roy Lichtenstein with thick outlines and Ben-Day dots":
+ return "ретро поп арт от 60-те години с дебели контури и точки в стил Бен-Дей"
+ case "watercolor illustration with soft washes, delicate linework, and pastel tones":
+ return "акварелна илюстрация с меки петна, фина линия и пастелни тонове"
+ case "European bande dessinée with detailed backgrounds, clear lines, and rich flat colors":
+ return "европейски bande dessinée с детайлни фонове, ясни линии и наситени плоски цветове"
+ case "noir black-and-white graphic novel with heavy shadows and high contrast":
+ return "ноар черно-бял графичен роман с тежки сенки и силен контраст"
+ case "children's picture book with bright, friendly illustrations and thick outlines":
+ return "детска книжка с ярки, приветливи илюстрации и дебели контури"
+ case "painterly oil-on-canvas comic with loose brushwork and vivid impressionist colors":
+ return "живописен комикс с маслени щрихи и ярки импресионистични цветове"
+ case "cyberpunk neon art with glowing outlines, dark backgrounds, and electric accent colors":
+ return "киберпънк неонова визия с светещи контури, тъмни фонове и електрически акцентни цветове"
+ case "ultra-realistic DSLR photography, cinematic 35mm lens, natural lighting, hyper-detailed textures":
+ return "ултрареалистична фотография с кинематографичен 35 мм обектив, естествено осветление и силно детайлни текстури"
+ case "cinematic still photography, golden-hour lighting, shallow depth of field, photojournalism quality":
+ return "кинематографична фотография с светлина на златен час, плитка дълбочина на полето и качеството на фоторепортаж"
+ case "hyper-realistic photography, studio-quality lighting, sharp focus, true-to-life colours and textures":
+ return "хиперреалистична фотография със студийно осветление, остър фокус и естествени цветове и текстури"
+ default:
+ return style
+ }
+}
diff --git a/internal/comic/localization_test.go b/internal/comic/localization_test.go
index 8855732..9da0f11 100644
--- a/internal/comic/localization_test.go
+++ b/internal/comic/localization_test.go
@@ -55,6 +55,17 @@ func TestFindImageLeakMarker(t *testing.T) {
}
}
+func TestFindEnglishLeakWord(t *testing.T) {
+ t.Parallel()
+
+ if word, ok := findEnglishLeakWord("спокойна сцена"); ok || word != "" {
+ t.Fatalf("findEnglishLeakWord() = %q, %v, want no match", word, ok)
+ }
+ if word, ok := findEnglishLeakWord("page 1 of 5"); !ok || word != "page" {
+ t.Fatalf("findEnglishLeakWord() = %q, %v, want page match", word, ok)
+ }
+}
+
func TestComicStylesStayComic(t *testing.T) {
t.Parallel()
@@ -64,3 +75,26 @@ func TestComicStylesStayComic(t *testing.T) {
}
}
}
+
+func TestContainsLatinLetters(t *testing.T) {
+ t.Parallel()
+
+ if containsLatinLetters("Здравей") {
+ t.Fatal("containsLatinLetters() = true for Cyrillic text")
+ }
+ if !containsLatinLetters("Page 4 of 5") {
+ t.Fatal("containsLatinLetters() = false for Latin text")
+ }
+}
+
+func TestLocalizedStylePrompt(t *testing.T) {
+ t.Parallel()
+
+ got := localizedStylePrompt("classic American comic book with bold ink outlines, halftone dots, and primary colors", "Bulgarian", "Cyrillic")
+ if containsLatinLetters(got) {
+ t.Fatalf("localizedStylePrompt() = %q, want no Latin letters", got)
+ }
+ if got == "" {
+ t.Fatal("localizedStylePrompt() returned empty string")
+ }
+}
diff --git a/internal/comic/narrator.go b/internal/comic/narrator.go
index a7c76c3..a75522a 100644
--- a/internal/comic/narrator.go
+++ b/internal/comic/narrator.go
@@ -190,9 +190,11 @@ func (n *Narrator) buildTeaser(ctx context.Context, templateName, storyText stri
return ""
}
systemPrompt, err := n.prompts.RenderPrompt(templateName, map[string]any{
- "StoryText": storyText,
- "Language": n.language,
- "Script": n.script,
+ "StoryText": storyText,
+ "Language": n.language,
+ "LanguageName": localizedLanguageName(n.language, n.script),
+ "Script": n.script,
+ "ScriptName": localizedScriptName(n.script),
})
if err != nil {
fmt.Printf(" Warning: text prompt render failed: %v\n", err)
diff --git a/internal/comic/types.go b/internal/comic/types.go
index ba9a83d..c8ada2f 100644
--- a/internal/comic/types.go
+++ b/internal/comic/types.go
@@ -94,11 +94,11 @@ var (
}
galleryPoses = []string{
- "extreme close-up portrait: face and shoulders filling the entire frame, dramatic three-quarter lighting, intense gaze directly at the viewer, fine detail on eyes and expression",
- "dynamic action pose: full body, low-angle shot looking up at the main character against the sky or setting backdrop, confident stance, hair and clothing caught in motion",
- "atmospheric mid-shot: waist-up, the main character silhouetted or lit by the ambient environment (bioluminescence, sunset, neon glow), looking off into the distance with a sense of wonder or resolve",
- "profile close-up: side view of face and upper body, soft rim lighting tracing the jawline and hair, contemplative expression, rich background bokeh",
- "power stance full-body: the main character seen from the front at eye level, arms relaxed but ready, environment filling the frame behind them, golden-hour or dramatic storm light",
+ "крупен план на лице и раменете, драматично три-четвъртно осветление, ясен поглед към зрителя, отчетливи очи и изражение",
+ "динамична цялостна поза, нисък ъгъл на камерата към героя на фона на небето или средата, уверена стойка, коса и дрехи в движение",
+ "атмосферен среден план, героят е осветен или очертан от светлината на средата (биолуминесценция, залез, неон), погледът е отправен в далечината с усещане за чудо или решителност",
+ "страничен близък план на лице и горна част на тялото, мек контурен блясък по челюстта и косата, замислено изражение, мек размит фон",
+ "уверена цялостна стойка, героят е заснет отпред на нивото на очите, ръцете са отпуснати но готови, средата изпълва кадъра, златна или драматична светлина",
}
)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 74fa944..4f6021d 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -86,14 +86,16 @@ func TestRenderPromptFallsBackToEmbeddedTemplate(t *testing.T) {
cfg.PromptsDir = t.TempDir()
got, err := cfg.RenderPrompt("story_prompt.md", map[string]any{
- "Language": "Bulgarian",
- "Genre": "a mystery with a surprising twist",
- "Words": "- ябълка\n- книга\n",
+ "Language": "Bulgarian",
+ "LanguageName": "български",
+ "ScriptName": "кирилица",
+ "Genre": "a mystery with a surprising twist",
+ "Words": "- ябълка\n- книга\n",
})
if err != nil {
t.Fatalf("RenderPrompt() error = %v", err)
}
- if !strings.Contains(got, "Write a ~250-word story in Bulgarian") {
+ if !strings.Contains(got, "Напиши история с дължина около 250 думи на български") {
t.Fatalf("RenderPrompt() = %q, want embedded story prompt", got)
}
}
@@ -142,7 +144,9 @@ func TestEmbeddedPromptTemplatesRender(t *testing.T) {
data := map[string]any{
"Language": "Bulgarian",
+ "LanguageName": "български",
"Script": "Cyrillic",
+ "ScriptName": "кирилица",
"Genre": "a mystery with a surprising twist",
"Style": "cinematic realism",
"Words": "- ябълка\n- книга\n",
diff --git a/internal/image/gemini.go b/internal/image/gemini.go
index 15373e2..d2f710c 100644
--- a/internal/image/gemini.go
+++ b/internal/image/gemini.go
@@ -173,6 +173,13 @@ func (c *GeminiProvider) IsAvailable() error {
// GenerateImage renders the first generated image to outputFile.
func (c *GeminiProvider) GenerateImage(ctx context.Context, prompt, outputFile string) error {
+ return c.GenerateImageWithReferences(ctx, prompt, outputFile, nil)
+}
+
+// GenerateImageWithReferences renders the first generated image to outputFile,
+// optionally conditioning the model on prior page images so comic pages stay
+// visually consistent across the full PDF.
+func (c *GeminiProvider) GenerateImageWithReferences(ctx context.Context, prompt, outputFile string, refs [][]byte) error {
if c == nil {
return fmt.Errorf("image provider is nil")
}
@@ -185,7 +192,11 @@ func (c *GeminiProvider) GenerateImage(ctx context.Context, prompt, outputFile s
if strings.TrimSpace(outputFile) == "" {
return fmt.Errorf("output file is required")
}
- results, err := c.Search(ctx, &SearchOptions{CustomPrompt: prompt})
+ opts := &SearchOptions{CustomPrompt: prompt}
+ if len(refs) > 0 {
+ opts.ReferenceImages = refs
+ }
+ results, err := c.Search(ctx, opts)
if err != nil {
return err
}
diff --git a/prompts/back_cover_prompt.md b/prompts/back_cover_prompt.md
index 3a9d852..5e54668 100644
--- a/prompts/back_cover_prompt.md
+++ b/prompts/back_cover_prompt.md
@@ -1,15 +1,15 @@
-{{if .Language}}Български комикс. Всички видими над