summaryrefslogtreecommitdiff
path: root/internal/image
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-03 23:02:41 +0300
committerPaul Buetow <paul@buetow.org>2026-04-03 23:02:41 +0300
commitb9ee95a66b94c8dc7fca946a2d464ed5d3ad86b1 (patch)
tree1170581406c1bd461430e85d749573152a6596c8 /internal/image
parentb0e8ccfebc5094829054021250f08ddb93c9978e (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.go66
-rw-r--r--internal/image/search.go9
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