diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/comic/artist.go | 95 | ||||
| -rw-r--r-- | internal/comic/comic_test.go | 56 | ||||
| -rw-r--r-- | internal/comic/generator.go | 33 | ||||
| -rw-r--r-- | internal/comic/image_validation.go | 36 | ||||
| -rw-r--r-- | internal/comic/localization.go | 61 | ||||
| -rw-r--r-- | internal/comic/localization_test.go | 34 | ||||
| -rw-r--r-- | internal/comic/narrator.go | 8 | ||||
| -rw-r--r-- | internal/comic/types.go | 10 | ||||
| -rw-r--r-- | internal/config/config_test.go | 12 | ||||
| -rw-r--r-- | internal/image/gemini.go | 13 |
10 files changed, 303 insertions, 55 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 } |
