diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-03 23:02:41 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-03 23:02:41 +0300 |
| commit | b9ee95a66b94c8dc7fca946a2d464ed5d3ad86b1 (patch) | |
| tree | 1170581406c1bd461430e85d749573152a6596c8 /internal/image | |
| parent | b0e8ccfebc5094829054021250f08ddb93c9978e (diff) | |
feat: comic pipeline v0.10.0 — iterative chaining, title slugs, comics subfolder, 16:9v0.10.0
- Iterative image chaining: each page receives cover + prev page as pixel-level
reference (multimodal) so the model copies appearance directly, not from text alone
- --story-theme flag overrides random genre pick per run
- Gemini now generates a short comic title (---COMIC TITLE--- separator) used as
the output slug; all files named <slug>_cover.png, <slug>_page_1.png, etc.
- All comic output stored under comics/<slug>/ subdirectory
- Aspect ratio changed to 16:9 (ThinkPad X1 Gen 9 screen fit; 16:10 not supported)
- Bulgarian language rule moved to top of every prompt for stronger compliance
- Dynamic story-specific cover masthead + publisher logo badge matching story genre
- --story-theme "a thrilling space..." forces genre without touching random pool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/image')
| -rw-r--r-- | internal/image/nanobanana.go | 66 | ||||
| -rw-r--r-- | internal/image/search.go | 9 |
2 files changed, 72 insertions, 3 deletions
diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go index 8bcd945..befe74c 100644 --- a/internal/image/nanobanana.go +++ b/internal/image/nanobanana.go @@ -114,7 +114,16 @@ func (c *NanoBananaClient) Search(ctx context.Context, opts *SearchOptions) ([]S fmt.Printf("Nano Banana Image Generation Prompt (%d chars): %s\n", len(prompt), prompt) fmt.Printf("Nano Banana Image Generation: Using model '%s' with aspect ratio '%s'\n", c.modelName(), aspectRatio) - imageBytes, mimeType, err := nanoBananaGenerateImage(ctx, c, prompt, aspectRatio) + var imageBytes []byte + var mimeType string + // Use multimodal chaining when reference images are available — this keeps + // character appearance consistent across pages far more reliably than + // injecting a text-only character bible. + if len(opts.ReferenceImages) > 0 { + imageBytes, mimeType, err = c.generateImageWithRefs(ctx, prompt, aspectRatio, opts.ReferenceImages) + } else { + imageBytes, mimeType, err = nanoBananaGenerateImage(ctx, c, prompt, aspectRatio) + } if err != nil { if searchErr, ok := err.(*SearchError); ok { return nil, searchErr @@ -420,6 +429,61 @@ func (c *NanoBananaClient) generateImage(ctx context.Context, prompt, aspectRati return imageBytes, mimeType, nil } +// generateImageWithRefs sends reference images alongside the text prompt so the +// model can match character appearance from the existing pages. This implements +// the iterative chaining technique: each new page is conditioned on the visual +// look established by previous pages rather than relying on text descriptions alone. +func (c *NanoBananaClient) generateImageWithRefs(ctx context.Context, prompt, aspectRatio string, refs [][]byte) ([]byte, string, error) { + if aspectRatio == "" { + aspectRatio = nanoBananaAspectRatio + } + cfg := &genai.GenerateContentConfig{ + ResponseModalities: []string{string(genai.ModalityImage)}, + ImageConfig: &genai.ImageConfig{AspectRatio: aspectRatio}, + } + + // Build multimodal content: reference image bytes first, then the instruction + // + prompt text. Leading with images means they are processed before the text. + parts := make([]*genai.Part, 0, len(refs)+1) + for _, ref := range refs { + if len(ref) > 0 { + parts = append(parts, &genai.Part{ + InlineData: &genai.Blob{MIMEType: "image/png", Data: ref}, + }) + } + } + refNote := fmt.Sprintf( + "The %d reference image(s) above show the EXACT character appearance that must be preserved. "+ + "Every character — same face, same age, same hair, same clothing, same animal breed and markings — "+ + "must look IDENTICAL in the new image. Now generate:\n\n", + len(refs), + ) + parts = append(parts, &genai.Part{Text: refNote + prompt}) + + resp, err := c.client.Models.GenerateContent(ctx, c.modelName(), + []*genai.Content{{Role: string(genai.RoleUser), Parts: parts}}, + cfg, + ) + if err != nil { + return nil, "", &SearchError{ + Provider: nanoBananaSource, + Code: "API_ERROR", + Message: fmt.Sprintf("failed to generate image with refs: %v", err), + } + } + + imageBytes, mimeType, err := extractGeneratedImage(resp) + if err != nil { + return nil, "", &SearchError{ + Provider: nanoBananaSource, + Code: "NO_RESULTS", + Message: err.Error(), + } + } + + return imageBytes, mimeType, nil +} + func extractGeneratedImage(response *genai.GenerateContentResponse) ([]byte, string, error) { if response == nil { return nil, "", fmt.Errorf("no response from Gemini") diff --git a/internal/image/search.go b/internal/image/search.go index 8de97e0..80d5e33 100644 --- a/internal/image/search.go +++ b/internal/image/search.go @@ -27,8 +27,13 @@ type SearchOptions struct { Page int // Page number (1-based) ImageType string // Type: "photo", "illustration", "vector", "all" Orientation string // Orientation: "horizontal", "vertical", "all" - CustomPrompt string // Custom prompt for AI image generation - AspectRatio string // Override aspect ratio (e.g. "9:16"); empty = provider default + CustomPrompt string // Custom prompt for AI image generation + AspectRatio string // Override aspect ratio (e.g. "9:16"); empty = provider default + // ReferenceImages holds raw PNG bytes of previously generated images. + // When non-empty, the NanoBanana client sends them as multimodal content + // alongside the text prompt so the model can match character appearance + // across pages (iterative chaining technique). + ReferenceImages [][]byte } // DefaultSearchOptions returns sensible defaults for Bulgarian word searches |
