diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/story/artist.go | 91 | ||||
| -rw-r--r-- | internal/story/gallery_copy_test.go | 32 | ||||
| -rw-r--r-- | internal/story/runner.go | 47 | ||||
| -rw-r--r-- | internal/version.go | 2 |
4 files changed, 152 insertions, 20 deletions
diff --git a/internal/story/artist.go b/internal/story/artist.go index 8e55d33..0cc4f17 100644 --- a/internal/story/artist.go +++ b/internal/story/artist.go @@ -65,13 +65,27 @@ const ( // response — typically caused by free-tier RPM exhaustion between rapid calls. helperRetryPause = 15 * time.Second - // 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. + // renderingRequirement is injected into every image prompt when --ultra-realistic + // is active. Kept strong and explicit because image models often drift toward + // comic/illustration when prompts also say "comic book", "masthead", or "panels". // 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" + renderingRequirement = "ULTRA-REALISTIC RENDERING (mandatory for this entire image):\n" + + " • The output must look like a REAL PHOTOGRAPH or a high-budget live-action film still — " + + "shot on a real set or location with real actors, costumes, and props.\n" + + " • Skin, hair, fabric, metal, and environments must show real-world texture, lens blur, " + + "and natural light — NOT ink, NOT cel shading, NOT painterly brushwork.\n" + + " • FORBIDDEN overall styles: cartoon, anime, manga, comic-book line art, halftone dots, " + + "Ben-Day, visible outlines, storybook illustration, watercolor/oil-paint look, or any " + + "obviously drawn or stylized artwork.\n" + + " • Speech bubbles, masthead lettering, and UI-like overlays (where the layout requires them) " + + "may look like graphic design ON TOP of the photo — the underlying scene must stay photographic.\n" + + " • Gallery pages (no bubbles): the whole frame must be 100%% photographic — no exception.\n" + + // renderingRequirementEnd is appended at the very end of each prompt so the + // model's last tokens reinforce photorealism (helps when earlier text is long). + renderingRequirementEnd = "\nFINAL LOCK — PHOTOREALISM: Entire image = camera-captured realism. " + + "If anything looks illustrated rather than photographed, the output is wrong. " + + "Do not drift toward comic art between panels or on gallery pages.\n" ) // realisticStyles is the style pool used when ultra-realistic mode is active. @@ -476,6 +490,15 @@ func (a *Artist) renderReq() string { return "" } +// appendUltraRealisticEnd adds a final photorealism reminder when renderReq is +// non-empty (ultra-realistic mode), so long prompts still end on a strong constraint. +func appendUltraRealisticEnd(renderReq string) string { + if renderReq == "" { + return "" + } + return renderingRequirementEnd +} + // buildCoverPrompt constructs the front-cover image prompt. func buildCoverPrompt(storyText, style, bible, renderReq string) string { // Use a short excerpt as a teaser on the cover prompt. @@ -489,6 +512,14 @@ func buildCoverPrompt(storyText, style, bible, renderReq string) string { } bibleBlock := bibleSection(bible, "cover") + mainArtVerb := "illustration" + if renderReq != "" { + mainArtVerb = "photographed cinematic scene" + } + coverBleed := "single full-bleed illustration" + if renderReq != "" { + coverBleed = "single full-bleed image (photoreal — like a physical comic book cover photo shoot)" + } return fmt.Sprintf( // Bulgarian language rule placed first so the model processes it before any other instruction. "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+ @@ -496,7 +527,7 @@ func buildCoverPrompt(storyText, style, bible, renderReq string) string { "Cyrillic script. The masthead title must also be rendered in a striking comic-book font.\n\n"+ "Art style: %s.%s\n"+ renderReq+ - "TRADITIONAL COMIC BOOK FRONT COVER — single full-bleed illustration, landscape 16:9 format.\n"+ + "TRADITIONAL COMIC BOOK FRONT COVER — %s, landscape 16:9 format.\n"+ "NO panel grid. NO speech bubbles.\n"+ "MANDATORY MASTHEAD — the most important visual element on this cover:\n"+ " • Invent a DRAMATIC, STORY-SPECIFIC comic book title that fits the characters and "+ @@ -511,7 +542,7 @@ func buildCoverPrompt(storyText, style, bible, renderReq string) string { "in the top-left corner — e.g. a planet, rocket, magnifying glass, sword — "+ "matching the story theme. The logo should feel like a real publisher imprint.\n"+ "Remaining layout rules:\n"+ - " • MAIN ART: below the masthead, a dramatic illustration of EXACTLY the named characters "+ + " • MAIN ART: below the masthead, a dramatic %s of EXACTLY the named characters "+ "from the story (as described in the reference above) — same faces, same ages, same "+ "clothing, same animals. Do NOT invent new characters or use generic stand-ins.\n"+ " • COVER LINES: 2–3 short Bulgarian teaser phrases in bold display type "+ @@ -521,8 +552,9 @@ func buildCoverPrompt(storyText, style, bible, renderReq string) string { "IMPORTANT: only the characters named in the reference may appear on this cover. "+ "Same age, same face, same clothing as in the interior pages. "+ "LANGUAGE REMINDER: all cover text in Bulgarian Cyrillic — see rule at top. "+ - "Story teaser:\n\n%s", - style, bibleBlock, teaser, + "Story teaser:\n\n%s"+ + "%s", + style, bibleBlock, coverBleed, mainArtVerb, teaser, appendUltraRealisticEnd(renderReq), ) } @@ -541,6 +573,13 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible vocabBlock := buildVocabBlock(entries) panelLayout := buildPanelLayout(section, pagePanels) + storyPanelRealism := "" + if renderReq != "" { + storyPanelRealism = "PANEL REALISM: Each of the 4 panels must depict a PHOTOGRAPHED scene (real actors, real lighting). " + + "Speech bubbles, thought bubbles, and vocabulary labels are flat graphic overlays only — " + + "the world behind them must not look drawn or cartoon-like.\n" + } + return fmt.Sprintf( // Lead with the hard language constraint so it is processed first. "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+ @@ -566,14 +605,16 @@ func buildStoryPagePrompt(section string, pageNum, totalPages int, style, bible "character pose or action, location or background detail, lighting or time-of-day, "+ "and foreground objects. Repeating the same angle or composition across panels is FORBIDDEN.\n"+ renderReq+ + storyPanelRealism+ "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 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 description explicitly describes a change.\n"+ - " • LANGUAGE: all speech, thought, and caption text — Bulgarian Cyrillic ONLY.\n", - vocabBlock, style, bibleBlock, pageNum, totalPages, panelLayout, + " • LANGUAGE: all speech, thought, and caption text — Bulgarian Cyrillic ONLY.\n"+ + "%s", + vocabBlock, style, bibleBlock, pageNum, totalPages, panelLayout, appendUltraRealisticEnd(renderReq), ) } @@ -659,6 +700,10 @@ func buildBackCoverPrompt(storyText, style, bible, blurb, renderReq string) stri } bibleBlock := bibleSection(bible, "back cover") + backBleed := "single full-bleed illustration" + if renderReq != "" { + backBleed = "single full-bleed image (photoreal — like a physical back-cover photo shoot)" + } return fmt.Sprintf( // Bulgarian language rule placed first for maximum model compliance. "ЗАДЪЛЖИТЕЛНО / MANDATORY LANGUAGE RULE: This is a BULGARIAN comic book. "+ @@ -666,12 +711,13 @@ func buildBackCoverPrompt(storyText, style, bible, blurb, renderReq string) stri "English text anywhere on the back cover is STRICTLY FORBIDDEN.\n\n"+ "Art style: %s.%s\n"+ renderReq+ - "TRADITIONAL COMIC BOOK BACK COVER — single full-bleed illustration, landscape 16:9 format.\n"+ + "TRADITIONAL COMIC BOOK BACK COVER — %s, landscape 16:9 format.\n"+ "NO panel grid. NO speech bubbles.\n"+ "Layout rules (must follow exactly):\n"+ " • MAIN ART: a calm, warm, resolved scene filling the upper 60%% of the cover — "+ "EXACTLY the named characters from the story (as described in the reference above) "+ "in a peaceful or triumphant ending moment, with the full story setting behind them. "+ + "The scene must look PHOTOGRAPHED (live-action), not drawn. "+ "Do NOT invent new characters or use generic stand-ins.\n"+ " • BLURB BOX: %s\n"+ " • BOTTOM STRIP: barcode box bottom-left (black-and-white barcode graphic), "+ @@ -680,8 +726,9 @@ func buildBackCoverPrompt(storyText, style, bible, blurb, renderReq string) stri "IMPORTANT: only the characters named in the reference may appear on this back cover. "+ "Same age, same face, same clothing, same animals as in the interior pages. "+ "LANGUAGE REMINDER: all text in Bulgarian Cyrillic — see rule at top. "+ - "Story ending hint:\n\n%s", - style, bibleBlock, blurbBoxInstruction, ending, + "Story ending hint:\n\n%s"+ + "%s", + style, bibleBlock, backBleed, blurbBoxInstruction, ending, appendUltraRealisticEnd(renderReq), ) } @@ -701,19 +748,25 @@ var galleryPoses = []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)) + galleryArt := "FULL-BLEED SINGLE ILLUSTRATION" + if renderReq != "" { + galleryArt = "FULL-BLEED SINGLE PHOTOGRAPH — must look 100%% like a real camera shot (no illustration style)" + } return fmt.Sprintf( "Art style: %s.%s\n"+ renderReq+ - "FULL-BLEED SINGLE ILLUSTRATION — landscape 16:9 format, ONE image only, NO grid, NO panels.\n"+ + "%s — landscape 16:9 format, ONE image only, NO grid, NO panels.\n"+ "DO NOT split the image into multiple panels or sections. The ENTIRE canvas is ONE single scene.\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 character gallery page. Pure art only.\n\n"+ + "This is a text-free character gallery page. "+ + "If ultra-realistic mode: pure cinematic photography only — not painted or comic art.\n\n"+ "Composition: %s\n\n"+ "The subject MUST be EXACTLY the main character(s) described in the reference above — "+ "same faces, same genders, same ages, same clothing. Include the companion animal if naturally present. "+ "Do NOT invent new characters or change any character's gender. Do NOT add any text overlays.\n"+ - "Background: the story's setting rendered with full cinematic atmosphere and colour mood.", - style, bibleBlock, pose, + "Background: the story's setting rendered with full cinematic atmosphere and colour mood."+ + "%s", + style, bibleBlock, galleryArt, pose, appendUltraRealisticEnd(renderReq), ) } diff --git a/internal/story/gallery_copy_test.go b/internal/story/gallery_copy_test.go new file mode 100644 index 0000000..7c6f4c1 --- /dev/null +++ b/internal/story/gallery_copy_test.go @@ -0,0 +1,32 @@ +package story + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCopyGalleryPNGsToComicsGallery(t *testing.T) { + root := t.TempDir() + comicDir := filepath.Join(root, "comics", "my-slug") + if err := os.MkdirAll(comicDir, 0755); err != nil { + t.Fatal(err) + } + g1 := filepath.Join(comicDir, "my-slug_gallery_1.png") + if err := os.WriteFile(g1, []byte("png1"), 0644); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(comicDir, "my-slug_cover.png"), []byte("x"), 0644) + + if err := copyGalleryPNGsToComicsGallery(root, comicDir); err != nil { + t.Fatal(err) + } + dst := filepath.Join(root, "comics", "gallery", "my-slug_gallery_1.png") + b, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(b) != "png1" { + t.Fatalf("got %q", b) + } +} diff --git a/internal/story/runner.go b/internal/story/runner.go index 07e0e12..c7fc3d5 100644 --- a/internal/story/runner.go +++ b/internal/story/runner.go @@ -2,6 +2,7 @@ package story import ( "fmt" + "io" "os" "path/filepath" "strings" @@ -209,6 +210,13 @@ func (r *Runner) drawComicPages(storyText, bible, titleSlug string, entries []ba fmt.Printf("Comic page saved: %s\n", p) } + // Aggregate all gallery close-ups into comics/gallery/ at the output root so + // multiple runs collect in one folder (filenames are slug-prefixed). + rootDir := filepath.Dir(filepath.Dir(r.artist.outputDir)) + if err := copyGalleryPNGsToComicsGallery(rootDir, r.artist.outputDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not copy gallery images to comics/gallery: %v\n", err) + } + if len(paths) == 0 { return } @@ -304,3 +312,42 @@ func (r *Runner) saveTTSPlaceholder(titleSlug, dir string) error { fmt.Printf("TTS placeholder saved: %s\n", path) return nil } + +// copyGalleryPNGsToComicsGallery copies *_gallery_*.png from a per-comic folder +// (comics/<slug>/) into <outputRoot>/comics/gallery/. Slug-prefixed filenames +// keep multiple runs from overwriting each other. +func copyGalleryPNGsToComicsGallery(outputRoot, comicDir string) error { + destDir := filepath.Join(outputRoot, "comics", "gallery") + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("mkdir gallery: %w", err) + } + matches, err := filepath.Glob(filepath.Join(comicDir, "*_gallery_*.png")) + if err != nil { + return err + } + for _, src := range matches { + dst := filepath.Join(destDir, filepath.Base(src)) + if err := copyFile(src, dst); err != nil { + return fmt.Errorf("%s -> %s: %w", src, dst, err) + } + } + if len(matches) > 0 { + fmt.Printf("Gallery images copied to %s (%d files)\n", destDir, len(matches)) + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/internal/version.go b/internal/version.go index a018412..e6b624a 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,3 +1,3 @@ package internal -const Version = "0.27.0" +const Version = "0.28.0" |
