diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-20 08:01:01 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-20 08:01:01 +0300 |
| commit | af2aa5e5efa82d3bc7cf5530e0a1eee72ba99244 (patch) | |
| tree | 609634b874bd4a82e5df7d9b56d8505cac14fdd6 /internal | |
| parent | a35ca6f40de73e93372ade6f2d74c231389799e2 (diff) | |
Fix task 25 follow-up: stop PNG prompt leaks and keep comics consistent
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/comic/artist.go | 28 | ||||
| -rw-r--r-- | internal/comic/comic_test.go | 96 | ||||
| -rw-r--r-- | internal/comic/image_validation.go | 68 | ||||
| -rw-r--r-- | internal/comic/localization_test.go | 26 | ||||
| -rw-r--r-- | internal/comic/types.go | 1 |
5 files changed, 205 insertions, 14 deletions
diff --git a/internal/comic/artist.go b/internal/comic/artist.go index 54f28cf..53f43f8 100644 --- a/internal/comic/artist.go +++ b/internal/comic/artist.go @@ -173,6 +173,12 @@ func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, labe for attempt := 1; attempt <= attempts; attempt++ { callCtx, cancel := withTimeout(ctx, helperTimeout) err := a.imageProvider.GenerateImage(callCtx, prompt, outputFile) + if err == nil { + if leakErr := validateImagePromptLeakageFn(callCtx, outputFile, label); leakErr != nil { + _ = os.Remove(outputFile) + err = leakErr + } + } cancel() if err == nil { return nil @@ -311,10 +317,10 @@ 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("MANDATORY PANEL LAYOUT — divide the image into exactly 4 panels in a 2×2 grid.\n") - sb.WriteString("Draw each panel EXACTLY as described below — these are the precise scenes to illustrate:\n") + sb.WriteString("ЗАДЪЛЖИТЕЛНО ОФОРМЛЕНИЕ НА ПАНЕЛИТЕ — раздели изображението точно на 4 панела в решетка 2×2.\n") + sb.WriteString("Оформи всеки панел ТОЧНО както е описано по-долу:\n") for i, label := range labels { - sb.WriteString(fmt.Sprintf(" • %s panel: %s\n", label, pagePanels[i])) + sb.WriteString(fmt.Sprintf(" • %s панел: %s\n", label, pagePanels[i])) } return sb.String() } @@ -327,19 +333,19 @@ func buildPanelLayout(section string, pagePanels []string) string { } excerpt += "…" } - return "MANDATORY PANEL LAYOUT — divide the image into exactly 4 panels in a 2×2 grid:\n" + - " • TOP-LEFT panel: scene 1 from the excerpt\n" + - " • TOP-RIGHT panel: scene 2 from the excerpt\n" + - " • BOTTOM-LEFT panel: scene 3 from the excerpt\n" + - " • BOTTOM-RIGHT panel: scene 4 from the excerpt\n" + - "Story excerpt (ALL panels must illustrate THIS excerpt only):\n\n" + excerpt + "\n" + 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" } func blurbBoxInstruction(blurb string) string { if strings.TrimSpace(blurb) == "" { - return "a rectangular text box (white or cream background, thin black border) near the bottom, styled like a classic back-cover synopsis box" + return "правоъгълно текстово поле (бял или кремав фон, тънка черна рамка) близо до дъното, в стил на класически синопсис на задната корица" } - return fmt.Sprintf("a rectangular text box (white or cream background, thin black border) near the bottom displaying this blurb text in italic type:\n %q", blurb) + return fmt.Sprintf("правоъгълно текстово поле (бял или кремав фон, тънка черна рамка) близо до дъното, което показва този текст в курсив:\n %q", blurb) } func errorsJoin(errs ...error) error { diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go index 3506303..3b89871 100644 --- a/internal/comic/comic_test.go +++ b/internal/comic/comic_test.go @@ -47,7 +47,7 @@ func TestBuildPanelLayoutUsesFallbackExcerpt(t *testing.T) { t.Parallel() got := buildPanelLayout("one two three four five", nil) - if !strings.Contains(got, "Story excerpt") { + if !strings.Contains(got, "Откъс от историята") { t.Fatalf("buildPanelLayout() = %q", got) } } @@ -142,6 +142,12 @@ func TestDrawComicPagesReturnsErrorWhenRenderFails(t *testing.T) { func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) { t.Parallel() + originalLeakValidation := validateImagePromptLeakageFn + validateImagePromptLeakageFn = func(context.Context, string, string) error { return nil } + t.Cleanup(func() { + validateImagePromptLeakageFn = originalLeakValidation + }) + tmpDir := t.TempDir() img := fakeImageProvider{t: t} genText := &scriptedTextProvider{responses: []string{ @@ -194,8 +200,11 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) { func TestRunnerPropagatesRenderFailures(t *testing.T) { originalSleep := sleep sleep = func(time.Duration) {} + originalLeakValidation := validateImagePromptLeakageFn + validateImagePromptLeakageFn = func(context.Context, string, string) error { return nil } t.Cleanup(func() { sleep = originalSleep + validateImagePromptLeakageFn = originalLeakValidation }) runner := NewRunner(&RunnerConfig{ @@ -226,6 +235,49 @@ func TestRunnerPropagatesRenderFailures(t *testing.T) { } } +func TestDrawComicPagesUsesOneStyleAcrossTheWholePDF(t *testing.T) { + originalLeakValidation := validateImagePromptLeakageFn + validateImagePromptLeakageFn = func(context.Context, string, string) error { return nil } + t.Cleanup(func() { + validateImagePromptLeakageFn = originalLeakValidation + }) + + renderer := &recordingPromptRenderer{} + artist := NewArtist(&ArtistConfig{ + ImageProvider: fakeImageProvider{t: t}, + Prompts: renderer, + OutputDir: t.TempDir(), + UltraRealistic: false, + }) + + if _, err := artist.DrawComicPages(context.Background(), "история", "библия", "slug", []WordEntry{{Word: "ябълка"}}, nil); err != nil { + t.Fatalf("DrawComicPages() error = %v", err) + } + + var style string + var promptCount int + for _, call := range renderer.calls { + if !isImagePromptTemplate(call.name) { + continue + } + promptCount++ + gotStyle, ok := call.data["Style"].(string) + if !ok || gotStyle == "" { + t.Fatalf("image prompt %q missing style: %#v", call.name, call.data) + } + if style == "" { + style = gotStyle + continue + } + if gotStyle != style { + t.Fatalf("image prompt styles diverged: first=%q later=%q in %q", style, gotStyle, call.name) + } + } + if promptCount == 0 { + t.Fatal("no image prompts recorded") + } +} + func TestConvertToStereoFallsBackToCopyWhenFFmpegMissing(t *testing.T) { originalLookPath := lookPath lookPath = func(string) (string, error) { @@ -274,6 +326,48 @@ func (fakePromptRenderer) RenderPrompt(name string, data any) (string, error) { } } +type recordingPromptRenderer struct { + calls []recordedPromptCall +} + +type recordedPromptCall struct { + name string + data map[string]any +} + +func (r *recordingPromptRenderer) RenderPrompt(name string, data any) (string, error) { + if m, ok := data.(map[string]any); ok { + copyData := make(map[string]any, len(m)) + for k, v := range m { + copyData[k] = v + } + r.calls = append(r.calls, recordedPromptCall{name: name, data: copyData}) + } else { + r.calls = append(r.calls, recordedPromptCall{name: name}) + } + switch name { + case storySystemPromptTemplate: + return "system prompt", nil + case storyPromptTemplate, storyFullPromptTemplate: + return "prompt", nil + case coverPromptTemplate, storyPagePromptTemplate, galleryPagePromptTemplate, backCoverPromptTemplate: + return "image prompt", nil + case blurbSystemTemplate, introSystemTemplate, conclusionSystemTemplate: + return "teaser prompt", nil + default: + return "", errors.New("unexpected template") + } +} + +func isImagePromptTemplate(name string) bool { + switch name { + case coverPromptTemplate, storyPagePromptTemplate, galleryPagePromptTemplate, backCoverPromptTemplate: + return true + default: + return false + } +} + type fakeTextProvider struct{ text string } func (f fakeTextProvider) Name() string { return "fake-text" } diff --git a/internal/comic/image_validation.go b/internal/comic/image_validation.go new file mode 100644 index 0000000..475cb96 --- /dev/null +++ b/internal/comic/image_validation.go @@ -0,0 +1,68 @@ +package comic + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +var imageLeakMarkers = []string{ + "mandatory language rule", + "this is a bulgarian comic book", + "bulgarian comic book", + "mandatory panel layout", + "mandatory speech bubbles", + "ultra-realistic rendering", + "final lock", + "photorealism", + "character & setting reference", + "this is a text-free character gallery page", + "story page", + "gallery page", + "back cover", + "cover lines", + "story teaser", + "story ending hint", + "art style:", + "no panel grid", + "no speech bubbles", + "no text of any kind", +} + +var validateImagePromptLeakageFn = validateImagePromptLeakage + +func validateImagePromptLeakage(ctx context.Context, outputFile, label string) error { + if ctx == nil { + ctx = context.Background() + } + + tesseractPath, err := exec.LookPath("tesseract") + if err != nil { + return nil + } + + cmd := exec.CommandContext(ctx, tesseractPath, outputFile, "stdout", "-l", "eng", "--psm", "11") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &bytes.Buffer{} + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s OCR failed: %w", label, err) + } + + ocr := strings.ToLower(out.String()) + if marker, ok := findImageLeakMarker(ocr); ok { + return fmt.Errorf("%s contains prompt leakage marker %q", label, marker) + } + return nil +} + +func findImageLeakMarker(text string) (string, bool) { + for _, marker := range imageLeakMarkers { + if strings.Contains(text, marker) { + return marker, true + } + } + return "", false +} diff --git a/internal/comic/localization_test.go b/internal/comic/localization_test.go index a1609f3..8855732 100644 --- a/internal/comic/localization_test.go +++ b/internal/comic/localization_test.go @@ -1,6 +1,9 @@ package comic -import "testing" +import ( + "strings" + "testing" +) func TestLocalizedBrandName(t *testing.T) { t.Parallel() @@ -40,3 +43,24 @@ func TestValidateNoPromptLeakage(t *testing.T) { t.Fatal("validateNoPromptLeakage() error = nil, want prompt leakage rejection") } } + +func TestFindImageLeakMarker(t *testing.T) { + t.Parallel() + + if marker, ok := findImageLeakMarker("calm text"); ok || marker != "" { + t.Fatalf("findImageLeakMarker() = %q, %v, want no match", marker, ok) + } + if marker, ok := findImageLeakMarker("mandatory language rule"); !ok || marker != "mandatory language rule" { + t.Fatalf("findImageLeakMarker() = %q, %v, want mandatory language rule match", marker, ok) + } +} + +func TestComicStylesStayComic(t *testing.T) { + t.Parallel() + + for _, style := range comicStyles { + if strings.Contains(strings.ToLower(style), "ultra realistic") || strings.Contains(strings.ToLower(style), "photograph") { + t.Fatalf("comicStyles contains hybrid or photorealistic style: %q", style) + } + } +} diff --git a/internal/comic/types.go b/internal/comic/types.go index 6689095..ba9a83d 100644 --- a/internal/comic/types.go +++ b/internal/comic/types.go @@ -82,7 +82,6 @@ var ( } comicStyles = []string{ - "ultra realistic comic strip with photographic detail and dramatic lighting", "classic American comic book with bold ink outlines, halftone dots, and primary colors", "Japanese manga with clean linework, expressive eyes, and speed lines", "retro 1960s pop art in the style of Roy Lichtenstein with thick outlines and Ben-Day dots", |
