From e670494adfe00a1fa55c7e8ffcbf0172ff662cce Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 20 Apr 2026 09:25:00 +0300 Subject: Fix task 25 comic generation leak handling --- internal/comic/artist.go | 95 +++++++++++++++++++++++++---------- internal/comic/comic_test.go | 56 +++++++++++++++++++-- internal/comic/generator.go | 33 ++++++++---- internal/comic/image_validation.go | 36 ++++++++++++- internal/comic/localization.go | 61 ++++++++++++++++++++++ internal/comic/localization_test.go | 34 +++++++++++++ internal/comic/narrator.go | 8 +-- internal/comic/types.go | 10 ++-- internal/config/config_test.go | 12 +++-- internal/image/gemini.go | 13 ++++- prompts/back_cover_prompt.md | 10 ++-- prompts/bible_system.md | 3 +- prompts/blurb_system.md | 2 +- prompts/cover_prompt.md | 12 ++--- prompts/gallery_page_prompt.md | 4 +- prompts/narrator_cinematic.md | 8 +-- prompts/narrator_conclusion_system.md | 4 +- prompts/narrator_intro_system.md | 4 +- prompts/panel_script_prompt.md | 24 ++++----- prompts/story_full_prompt.md | 53 +++++++++++-------- prompts/story_page_prompt.md | 12 ++--- prompts/story_prompt.md | 9 ++-- prompts/story_system.md | 3 +- 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}}Български комикс. Всички видими надписи, ленти и етикети на задната корица са на {{.Script}}. Без латиница на задната корица. +{{if .Language}}Български комикс. Всички видими надписи, ленти и етикети на задната корица са на {{.ScriptName}}. Без латиница на задната корица. {{end}}Стил: {{.Style}}.{{.RenderingRequirement}} -ТРАДИЦИОННА ЗАДНА КО РИЦА НА КОМИКС — {{if .RenderingRequirement}}едно пълно изображение (фотореалистично — като истинска фотосесия за задната корица){{else}}една пълноекранна илюстрация{{end}}, пейзажен формат 16:9. +Пълна задна корица {{if .RenderingRequirement}}фотографска сцена{{else}}илюстрация{{end}} в пейзажен формат 16:9. Без панелна решетка. Без балони за реплики. -{{if .Bible}}РЕФЕРЕНЦИЯ ЗА ПЕРСОНАЖИТЕ И СРЕДАТА (задна корица — следвай точно, без промяна на дрехите): +{{if .Bible}}РЕФЕРЕНЦИЯ ЗА ПЕРСОНАЖИТЕ И СРЕДАТА: {{.Bible}} {{end}}Правила за композицията: - • ОСНОВНА СЦЕНА: спокойно, топло и завършено изображение в горните 60% на корицата — ТОЧНО описаните герои от историята в мирен или триумфален финален момент, с цялата среда зад тях. Сцената трябва да изглежда ФОТОГРАФСКА, не рисувана. + • ОСНОВНА СЦЕНА: спокойно, топло и завършено изображение в горните 60% на корицата — ТОЧНО описаните герои от историята в мирен или триумфален финален момент, с цялата среда зад тях. Сцената трябва да изглежда фотографска, не рисувана. • ТЕКСТОВО ПОЛЕ: {{.BlurbBox}} • ДОЛНА ЛЕНТА: поле с баркод долу вляво, заглавие на поредицата „{{.SeriesTitle}}“ долу вдясно — класическо комиксово оформление. -ВАЖНО: на задната корица могат да присъстват само героите, посочени в референцията. Същата възраст, лице и дрехи като в страниците. Всички надписи са на {{.Script}}. Подсказка за финала на историята: +ВАЖНО: на задната корица могат да присъстват само героите, посочени в референцията. Същата възраст, лице и дрехи като в страниците. Всички надписи са на {{.ScriptName}}. Подсказка за финала на историята: {{.StoryText}}{{if .RenderingRequirement}}{{.RenderingRequirementEnd}}{{end}} diff --git a/prompts/bible_system.md b/prompts/bible_system.md index 918a4b7..63901b4 100644 --- a/prompts/bible_system.md +++ b/prompts/bible_system.md @@ -1,4 +1,5 @@ -You are a comic-book art director producing a CHARACTER CONSISTENCY GUIDE in {{.Language}} using {{.Script}} script for an illustrator. +You are a comic-book art director producing a CHARACTER CONSISTENCY GUIDE in {{.LanguageName}} using {{.ScriptName}} script for an illustrator. +Use only {{.ScriptName}}. Do not use Latin letters anywhere in the guide. If a name or term is foreign, transliterate it into {{.ScriptName}}. For every named HUMAN character provide: name, apparent age category (young child, teenager, young adult, middle-aged, elderly), hair (colour + style), eye colour, skin tone, build, and the EXACT clothing they wear — specify garment, colour, pattern, and fit. The character's apparent age MUST NOT change across any panel, page, cover, or back cover — they must always look the same. Clothing must NOT change between panels unless the story explicitly describes a change; if no change is described, list the same outfit for all appearances. diff --git a/prompts/blurb_system.md b/prompts/blurb_system.md index b6aa189..f883023 100644 --- a/prompts/blurb_system.md +++ b/prompts/blurb_system.md @@ -1,3 +1,3 @@ You are a comic-book editor writing back-cover marketing copy. -Rules: write exactly 2–3 sentences in {{.Language}} using {{.Script}} script; exciting and enticing; do NOT spoil the ending; use present-tense second-person phrasing. +Rules: write exactly 2–3 sentences in {{.LanguageName}} using {{.ScriptName}} script; exciting and enticing; do NOT spoil the ending; use present-tense second-person phrasing. Output only the blurb text — no quotes, no labels, no extra commentary. diff --git a/prompts/cover_prompt.md b/prompts/cover_prompt.md index f824c6a..66a92fd 100644 --- a/prompts/cover_prompt.md +++ b/prompts/cover_prompt.md @@ -1,19 +1,19 @@ -{{if .Language}}Български комикс. Всички надписи на корицата са на {{.Script}}, включително основното заглавие. +{{if .Language}}Български комикс. Всички надписи на корицата са на {{.ScriptName}}, включително основното заглавие. {{end}}Стил: {{.Style}}.{{.RenderingRequirement}} -ТРАДИЦИОННА ПРЕДНА КО РИКА НА КОМИКС — {{if .RenderingRequirement}}едно пълно изображение (фотореалистично — като истинска фотосесия за корица){{else}}една пълноекранна илюстрация{{end}}, пейзажен формат 16:9. +Пълна предна корица {{if .RenderingRequirement}}фотографска сцена{{else}}илюстрация{{end}} в пейзажен формат 16:9. Без панелна решетка. Без балони за реплики. -{{if .Bible}}РЕФЕРЕНЦИЯ ЗА ПЕРСОНАЖИТЕ И СРЕДАТА (корица — следвай точно, без промяна на дрехите): +{{if .Bible}}РЕФЕРЕНЦИЯ ЗА ПЕРСОНАЖИТЕ И СРЕДАТА: {{.Bible}} {{end}}ОСНОВНО ЗАГЛАВИЕ — най-важният визуален елемент на корицата: - • Измисли ДРАМАТИЧНО, СПЕЦИФИЧНО ЗА ИСТОРИЯТА комиксово заглавие, което подхожда на героите и темата на откъса по-долу (например за космическа история: „ГАЛАКТИЧЕСКИ ГЕРОИ“, за мистерия: „ТАЙНАТА НА ГОРАТА“). Заглавието трябва да е огромно, доминиращо и разположено в самия връх на корицата. + • Измисли драматично, специфично за историята комиксово заглавие, което подхожда на героите и темата на откъса по-долу. Заглавието трябва да е огромно, доминиращо и разположено в самия връх на корицата. • Под основното заглавие добави по-малка лента с подзаглавие „{{.Subtitle}}“ в контрастен цвят. • Добави малка кръгла или звездовидна значка в горния ляв ъгъл, свързана с темата на историята. Останали правила за композицията: • ОСНОВНА СЦЕНА: под заглавието — драматична {{if .RenderingRequirement}}фотографска кинематографична сцена{{else}}илюстрация{{end}} с ТОЧНО описаните герои от историята — същите лица, възраст, дрехи и животни. Не измисляй нови персонажи и не използвай заместители. - • КОРОВИ ФРАЗИ: 2–3 кратки фрази на {{.Language}} в удебелен дисплей шрифт. + • КОРОВИ ФРАЗИ: 2–3 кратки фрази на {{.LanguageName}} в удебелен дисплей шрифт. • ДОЛНА ЛЕНТА: поле за цена долу вляво, номер на брой долу вдясно — класическо комиксово оформление. -ВАЖНО: на корицата могат да присъстват само героите, посочени в референцията. Същата възраст, лице и дрехи като в страниците. Всички надписи са на {{.Script}}. Откъс от историята: +ВАЖНО: на корицата могат да присъстват само героите, посочени в референцията. Същата възраст, лице и дрехи като в страниците. Всички надписи са на {{.ScriptName}}. Откъс от историята: {{.StoryText}}{{if .RenderingRequirement}}{{.RenderingRequirementEnd}}{{end}} diff --git a/prompts/gallery_page_prompt.md b/prompts/gallery_page_prompt.md index b5dfca6..8713bbe 100644 --- a/prompts/gallery_page_prompt.md +++ b/prompts/gallery_page_prompt.md @@ -1,8 +1,8 @@ Стил: {{.Style}}.{{.RenderingRequirement}} -ПЪЛЕН КАДЪР {{if .RenderingRequirement}}ФОТОГРАФИЯ — да изглежда 100% като реален кадър от камера{{else}}ИЛЮСТРАЦИЯ{{end}} — пейзажен формат 16:9, само едно изображение, без решетка, без панели. +Един цял кадър {{if .RenderingRequirement}}фотография — да изглежда 100% като реален кадър от камера{{else}}илюстрация{{end}} с пейзажен формат 16:9. Не разделяй изображението на няколко панела или секции. Цялото поле трябва да е една единствена сцена. Без текст от какъвто и да е вид. Без заглавие, етикети, балони за реплики, граници на панели или интерфейсни елементи. -{{if .Bible}}РЕФЕРЕНЦИЯ ЗА ПЕРСОНАЖИТЕ И СРЕДАТА (галериен кадър — следвай точно, без да сменяш дрехите): +{{if .Bible}}РЕФЕРЕНЦИЯ ЗА ПЕРСОНАЖИТЕ И СРЕДАТА: {{.Bible}} {{end}} diff --git a/prompts/narrator_cinematic.md b/prompts/narrator_cinematic.md index 86b2c3b..3b75f81 100644 --- a/prompts/narrator_cinematic.md +++ b/prompts/narrator_cinematic.md @@ -1,5 +1,5 @@ -You are a dramatic cinematic narrator performing a story written in {{.Language}}. -IMPORTANT: This text is in the {{.Language}} language — not a different language with a similar script. -Pronounce every word using authentic {{.Language}} phonology and accent. Deliver this as a professional movie trailer narrator would: deep, resonant, and commanding. +You are a dramatic cinematic narrator performing a story written in {{.LanguageName}}. +IMPORTANT: This text is in the {{.LanguageName}} language — not a different language with a similar script. +Pronounce every word using authentic {{.LanguageName}} phonology and accent. Deliver this as a professional movie trailer narrator would: deep, resonant, and commanding. Use long dramatic pauses before key moments. Build tension with slower, deliberate pacing, then accelerate through action. Drop your voice low and gravelly for mysterious or serious passages; let warmth and energy rise for joyful or triumphant ones. -Breathe life into every sentence — this should sound like an epic {{.Language}} film, not a reading exercise. +Breathe life into every sentence — this should sound like an epic {{.LanguageName}} film, not a reading exercise. diff --git a/prompts/narrator_conclusion_system.md b/prompts/narrator_conclusion_system.md index 6cc3b37..160f99b 100644 --- a/prompts/narrator_conclusion_system.md +++ b/prompts/narrator_conclusion_system.md @@ -1,5 +1,5 @@ -You are a dramatic cinematic narrator writing a closing epilogue for a {{.Language}} story. -Write a SHORT closing epilogue in {{.Language}} using {{.Script}} script. +You are a dramatic cinematic narrator writing a closing epilogue for a {{.LanguageName}} story. +Write a SHORT closing epilogue in {{.LanguageName}} using {{.ScriptName}} script. Exactly 3–4 sentences, cinematic and poetic, with a warm and conclusive tone — like the final voice-over of a film that leaves the audience with a sense of wonder and completion. Do NOT summarise the plot; instead reflect on the deeper meaning or emotion of the story. Output only the epilogue text, nothing else. diff --git a/prompts/narrator_intro_system.md b/prompts/narrator_intro_system.md index 3a4a900..af171d9 100644 --- a/prompts/narrator_intro_system.md +++ b/prompts/narrator_intro_system.md @@ -1,4 +1,4 @@ -You are a dramatic cinematic narrator writing an opening teaser for a {{.Language}} story. -Write a SHORT opening teaser in {{.Language}} using {{.Script}} script. +You are a dramatic cinematic narrator writing an opening teaser for a {{.LanguageName}} story. +Write a SHORT opening teaser in {{.LanguageName}} using {{.ScriptName}} script. Exactly 2–3 sentences, cinematic and suspenseful, that summarise what the story is about and hook the listener — like the opening voice-over of a film trailer. Do NOT spoil the ending. Output only the teaser text, nothing else. diff --git a/prompts/panel_script_prompt.md b/prompts/panel_script_prompt.md index e4c5816..88f3350 100644 --- a/prompts/panel_script_prompt.md +++ b/prompts/panel_script_prompt.md @@ -1,12 +1,12 @@ -Write exactly 20 visual panel descriptions for the comic illustrator, one per line, in strict chronological story order. -Format each line exactly as: P{page}-{panel}: {description} -where page is 1–5 and panel is A (top-left), B (top-right), C (bottom-left), D (bottom-right). -Each description is 1–2 sentences covering: WHO is in the panel, WHAT they are doing, WHERE they are, their expression or body language, and — REQUIRED for most panels — the exact {{.Language}} dialogue or thought text the character says/thinks, written in quotes. -At least 3 of the 4 panels on every page MUST include {{.Language}} dialogue in a speech bubble or thought bubble. The dialogue must come directly from the story and be in {{.Script}} script. -The 20 panels must retell the story from beginning to end — each page covers one story beat, each panel advances the action. -Do NOT repeat the same scene or camera angle on consecutive panels. -Example format (replace with actual story content): -P1-A: Maria walks out her front door into morning sunlight, laptop bag on shoulder, saying "Finally!" in a speech bubble. -P1-B: She strides down a busy city street past parked cars, headphones in, thinking "What a beautiful day." in a thought bubble. -P1-C: Close-up of her hand gripping a large steaming coffee cup, the barista handing it over saying "Here you go!" in a speech bubble. -P1-D: Maria pauses at a park entrance, gazing at green trees ahead, saying "This is exactly what I needed." in a speech bubble. +Напиши точно 20 визуални описания на панели за комиксовия илюстратор, по един ред, в строг хронологичен ред на историята. +Форматът на всеки ред трябва да е точно: P{page}-{panel}: {описание} +където page е от 1 до 5, а panel е A (горе вляво), B (горе вдясно), C (долу вляво), D (долу вдясно). +Всяко описание е от 1 до 2 изречения и трябва да включва: КОЙ е в панела, КАКВО прави, КЪДЕ се намира, какво е изражението или езикът на тялото му, и - ЗАДЪЛЖИТЕЛНО за повечето панели - точния диалог или мисъл на {{.LanguageName}}, написан в кавички. +Поне 3 от 4-те панела на всяка страница ТРЯБВА да включват диалог или мисъл на {{.LanguageName}} в балон за реплика или мисъл. Диалогът трябва да идва директно от историята и да е на {{.ScriptName}}. +20-те панела трябва да преразкажат историята от началото до края - всяка страница покрива един етап, всеки панел придвижва действието напред. +Не повтаряй една и съща сцена или ъгъл на камерата в последователни панели. +Примерен формат, замени с истинското съдържание на историята: +P1-A: Мария излиза от входната си врата в утринната светлина, с чанта на рамо, и казва "Най-сетне!" в балон за реплика. +P1-B: Тя върви по оживена улица покрай паркирани коли, с слушалки в ушите, и мисли "Толкова хубав ден." в балон за мисъл. +P1-C: Крупен план на ръката ѝ, която стиска голяма пареща чаша кафе, а баристата ѝ я подава с думите "Заповядайте!" в балон за реплика. +P1-D: Мария спира при входа на парк и поглежда към зелените дървета отпред, като казва "Точно това ми трябваше." в балон за реплика. diff --git a/prompts/story_full_prompt.md b/prompts/story_full_prompt.md index 76d5505..1b5b4e9 100644 --- a/prompts/story_full_prompt.md +++ b/prompts/story_full_prompt.md @@ -1,37 +1,46 @@ -Write a ~250-word story in {{.Language}} that naturally uses all of the following words. -The story must be {{.Genre}} — do NOT write a generic fairy tale. -Use every word naturally, but do NOT repeat the word list formatting, bullet symbols, or labels in the story text. +Напиши история с дължина около 250 думи на {{.LanguageName}}, която естествено използва всички следните думи. +Историята трябва да е {{.Genre}} — не пиши безлична приказка. +Използвай само {{.ScriptName}}. Не използвай латиница никъде в текста. Ако чуждо име или термин е нужен, транслитерирай го в {{.ScriptName}}. +Използвай всяка дума естествено, но не повтаряй списъка с думи, булетите или етикетите в текста на историята. -Words to include: +Думи за включване: {{.Words}} -After the story text, write exactly this line by itself (nothing else on that line): +След текста на историята напиши точно този ред самостоятелно (без нищо друго на този ред): {{.StoryBibleSeparator}} -Then write a CHARACTER CONSISTENCY GUIDE in {{.Language}} using {{.Script}} script for an illustrator. -IMPORTANT: all human characters must be adults (18 years or older). Do NOT describe any character as a child, teenager, or minor. -For every named HUMAN character: name, apparent age as a young adult or older (e.g. young adult, adult, middle-aged, elderly), hair (colour + style), eye colour, skin tone, build, and EXACT clothing (garment, colour, pattern, fit). Apparent age and clothing must NOT change — list the same for all appearances. -For every named ANIMAL character: name, species, exact breed, fur colour and pattern, eye colour, size, distinctive markings, body posture. Must look IDENTICAL on every page — same breed, same markings, same eye colour. -Also describe: setting (location, time of day, weather, key props) and overall lighting/colour mood. -Maximum 280 words for the guide. No headers, just dense descriptive prose. +След това напиши РЪКОВОДСТВО ЗА ПОСЛЕДОВАТЕЛНОСТТА НА ПЕРСОНАЖИТЕ на {{.LanguageName}} с {{.ScriptName}} за илюстратор. +ВАЖНО: всички човешки персонажи трябва да са възрастни (18+). Не описвай никой като дете, тийнейджър или непълнолетен. +За всеки именуван ЧОВЕШКИ персонаж: име, приблизителна възраст като млад възрастен или по-възрастен, коса (цвят + стил), цвят на очите, тон на кожата, телосложение и ТОЧНО облекло (дреха, цвят, шарка, кройка). Приблизителната възраст и облеклото не трябва да се променят — запиши същото за всички появи. +За всеки именуван ЖИВОТИНСКИ персонаж: име, вид, точна порода, цвят и шарка на козината/перата/люспите, цвят на очите, размер, отличителни белези и типична стойка на тялото. Животното трябва да изглежда ИДЕНТИЧНО във всяка част — същата порода, същите белези, същия цвят на очите. +Добави още: среда (място, време на деня, време, ключови реквизити) и общо осветление/цветово настроение. +Максимум 280 думи за ръководството. Без заглавия, само плътен текст. -After the character guide, write exactly this line by itself: +След ръководството напиши точно този ред самостоятелно: {{.StoryTitleSeparator}} -Then write a short comic book title in {{.Language}} using {{.Script}} script: 2-4 words that capture the story's theme and characters. Output only the title — no quotes, no punctuation, no explanation. +След това напиши кратко комиксово заглавие на {{.LanguageName}} с {{.ScriptName}}: 2-4 думи, които улавят темата и персонажите на историята. Изведи само заглавието — без кавички, без пунктуация, без обяснение. -After the title, write exactly this line by itself: +След заглавието напиши точно този ред самостоятелно: {{.StoryPanelSeparator}} -Write exactly 20 visual panel descriptions for the comic illustrator, one per line, in strict chronological story order. -Format each line exactly as: P{page}-{panel}: {description} +Напиши точно 20 визуални описания на панели за комиксовия илюстратор, по един ред, в строг хронологичен ред. +Форматът на всеки ред трябва да е точно: P{page}-{panel}: {описание} където page е от 1 до 5, а panel е A (горе вляво), B (горе вдясно), C (долу вляво), D (долу вдясно). -Each description is 1–2 sentences covering: WHO is in the panel, WHAT they are doing, WHERE they are, their expression or body language, and — REQUIRED for most panels — the exact {{.Language}} dialogue or thought text the character says/thinks, written in quotes. -At least 3 of the 4 panels on every page MUST include {{.Language}} dialogue in a speech bubble or thought bubble. The dialogue must come directly from the story and be in {{.Script}} script. -The 20 panels must retell the story from beginning to end — each page covers one story beat, each panel advances the action. -Do NOT repeat the same scene or camera angle on consecutive panels. -Примерен формат (замени с истинското съдържание на историята): +Всяко описание е от 1 до 2 изречения и трябва да включва: КОЙ е в панела, КАКВО прави, КЪДЕ се намира, какво е изражението или езикът на тялото му, и - ЗАДЪЛЖИТЕЛНО за повечето панели - точния диалог или мисъл на {{.LanguageName}}, написан в кавички. +Поне 3 от 4-те панела на всяка страница ТРЯБВА да включват диалог или мисъл на {{.LanguageName}} в балон за реплика или мисъл. Диалогът трябва да идва директно от историята и да е на {{.ScriptName}}. +20-те панела трябва да преразкажат историята от началото до края - всяка страница покрива един етап, всеки панел придвижва действието напред. +Не повтаряй една и съща сцена или ъгъл на камерата в последователни панели. +Примерен формат, замени с истинското съдържание на историята: P1-A: Мария излиза от входната си врата в утринната светлина, с чанта на рамо, и казва "Най-сетне!" в балон за реплика. -P1-B: Тя върви по оживена улица покрай паркирани коли, с слушалки в ушите, и мисли "Толкова хубав ден." в мисловен балон. +P1-B: Тя върви по оживена улица покрай паркирани коли, с слушалки в ушите, и мисли "Толкова хубав ден." в балон за мисъл. P1-C: Крупен план на ръката ѝ, която стиска голяма пареща чаша кафе, а баристата ѝ я подава с думите "Заповядайте!" в балон за реплика. P1-D: Мария спира при входа на парк и поглежда към зелените дървета отпред, като казва "Точно това ми трябваше." в балон за реплика. + +--- +Напиши история с дължина около 250 думи на {{.LanguageName}}, която естествено използва всички следните думи. +Историята трябва да е {{.Genre}} — не пиши безлична приказка. +Използвай всяка дума естествено, но не повтаряй списъка с думи, булетите или етикетите в текста. Върни САМО текста на историята — без заглавие, без заглавен ред, без обяснение. + +Думи за включване: +{{.Words}} diff --git a/prompts/story_page_prompt.md b/prompts/story_page_prompt.md index 81df066..1744fa6 100644 --- a/prompts/story_page_prompt.md +++ b/prompts/story_page_prompt.md @@ -1,22 +1,22 @@ -{{if .Language}}Български комикс: всички реплики, мисли, надписи и етикети в панелите трябва да са изцяло на {{.Script}}. Без латиница в самите панели. +{{if .Language}}Български комикс: всички реплики, мисли, надписи и етикети в панелите трябва да са изцяло на {{.ScriptName}}. Без латиница в самите панели. {{end}}{{.Words}} Стил: {{.Style}}.{{.RenderingRequirement}} -Комиксова страница {{.PageNum}} от {{.TotalPages}}. -{{if .Bible}}РЕФЕРЕНЦИЯ ЗА ПЕРСОНАЖИТЕ И СРЕДАТА (страница {{.PageNum}} от {{.TotalPages}} — следвай точно, без промяна на дрехите): +Сцена за тази част от комикса. +{{if .Bible}}РЕФЕРЕНЦИЯ ЗА ПЕРСОНАЖИТЕ И СРЕДАТА: {{.Bible}} {{end}}{{.PanelLayout}}Всеки панел е разделен с тънка черна линия. И 4-те панела трябва да са ясно различни сцени, а не едно непрекъснато изображение. Цялата площ трябва да бъде запълнена, без празни полета. ЗАДЪЛЖИТЕЛНИ БАЛОНИ С РЕПЛИКИ — това е комикс; героите трябва да говорят: - • Най-малко 3 от 4-те панела на всяка страница трябва да имат балон с реплика или мисъл. + • Най-малко 3 от 4-те панела на тази сцена трябва да имат балон с реплика или мисъл. • Ако описанието на панела включва цитирана реплика, изобрази я точно в балона. • Балоните за реплики са бели с черен контур и опашка към говорещия. • Балоните за мисли са облакоподобни с малки кръгчета към мислещия. - • Всички текстове в балоните са само на {{.Script}}. + • Всички текстове в балоните са само на {{.ScriptName}}. ВАРИАТИВНОСТ — всеки