diff options
| -rw-r--r-- | internal/story/artist.go | 87 | ||||
| -rw-r--r-- | internal/story/generator.go | 109 | ||||
| -rw-r--r-- | internal/story/runner.go | 14 | ||||
| -rw-r--r-- | internal/version.go | 2 |
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" |
