diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-04 11:25:11 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-04 11:25:11 +0300 |
| commit | 03f2ca528a34a7e099f1db8d5a931136af2a4652 (patch) | |
| tree | 9a6f79220e85e3bacbb0e693ad7504529269ceae /internal/story | |
| parent | 793d7b3c61a403a8c03f1eb44df741c6951444e8 (diff) | |
feat: random ultra-realistic/comic style, --no-ultra-realistic flag, updated READMEv0.13.0
- Random 50/50 rendering mode per run (ultra-realistic or standard comic style)
- --no-ultra-realistic flag forces standard comic style when set
- renderingRequirement const centralised and applied to all image prompts via DRY renderReq() method
- Log rendering mode chosen at generation time
- Update README: correct page count (10), output filenames, all --story flags documented
- Bump version to 0.13.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/story')
| -rw-r--r-- | internal/story/artist.go | 69 | ||||
| -rw-r--r-- | internal/story/runner.go | 20 |
2 files changed, 64 insertions, 25 deletions
diff --git a/internal/story/artist.go b/internal/story/artist.go index 6e69435..fd04107 100644 --- a/internal/story/artist.go +++ b/internal/story/artist.go @@ -61,6 +61,7 @@ const ( // renderingRequirement is appended to every image prompt (cover, story pages, // back cover, gallery) to push the model toward photorealistic output even // within a comic grid layout. Centralised here so it is easy to tune. + // Omitted when Artist.ultraRealistic is false (--no-ultra-realistic flag). renderingRequirement = "RENDERING REQUIREMENT: every panel and illustration must look " + "like a real photograph — photorealistic skin texture, fabric detail, lighting, " + "and environment. NOT a drawing, painting, or illustration. Real-world photo quality.\n" @@ -120,26 +121,33 @@ type ArtistConfig struct { TextModel string // NanoBanana text/prompt model OutputDir string // target directory; defaults to "." Style string // overrides the random art-style pick when non-empty + // UltraRealistic controls whether renderingRequirement is injected into every + // prompt. Default true (ultra-realistic). Set false via --no-ultra-realistic + // to produce standard comic-book style output without the photo requirement. + UltraRealistic bool } // Artist generates comic-book pages that illustrate the story. type Artist struct { - nbClient image.ImageClient - apiKey string // used for the character-bible Gemini call - outputDir string - style string + nbClient image.ImageClient + apiKey string // used for the character-bible Gemini call + outputDir string + style string + ultraRealistic bool // false → omit renderingRequirement from all prompts } // NewArtist creates an Artist backed by the NanoBanana image generator. func NewArtist(config *ArtistConfig) *Artist { dir := "." var apiKey, style string + ultraRealistic := true // default on var nbConfig *image.NanoBananaConfig if config != nil { dir = orDefault(config.OutputDir, ".") apiKey = config.APIKey style = config.Style + ultraRealistic = config.UltraRealistic nbConfig = &image.NanoBananaConfig{ APIKey: config.APIKey, Model: config.Model, @@ -148,10 +156,11 @@ func NewArtist(config *ArtistConfig) *Artist { } return &Artist{ - nbClient: image.NewNanoBananaClient(nbConfig), - apiKey: apiKey, - outputDir: dir, - style: style, + nbClient: image.NewNanoBananaClient(nbConfig), + apiKey: apiKey, + outputDir: dir, + style: style, + ultraRealistic: ultraRealistic, } } @@ -173,6 +182,11 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr style = pickStyle() } fmt.Printf(" Comic style: %s\n", style) + if a.ultraRealistic { + fmt.Println(" Rendering mode: ultra-realistic (photorealistic panels)") + } else { + fmt.Println(" Rendering mode: standard comic style") + } bible, blurb := a.resolveHelperTexts(storyText, prebuiltBible) @@ -186,7 +200,7 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr // 1. Cover — generated without refs (it is the visual baseline). // Retried up to pageMaxRetries times; failure is non-fatal but the cover // is omitted from the PDF and no anchor reference is established. - p, coverBytes := a.generatePageWithRetry(buildCoverPrompt(storyText, style, bible), titleSlug+"_cover", nil, "cover page") + p, coverBytes := a.generatePageWithRetry(buildCoverPrompt(storyText, style, bible, a.renderReq()), titleSlug+"_cover", nil, "cover page") if p != "" { paths = append(paths, p) recentRefs = appendRef(recentRefs, coverBytes) // cover becomes the anchor reference @@ -199,7 +213,7 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr sections := splitIntoSections(storyText, storyPageCount) for i, section := range sections { pageNum := i + 1 - prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries) + prompt := buildStoryPagePrompt(section, pageNum, storyPageCount, style, bible, entries, a.renderReq()) fileName := fmt.Sprintf("%s_page_%d", titleSlug, pageNum) p, pageBytes := a.generateStoryPage(prompt, fileName, pageNum, recentRefs) if p != "" { @@ -213,7 +227,7 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr // They act as alternative covers and use the accumulated refs for consistency. for i := range galleryPageCount { galleryNum := i + 1 - prompt := buildGalleryPagePrompt(style, bible, galleryNum) + prompt := buildGalleryPagePrompt(style, bible, galleryNum, a.renderReq()) fileName := fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum) gp, galleryBytes := a.generatePageWithRetry(prompt, fileName, recentRefs, fmt.Sprintf("gallery page %d/%d", galleryNum, galleryPageCount)) @@ -225,7 +239,7 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr // 4. Back cover — receives the same rolling refs as the last gallery page. // Retried up to pageMaxRetries times; failure is non-fatal. - p, _ = a.generatePageWithRetry(buildBackCoverPrompt(storyText, style, bible, blurb), titleSlug+"_back", recentRefs, "back cover") + p, _ = a.generatePageWithRetry(buildBackCoverPrompt(storyText, style, bible, blurb, a.renderReq()), titleSlug+"_back", recentRefs, "back cover") if p != "" { paths = append(paths, p) } @@ -388,8 +402,17 @@ func (a *Artist) generateSinglePage(prompt, fileNamePattern string, refs [][]byt return savedPath, imgBytes, nil } +// renderReq returns the renderingRequirement string when ultraRealistic is true, +// or an empty string when --no-ultra-realistic is set. Used in all prompt builders. +func (a *Artist) renderReq() string { + if a.ultraRealistic { + return renderingRequirement + } + return "" +} + // buildCoverPrompt constructs the front-cover image prompt. -func buildCoverPrompt(storyText, style, bible string) string { +func buildCoverPrompt(storyText, style, bible, renderReq string) string { // Use a short excerpt as a teaser on the cover prompt. teaser := strings.TrimSpace(storyText) if len(teaser) > 300 { @@ -407,7 +430,7 @@ func buildCoverPrompt(storyText, style, bible string) string { "All text on the cover (cover lines, banners, labels) MUST be in Bulgarian "+ "Cyrillic script. The masthead title must also be rendered in a striking comic-book font.\n\n"+ "Art style: %s.%s\n"+ - renderingRequirement+ + renderReq+ "TRADITIONAL COMIC BOOK FRONT COVER — portrait orientation, single full-bleed illustration.\n"+ "NO panel grid. NO speech bubbles.\n"+ "MANDATORY MASTHEAD — the most important visual element on this cover:\n"+ @@ -444,7 +467,7 @@ func buildCoverPrompt(storyText, style, bible 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) string { +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] @@ -474,7 +497,7 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible "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"+ - renderingRequirement+ + 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"+ @@ -512,7 +535,7 @@ func buildVocabBlock(entries []batch.WordEntry) string { // buildBackCoverPrompt constructs the back-cover image prompt. // blurb is an English marketing summary generated by Gemini; when non-empty it is // embedded verbatim in the blurb-box instruction so the image model renders it. -func buildBackCoverPrompt(storyText, style, bible, blurb string) string { +func buildBackCoverPrompt(storyText, style, bible, blurb, renderReq string) string { // Use the last ~200 chars of the story as a visual hint for the scene. ending := strings.TrimSpace(storyText) if len(ending) > 200 { @@ -540,7 +563,7 @@ func buildBackCoverPrompt(storyText, style, bible, blurb string) string { "All visible text (blurb box, labels, banners) MUST be in Bulgarian Cyrillic script. "+ "English text anywhere on the back cover is STRICTLY FORBIDDEN.\n\n"+ "Art style: %s.%s\n"+ - renderingRequirement+ + renderReq+ "TRADITIONAL COMIC BOOK BACK COVER — portrait orientation, single full-bleed illustration.\n"+ "NO panel grid. NO speech bubbles.\n"+ "Layout rules (must follow exactly):\n"+ @@ -571,12 +594,12 @@ var galleryPoses = []string{ // buildGalleryPagePrompt constructs a text-free close-up character art page prompt. // galleryNum (1-based) selects the pose from galleryPoses so each page is distinct. // No text, no panels, no speech bubbles — pure full-bleed illustration. -func buildGalleryPagePrompt(style, bible string, galleryNum int) string { +func buildGalleryPagePrompt(style, bible string, galleryNum int, renderReq string) string { pose := galleryPoses[(galleryNum-1)%len(galleryPoses)] bibleBlock := bibleSection(bible, fmt.Sprintf("gallery page %d", galleryNum)) return fmt.Sprintf( "Art style: %s.%s\n"+ - renderingRequirement+ + renderReq+ "FULL-BLEED CHARACTER ART PAGE — portrait orientation, single illustration.\n"+ "NO text of any kind. NO title. NO labels. NO speech bubbles. NO panel borders. NO UI elements.\n"+ "This is a text-free variant cover / gallery page. Pure art only.\n\n"+ @@ -608,6 +631,12 @@ func pickStyle() string { return comicStyles[1+rand.IntN(len(comicStyles)-1)] } +// pickUltraRealistic returns true (photorealistic) or false (comic style) with +// equal probability, giving each run a 50/50 chance of either look. +func pickUltraRealistic() bool { + return rand.Float64() < 0.5 +} + func splitIntoSections(text string, n int) []string { paragraphs := splitParagraphs(text) if len(paragraphs) >= n { diff --git a/internal/story/runner.go b/internal/story/runner.go index 107e584..3299d59 100644 --- a/internal/story/runner.go +++ b/internal/story/runner.go @@ -40,6 +40,10 @@ type RunnerConfig struct { // adventure with aliens and spaceships"). Passed verbatim as the genre line in the // Gemini story prompt so the model writes in that genre instead of a random one. Theme string + // UltraRealistic controls whether the renderingRequirement photorealistic + // instruction is injected into every image prompt. + // nil → random 50/50 each run; true → always on; false → always off (--no-ultra-realistic). + UltraRealistic *bool // NarratorVoice picks a specific Gemini cinematic voice for narration. // Empty → random pick from the curated cinematic pool each run. NarratorVoice string @@ -61,6 +65,8 @@ func NewRunner(config *RunnerConfig) *Runner { } var apiKey, textModel, imageModel, imageTextModel, style, theme, narratorVoice string + // Resolve ultraRealistic: nil config or nil pointer → random 50/50 pick each run. + ultraRealistic := pickUltraRealistic() if config != nil { apiKey = config.APIKey textModel = config.TextModel @@ -69,6 +75,9 @@ func NewRunner(config *RunnerConfig) *Runner { style = config.Style theme = config.Theme narratorVoice = config.NarratorVoice + if config.UltraRealistic != nil { + ultraRealistic = *config.UltraRealistic + } } // Narrator init failure (e.g. missing key) is non-fatal — handleNarration @@ -89,11 +98,12 @@ func NewRunner(config *RunnerConfig) *Runner { Theme: theme, }), artist: NewArtist(&ArtistConfig{ - APIKey: apiKey, - Model: imageModel, - TextModel: imageTextModel, - OutputDir: dir, - Style: style, + APIKey: apiKey, + Model: imageModel, + TextModel: imageTextModel, + OutputDir: dir, + Style: style, + UltraRealistic: ultraRealistic, }), narrator: narrator, } |
