summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/story/artist.go87
-rw-r--r--internal/story/generator.go109
-rw-r--r--internal/story/runner.go14
-rw-r--r--internal/version.go2
4 files changed, 166 insertions, 46 deletions
diff --git a/internal/story/artist.go b/internal/story/artist.go
index e3a0fb6..3c66978 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -190,7 +190,11 @@ func NewArtist(config *ArtistConfig) *Artist {
// story page prompt so the image model visually features and labels them in panels.
// titleSlug is used as the file-name prefix; it must already be a safe slug.
// Returns the list of saved image paths in order.
-func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entries []batch.WordEntry) ([]string, error) {
+// DrawComicPages generates all 12 pages of the comic (cover + 5 story + 5 gallery + back).
+// panelScript is a [page][panel] slice of explicit visual descriptions produced by Gemini;
+// when non-nil it drives each panel directly instead of raw story text excerpts,
+// ensuring the illustrations follow the narrative chronologically and coherently.
+func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entries []batch.WordEntry, panelScript [][]string) ([]string, error) {
style := a.style
if style == "" {
// Ultra-realistic mode uses photography-only language so the image model
@@ -231,13 +235,17 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr
}
// 2. Story pages — each receives cover + previous page as refs.
- // Failures are non-fatal: up to pageMaxRetries attempts per page, then a
- // warning is logged and generation continues with the next page so the PDF
- // always contains as many pages as the API manages to produce.
+ // When a panelScript is available, panels are driven by explicit visual descriptions
+ // so the illustrations follow the story chronologically. Raw text excerpts are used
+ // as fallback when the script is absent or incomplete for a given page.
sections := splitIntoSections(storyText, storyPageCount)
for i, section := range sections {
pageNum := i + 1
- prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries, a.renderReq())
+ var pagePanels []string
+ if i < len(panelScript) {
+ pagePanels = panelScript[i]
+ }
+ prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries, a.renderReq(), pagePanels)
fileName := fmt.Sprintf("%s_page_%d", titleSlug, pageNum)
p, pageBytes := a.loadOrGenerate(fileName, func() (string, []byte) {
return a.generateStoryPage(prompt, fileName, pageNum, recentRefs)
@@ -520,18 +528,15 @@ func buildCoverPrompt(storyText, style, bible, renderReq string) string {
// labels each word visually inside the panels — making each page a learning tool.
// The Bulgarian language requirement is placed at the very top so it is processed
// before all other instructions.
-func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible string, entries []batch.WordEntry, renderReq string) string {
- excerpt := strings.TrimSpace(section)
- if len(excerpt) > comicPromptMaxChars {
- excerpt = excerpt[:comicPromptMaxChars]
- if idx := strings.LastIndex(excerpt, " "); idx > 0 {
- excerpt = excerpt[:idx]
- }
- excerpt += "…"
- }
-
+// buildStoryPagePrompt constructs the image prompt for one 4-panel story page.
+// When pagePanels contains explicit visual descriptions (from the Gemini panel
+// script), those drive each panel directly for narrative coherence. Otherwise
+// the raw story excerpt is used as a fallback.
+func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible string, entries []batch.WordEntry, renderReq string, pagePanels []string) string {
bibleBlock := bibleSection(bible, fmt.Sprintf("story page %d of %d", pageNum, totalPages))
vocabBlock := buildVocabBlock(entries)
+ panelLayout := buildPanelLayout(section, pagePanels)
+
return fmt.Sprintf(
// Lead with the hard language constraint so it is processed first.
"ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+
@@ -541,12 +546,8 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible
"English text anywhere in the panels is STRICTLY FORBIDDEN — use ONLY Bulgarian.\n\n"+
"%s"+ // vocabulary block — before art style so it is never truncated
"Art style: %s.%s\n"+
- "Comic book story page %d of %d. "+
- "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"+
+ "Comic book story page %d of %d.\n"+
+ "%s"+ // panel layout (script-driven or excerpt-driven)
"Each panel is separated by a thin black gutter line. "+
"All 4 panels must be clearly distinct scenes — NOT one continuous image. "+
"The full image area must be covered by the 4 panels with no empty space.\n"+
@@ -557,16 +558,50 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible
renderReq+
"STRICT CONSISTENCY RULES — apply to every single panel:\n"+
" • Human characters: identical face, AGE APPEARANCE, hair colour/style, and clothing "+
- "to the reference — a child must never look older or younger than defined.\n"+
+ "to the reference — a child must never look older or younger as defined.\n"+
" • Animal characters: identical breed, fur colour/pattern, markings, and eye colour — "+
"NEVER substitute a different animal or a generic version of the species.\n"+
- " • Clothing changes only if this page's excerpt explicitly describes a change.\n"+
- " • LANGUAGE: all speech, thought, and caption text — Bulgarian Cyrillic ONLY.\n"+
- "Story excerpt (ALL panels must illustrate THIS excerpt only — no other part of the story):\n\n%s",
- vocabBlock, style, bibleBlock, pageNum, totalPages, excerpt,
+ " • Clothing changes only if this page's description explicitly describes a change.\n"+
+ " • LANGUAGE: all speech, thought, and caption text — Bulgarian Cyrillic ONLY.\n",
+ vocabBlock, style, bibleBlock, pageNum, totalPages, panelLayout,
)
}
+// buildPanelLayout returns the MANDATORY PANEL LAYOUT block.
+// When pagePanels are provided (from the Gemini panel script) each panel gets
+// an explicit visual instruction; otherwise the raw excerpt is used so every
+// panel can interpret it freely.
+func buildPanelLayout(section string, pagePanels []string) string {
+ labels := [4]string{"TOP-LEFT", "TOP-RIGHT", "BOTTOM-LEFT", "BOTTOM-RIGHT"}
+
+ // Script-driven path: all 4 panel descriptions are non-empty.
+ 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")
+ for i, label := range labels {
+ sb.WriteString(fmt.Sprintf(" • %s panel: %s\n", label, pagePanels[i]))
+ }
+ return sb.String()
+ }
+
+ // Fallback: excerpt-driven path when the panel script is absent or incomplete.
+ excerpt := strings.TrimSpace(section)
+ if len(excerpt) > comicPromptMaxChars {
+ excerpt = excerpt[:comicPromptMaxChars]
+ if idx := strings.LastIndex(excerpt, " "); idx > 0 {
+ excerpt = excerpt[:idx]
+ }
+ 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"
+}
+
// buildVocabBlock formats the vocabulary entries as a mandatory visual instruction
// block. Each word must appear as a clearly labelled object or element in at least
// one panel — making the comic page a vocabulary learning tool as well as a story page.
diff --git a/internal/story/generator.go b/internal/story/generator.go
index 08bda2b..a272893 100644
--- a/internal/story/generator.go
+++ b/internal/story/generator.go
@@ -36,6 +36,17 @@ const (
// deliver a short English comic title. parseGenerateResult extracts it for
// use as the output directory name and file prefix.
storyTitleSeparator = "---COMIC TITLE---"
+
+ // storyPanelSeparator marks the start of the 20-line panel visual script.
+ // The script lists one sentence per panel (P1-A … P5-D) so the artist can
+ // draw each panel from an explicit description rather than guessing from
+ // raw prose — this is the primary mechanism for narrative coherence.
+ storyPanelSeparator = "---PANEL SCRIPT---"
+
+ // storyPageCount duplicated here so generator.go can reference it without
+ // importing artist.go (both are in package story; used in buildStoryPromptFull).
+ storyPagesInScript = 5
+ storyPanelsPerPage = 4
)
// storyGenres is the pool of genres picked randomly each run to keep stories
@@ -73,13 +84,14 @@ func resolveGenre(theme string) string {
return pickStoryGenre()
}
-// GenerateResult holds the story text, character bible, and comic title from a
-// single combined Gemini call. All three are produced in the same request —
-// no second API call, no rate limits.
+// GenerateResult holds the story text, character bible, comic title, and panel
+// script from a single combined Gemini call. All four are produced in the same
+// request — no second API call, no rate limits.
type GenerateResult struct {
- StoryText string // Bulgarian vocabulary story
- Bible string // English character consistency guide for illustrators
- Title string // Short English comic title (2-4 words), used as filename slug
+ StoryText string // Bulgarian vocabulary story
+ Bible string // English character consistency guide for illustrators
+ Title string // Short English comic title (2-4 words), used as filename slug
+ PanelScript [][]string // [page][panel] explicit visual description, 5 pages × 4 panels
}
// Config holds generator settings and API credentials.
@@ -204,7 +216,7 @@ func (g *Generator) GenerateFull(entries []batch.WordEntry) (GenerateResult, err
return parseGenerateResult(combined), nil
}
-// parseGenerateResult splits the combined model output on the two separators.
+// parseGenerateResult splits the combined model output on the three separators.
// Format expected:
//
// <story text>
@@ -212,9 +224,12 @@ func (g *Generator) GenerateFull(entries []batch.WordEntry) (GenerateResult, err
// <bible>
// ---COMIC TITLE---
// <title>
+// ---PANEL SCRIPT---
+// P1-A: ...
+// ...
+// P5-D: ...
//
-// If a separator is missing, the corresponding field is left empty and parsing
-// is best-effort so the pipeline can still proceed without all three sections.
+// Missing separators are tolerated — the pipeline continues with empty fields.
func parseGenerateResult(combined string) GenerateResult {
bibleIdx := strings.Index(combined, storyBibleSeparator)
if bibleIdx < 0 {
@@ -226,17 +241,59 @@ func parseGenerateResult(combined string) GenerateResult {
titleIdx := strings.Index(afterBible, storyTitleSeparator)
if titleIdx < 0 {
- return GenerateResult{StoryText: story, Bible: afterBible}
+ return GenerateResult{StoryText: story, Bible: strings.TrimSpace(afterBible)}
}
bible := strings.TrimSpace(afterBible[:titleIdx])
- title := strings.TrimSpace(afterBible[titleIdx+len(storyTitleSeparator):])
- // Keep only the first line of the title in case the model adds a blank line.
+ afterTitle := strings.TrimSpace(afterBible[titleIdx+len(storyTitleSeparator):])
+
+ panelIdx := strings.Index(afterTitle, storyPanelSeparator)
+ var title, panelText string
+ if panelIdx < 0 {
+ title = afterTitle
+ } else {
+ title = afterTitle[:panelIdx]
+ panelText = afterTitle[panelIdx+len(storyPanelSeparator):]
+ }
+ // Keep only the first non-empty line of the title.
if nl := strings.IndexByte(title, '\n'); nl >= 0 {
- title = strings.TrimSpace(title[:nl])
+ title = title[:nl]
+ }
+ title = strings.TrimSpace(title)
+
+ return GenerateResult{
+ StoryText: story,
+ Bible: bible,
+ Title: title,
+ PanelScript: parsePanelScript(panelText),
+ }
+}
+
+// parsePanelScript parses the panel script block into a [page][panel] slice.
+// Lines must match the format "P{1-5}-{A-D}: description".
+// Missing or malformed lines produce empty strings so callers can fall back
+// to raw story excerpts for those panels.
+func parsePanelScript(text string) [][]string {
+ script := make([][]string, storyPagesInScript)
+ for i := range script {
+ script[i] = make([]string, storyPanelsPerPage)
}
- return GenerateResult{StoryText: story, Bible: bible, Title: title}
+ panelIndex := map[byte]int{'A': 0, 'B': 1, 'C': 2, 'D': 3}
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(line)
+ // Expected prefix: "P1-A: " through "P5-D: "
+ if len(line) < 7 || line[0] != 'P' || line[2] != '-' || line[4] != ':' {
+ continue
+ }
+ page := int(line[1] - '1') // '1'→0 … '5'→4
+ panel, ok := panelIndex[line[3]]
+ if !ok || page < 0 || page >= storyPagesInScript {
+ continue
+ }
+ script[page][panel] = strings.TrimSpace(line[5:])
+ }
+ return script
}
// buildStoryPrompt creates the simple story-only prompt used by Generate.
@@ -287,9 +344,33 @@ func buildStoryPromptFull(entries []batch.WordEntry, theme string) string {
sb.WriteString("story's theme and characters (e.g. 'Stardust Explorers', 'The Clockwork Dragon', ")
sb.WriteString("'Mystery at Midnight'). Output only the title — no quotes, no punctuation, no explanation.\n")
+ sb.WriteString("\nAfter the title, write exactly this line by itself:\n")
+ sb.WriteString(storyPanelSeparator)
+ sb.WriteString(buildPanelScriptPrompt())
+
return sb.String()
}
+// buildPanelScriptPrompt returns the instructions for the 20-panel visual script
+// section appended after ---PANEL SCRIPT---. Kept separate so buildStoryPromptFull
+// stays under 50 lines.
+func buildPanelScriptPrompt() string {
+ return "\n\nWrite exactly 20 visual panel descriptions for the comic illustrator, " +
+ "one per line, in strict chronological story order.\n" +
+ "Format each line exactly as: P{page}-{panel}: {description}\n" +
+ "where page is 1–5 and panel is A (top-left), B (top-right), C (bottom-left), D (bottom-right).\n" +
+ "Each description is 1–2 sentences: WHO is in the panel, WHAT they are doing, " +
+ "WHERE they are, and their expression or body language. Be vivid and specific.\n" +
+ "The 20 panels must retell the story from beginning to end — " +
+ "each page covers one story beat, each panel advances the action.\n" +
+ "Do NOT repeat the same scene or camera angle on consecutive panels.\n" +
+ "Example format (replace with actual story content):\n" +
+ "P1-A: Maria walks out her front door into morning sunlight, laptop bag on shoulder, looking relieved.\n" +
+ "P1-B: She strides down a busy city street past parked cars, headphones in, smiling.\n" +
+ "P1-C: Close-up of her hand gripping a large steaming coffee cup with both hands.\n" +
+ "P1-D: Maria pauses at a park entrance, gazing at the green trees ahead with anticipation.\n"
+}
+
// slugify converts a comic title into a safe directory/file name component.
// It lowercases, replaces whitespace with hyphens, and removes everything that
// is not an ASCII letter, digit, or hyphen. Falls back to "comic" if the result
diff --git a/internal/story/runner.go b/internal/story/runner.go
index e5e9aa5..c9571a6 100644
--- a/internal/story/runner.go
+++ b/internal/story/runner.go
@@ -173,7 +173,7 @@ func (r *Runner) Run(batchFile string) error {
fmt.Fprintf(os.Stderr, "Warning: could not write theme file: %v\n", err)
}
- r.drawComicPages(result.StoryText, result.Bible, slug, entries)
+ r.drawComicPages(result.StoryText, result.Bible, slug, entries, result.PanelScript)
// Narration is opt-in (--narrate flag). Skip entirely when not requested
// so runs complete faster and don't consume TTS quota unnecessarily.
@@ -184,12 +184,16 @@ func (r *Runner) Run(batchFile string) error {
return r.handleNarration(result.StoryText, slug, comicsDir)
}
-// drawComicPages generates the 5 comic images and assembles them into a PDF.
-// entries carries the vocabulary words so panels can visually feature and label them.
+// drawComicPages generates all 12 comic pages and assembles them into a PDF.
+// panelScript carries the explicit per-panel visual descriptions from Gemini so
+// each panel illustrates the correct story beat in narrative order.
// Errors are non-fatal — story.txt is always accessible regardless of image failures.
-func (r *Runner) drawComicPages(storyText, bible, titleSlug string, entries []batch.WordEntry) {
+func (r *Runner) drawComicPages(storyText, bible, titleSlug string, entries []batch.WordEntry, panelScript [][]string) {
fmt.Printf("Generating %d comic pages...\n", storyPageCount+galleryPageCount+2) // cover + story + gallery + back
- paths, err := r.artist.DrawComicPages(storyText, bible, titleSlug, entries)
+ if len(panelScript) > 0 {
+ fmt.Println(" Panel script ready — panels will follow narrative order.")
+ }
+ paths, err := r.artist.DrawComicPages(storyText, bible, titleSlug, entries, panelScript)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: comic page generation failed: %v\n", err)
}
diff --git a/internal/version.go b/internal/version.go
index a9f1a2e..03f6bf6 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,3 +1,3 @@
package internal
-const Version = "0.24.0"
+const Version = "0.25.0"