summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-19 22:57:23 +0300
committerPaul Buetow <paul@buetow.org>2026-04-19 22:57:23 +0300
commit31897cc877545d7c39c3dc56227bf3f1690965ee (patch)
tree0eb1bb63376634bfb2e825f88080253cfff02d66
parente27953c1b6ab2deb7535f92161fb6ff0f119f594 (diff)
x4: port comic core into comicforge
-rw-r--r--internal/comic/artist.go342
-rw-r--r--internal/comic/comic_test.go210
-rw-r--r--internal/comic/generator.go141
-rw-r--r--internal/comic/helpers.go85
-rw-r--r--internal/comic/narrator.go360
-rw-r--r--internal/comic/pdf.go30
-rw-r--r--internal/comic/runner.go278
-rw-r--r--internal/comic/templates.go1
-rw-r--r--internal/comic/types.go265
-rw-r--r--internal/tts/gemini.go140
10 files changed, 1852 insertions, 0 deletions
diff --git a/internal/comic/artist.go b/internal/comic/artist.go
new file mode 100644
index 0000000..c21e633
--- /dev/null
+++ b/internal/comic/artist.go
@@ -0,0 +1,342 @@
+package comic
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "codeberg.org/snonux/comicforge/internal/provider"
+)
+
+// ArtistConfig configures comic page generation.
+type ArtistConfig struct {
+ ImageProvider provider.ImageProvider
+ TextProvider provider.TextProvider
+ Prompts PromptRenderer
+ OutputDir string
+ Style string
+ Theme string
+ Language string
+ Script string
+ UltraRealistic bool
+ StoryPages int
+ GalleryPages int
+ PanelsPerPage int
+}
+
+// Artist generates comic-book pages.
+type Artist struct {
+ imageProvider provider.ImageProvider
+ textProvider provider.TextProvider
+ prompts PromptRenderer
+ outputDir string
+ style string
+ theme string
+ language string
+ script string
+ ultraRealistic bool
+ storyPages int
+ galleryPages int
+ panelsPerPage int
+ initErr error
+}
+
+// NewArtist creates an Artist.
+func NewArtist(cfg *ArtistConfig) *Artist {
+ a := &Artist{
+ outputDir: ".",
+ language: "Bulgarian",
+ script: "Cyrillic",
+ storyPages: storyPagesInScript,
+ galleryPages: 5,
+ panelsPerPage: storyPanelsPerPage,
+ ultraRealistic: true,
+ }
+ if cfg == nil {
+ a.initErr = fmt.Errorf("artist config is required")
+ return a
+ }
+
+ a.imageProvider = cfg.ImageProvider
+ a.textProvider = cfg.TextProvider
+ a.prompts = cfg.Prompts
+ a.outputDir = orDefault(cfg.OutputDir, a.outputDir)
+ a.style = cfg.Style
+ a.theme = cfg.Theme
+ a.language = orDefault(cfg.Language, a.language)
+ a.script = orDefault(cfg.Script, a.script)
+ a.ultraRealistic = cfg.UltraRealistic
+ if cfg.StoryPages > 0 {
+ a.storyPages = cfg.StoryPages
+ }
+ if cfg.GalleryPages > 0 {
+ a.galleryPages = cfg.GalleryPages
+ }
+ if cfg.PanelsPerPage > 0 {
+ a.panelsPerPage = cfg.PanelsPerPage
+ }
+
+ if a.imageProvider == nil {
+ a.initErr = fmt.Errorf("%w: image provider", ErrMissingProvider)
+ }
+ if a.prompts == nil {
+ a.initErr = errorsJoin(a.initErr, fmt.Errorf("%w: prompt renderer", ErrMissingProvider))
+ }
+ return a
+}
+
+// DrawComicPages renders the cover, story pages, gallery pages, and back cover.
+func (a *Artist) DrawComicPages(ctx context.Context, storyText, bible, titleSlug string, entries []WordEntry, panelScript [][]string) ([]string, error) {
+ if err := a.ready(); err != nil {
+ return nil, err
+ }
+ style := a.style
+ if style == "" {
+ style = pickStyle(nil, a.ultraRealistic)
+ }
+ fmt.Printf(" Comic style: %s\n", style)
+
+ resolvedBible, blurb, err := a.resolveHelperTexts(ctx, storyText, bible)
+ if err != nil {
+ return nil, err
+ }
+
+ var paths []string
+ if p, err := a.renderPage(ctx, titleSlug+"_cover", coverPromptTemplate, a.coverPromptData(storyText, style, resolvedBible), "cover page"); err == nil && p != "" {
+ paths = append(paths, p)
+ }
+
+ sections := splitIntoSections(storyText, a.storyPages)
+ for i, section := range sections {
+ pageNum := i + 1
+ data := a.storyPagePromptData(section, pageNum, style, resolvedBible, entries, panelScript)
+ if p, err := a.renderPage(ctx, fmt.Sprintf("%s_page_%d", titleSlug, pageNum), storyPagePromptTemplate, data, fmt.Sprintf("story page %d", pageNum)); err == nil && p != "" {
+ paths = append(paths, p)
+ }
+ }
+
+ for i := 0; i < a.galleryPages; i++ {
+ galleryNum := i + 1
+ data := a.galleryPromptData(style, resolvedBible, galleryNum)
+ if p, err := a.renderPage(ctx, fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum), galleryPagePromptTemplate, data, fmt.Sprintf("gallery page %d/%d", galleryNum, a.galleryPages)); err == nil && p != "" {
+ paths = append(paths, p)
+ }
+ }
+
+ if p, err := a.renderPage(ctx, titleSlug+"_back", backCoverPromptTemplate, a.backPromptData(storyText, style, resolvedBible, blurb), "back cover"); err == nil && p != "" {
+ paths = append(paths, p)
+ }
+ return paths, nil
+}
+
+func (a *Artist) ready() error {
+ if a == nil {
+ return fmt.Errorf("artist is nil")
+ }
+ if a.initErr != nil {
+ return a.initErr
+ }
+ return nil
+}
+
+func (a *Artist) renderPage(ctx context.Context, fileName, templateName string, data map[string]any, label string) (string, error) {
+ path := filepath.Join(a.outputDir, fileName+".png")
+ if _, err := os.Stat(path); err == nil {
+ fmt.Printf(" Skipping %s (already exists)\n", filepath.Base(path))
+ return path, nil
+ }
+ prompt, err := a.prompts.RenderPrompt(templateName, data)
+ if err != nil {
+ return "", fmt.Errorf("render %s prompt: %w", label, err)
+ }
+ if err := a.generateWithRetry(ctx, prompt, path, label); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, label string) error {
+ attempts := pageMaxRetries
+ for attempt := 1; attempt <= attempts; attempt++ {
+ callCtx, cancel := withTimeout(ctx, helperTimeout)
+ err := a.imageProvider.GenerateImage(callCtx, prompt, outputFile)
+ cancel()
+ if err == nil {
+ return nil
+ }
+ if attempt < attempts {
+ pause := pageRetryBase * time.Duration(attempt)
+ fmt.Printf(" Warning: %s attempt %d/%d failed (%v), retrying in %s...\n", label, attempt, attempts, err, pause)
+ time.Sleep(pause)
+ continue
+ }
+ return fmt.Errorf("%s failed after %d attempts: %w", label, attempts, err)
+ }
+ return nil
+}
+
+func (a *Artist) resolveHelperTexts(ctx context.Context, storyText, prebuiltBible string) (string, string, error) {
+ bible := strings.TrimSpace(prebuiltBible)
+ if bible != "" {
+ fmt.Printf(" Character bible ready (%d chars)\n", len(bible))
+ }
+ blurb := ""
+ if a.textProvider == nil {
+ return bible, blurb, nil
+ }
+ systemPrompt, err := a.prompts.RenderPrompt(blurbSystemTemplate, map[string]any{
+ "StoryText": storyText,
+ })
+ if err != nil {
+ return "", "", fmt.Errorf("render blurb prompt: %w", err)
+ }
+ prompt := systemPrompt + "\n\n" + storyText
+ callCtx, cancel := withTimeout(ctx, helperTimeout)
+ defer cancel()
+ text, err := a.textProvider.GenerateText(callCtx, prompt)
+ if err != nil {
+ fmt.Printf(" Warning: back-cover blurb generation failed: %v\n", err)
+ return bible, blurb, nil
+ }
+ blurb = strings.TrimSpace(text)
+ if blurb != "" {
+ fmt.Printf(" Back-cover blurb ready (%d chars)\n", len(blurb))
+ }
+ return bible, blurb, nil
+}
+
+func (a *Artist) coverPromptData(storyText, style, bible string) map[string]any {
+ return map[string]any{
+ "Language": a.language,
+ "Script": a.script,
+ "Style": style,
+ "Bible": bible,
+ "Subtitle": "ComicForge Adventures",
+ "StoryText": storyText,
+ "RenderingRequirement": a.renderingRequirement(),
+ "RenderingRequirementEnd": a.renderingRequirementEnd(),
+ }
+}
+
+func (a *Artist) storyPagePromptData(section string, pageNum int, style, bible string, entries []WordEntry, panelScript [][]string) map[string]any {
+ return map[string]any{
+ "Language": a.language,
+ "Script": a.script,
+ "Style": style,
+ "Bible": bible,
+ "Words": buildWordList(entries, ""),
+ "PageNum": pageNum,
+ "TotalPages": a.storyPages,
+ "PanelLayout": buildPanelLayout(section, pageScriptForPage(panelScript, pageNum-1)),
+ "RenderingRequirement": a.renderingRequirement(),
+ "RenderingRequirementEnd": a.renderingRequirementEnd(),
+ }
+}
+
+func (a *Artist) galleryPromptData(style, bible string, galleryNum int) map[string]any {
+ return map[string]any{
+ "Language": a.language,
+ "Script": a.script,
+ "Style": style,
+ "Bible": bible,
+ "Pose": galleryPoses[(galleryNum-1)%len(galleryPoses)],
+ "RenderingRequirement": a.renderingRequirement(),
+ "RenderingRequirementEnd": a.renderingRequirementEnd(),
+ }
+}
+
+func (a *Artist) backPromptData(storyText, style, bible, blurb string) map[string]any {
+ return map[string]any{
+ "Language": a.language,
+ "Script": a.script,
+ "Style": style,
+ "Bible": bible,
+ "BlurbBox": blurbBoxInstruction(blurb),
+ "SeriesTitle": "ComicForge Adventures",
+ "StoryText": storyText,
+ "RenderingRequirement": a.renderingRequirement(),
+ "RenderingRequirementEnd": a.renderingRequirementEnd(),
+ }
+}
+
+func (a *Artist) renderingRequirement() string {
+ if a.ultraRealistic {
+ text, err := a.prompts.RenderPrompt(renderingRequirementPrompt, nil)
+ if err == nil {
+ return text
+ }
+ }
+ return ""
+}
+
+func (a *Artist) renderingRequirementEnd() string {
+ if a.ultraRealistic {
+ text, err := a.prompts.RenderPrompt(renderingRequirementEndPrompt, nil)
+ if err == nil {
+ return text
+ }
+ }
+ return ""
+}
+
+func pageScriptForPage(panelScript [][]string, idx int) []string {
+ if idx < 0 || idx >= len(panelScript) {
+ return nil
+ }
+ return panelScript[idx]
+}
+
+func buildPanelLayout(section string, pagePanels []string) string {
+ labels := [4]string{"TOP-LEFT", "TOP-RIGHT", "BOTTOM-LEFT", "BOTTOM-RIGHT"}
+ 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()
+ }
+
+ 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"
+}
+
+func blurbBoxInstruction(blurb string) string {
+ if strings.TrimSpace(blurb) == "" {
+ return "a rectangular text box (white or cream background, thin black border) near the bottom, styled like a classic back-cover synopsis box"
+ }
+ return fmt.Sprintf("a rectangular text box (white or cream background, thin black border) near the bottom displaying this blurb text in italic type:\n %q", blurb)
+}
+
+func errorsJoin(errs ...error) error {
+ var filtered []error
+ for _, err := range errs {
+ if err != nil {
+ filtered = append(filtered, err)
+ }
+ }
+ switch len(filtered) {
+ case 0:
+ return nil
+ case 1:
+ return filtered[0]
+ default:
+ return fmt.Errorf("%v; %w", filtered[0], filtered[1])
+ }
+}
diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go
new file mode 100644
index 0000000..9c1dc23
--- /dev/null
+++ b/internal/comic/comic_test.go
@@ -0,0 +1,210 @@
+package comic
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSlugify(t *testing.T) {
+ t.Parallel()
+
+ if got, want := slugify(" The Clockwork Dragon! "), "the-clockwork-dragon"; got != want {
+ t.Fatalf("slugify() = %q, want %q", got, want)
+ }
+ if got, want := slugify("!!!"), "comic"; got != want {
+ t.Fatalf("slugify() = %q, want %q", got, want)
+ }
+}
+
+func TestParseGenerateResult(t *testing.T) {
+ t.Parallel()
+
+ combined := strings.Join([]string{
+ "story text",
+ storyBibleSeparator,
+ "bible text",
+ storyTitleSeparator,
+ "My Comic",
+ storyPanelSeparator,
+ "P1-A: first",
+ "P1-B: second",
+ }, "\n")
+ got := parseGenerateResult(combined)
+ if got.StoryText != "story text" || got.Bible != "bible text" || got.Title != "My Comic" {
+ t.Fatalf("parseGenerateResult() = %#v", got)
+ }
+ if got.PanelScript[0][0] != "first" || got.PanelScript[0][1] != "second" {
+ t.Fatalf("parseGenerateResult() panel script = %#v", got.PanelScript)
+ }
+}
+
+func TestBuildPanelLayoutUsesFallbackExcerpt(t *testing.T) {
+ t.Parallel()
+
+ got := buildPanelLayout("one two three four five", nil)
+ if !strings.Contains(got, "Story excerpt") {
+ t.Fatalf("buildPanelLayout() = %q", got)
+ }
+}
+
+func TestCopyGalleryPNGsToComicsGallery(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ comicDir := filepath.Join(root, "comics", "my-slug")
+ if err := os.MkdirAll(comicDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(comicDir, "my-slug_gallery_1.png"), []byte("png1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := copyGalleryPNGsToComicsGallery(root, comicDir); err != nil {
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(filepath.Join(root, "comics", "gallery", "my-slug_gallery_1.png"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(b) != "png1" {
+ t.Fatalf("copied file = %q", b)
+ }
+}
+
+func TestGeneratorGenerateFull(t *testing.T) {
+ t.Parallel()
+
+ text := strings.Join([]string{
+ "story",
+ storyBibleSeparator,
+ "bible",
+ storyTitleSeparator,
+ "Title",
+ storyPanelSeparator,
+ "P1-A: a",
+ "P1-B: b",
+ "P1-C: c",
+ "P1-D: d",
+ "P2-A: e",
+ "P2-B: f",
+ "P2-C: g",
+ "P2-D: h",
+ "P3-A: i",
+ "P3-B: j",
+ "P3-C: k",
+ "P3-D: l",
+ "P4-A: m",
+ "P4-B: n",
+ "P4-C: o",
+ "P4-D: p",
+ "P5-A: q",
+ "P5-B: r",
+ "P5-C: s",
+ "P5-D: t",
+ }, "\n")
+ generator := NewGenerator(&GeneratorConfig{
+ TextProvider: fakeTextProvider{text: text},
+ Prompts: fakePromptRenderer{},
+ })
+ got, err := generator.GenerateFull(context.Background(), []WordEntry{{Word: "ябълка"}})
+ if err != nil {
+ t.Fatalf("GenerateFull() error = %v", err)
+ }
+ if got.Title != "Title" || got.StoryText != "story" || got.Bible != "bible" {
+ t.Fatalf("GenerateFull() = %#v", got)
+ }
+}
+
+func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ img := fakeImageProvider{t: t}
+ genText := fakeTextProvider{text: strings.Join([]string{
+ "story",
+ storyBibleSeparator,
+ "bible",
+ storyTitleSeparator,
+ "Title",
+ storyPanelSeparator,
+ "P1-A: a", "P1-B: b", "P1-C: c", "P1-D: d",
+ "P2-A: e", "P2-B: f", "P2-C: g", "P2-D: h",
+ "P3-A: i", "P3-B: j", "P3-C: k", "P3-D: l",
+ "P4-A: m", "P4-B: n", "P4-C: o", "P4-D: p",
+ "P5-A: q", "P5-B: r", "P5-C: s", "P5-D: t",
+ }, "\n")}
+ narr := fakeTTSProvider{}
+ runner := NewRunner(&RunnerConfig{
+ TextProvider: genText,
+ ImageProvider: img,
+ MainTTSProvider: narr,
+ ConclusionTTSProvider: narr,
+ Prompts: fakePromptRenderer{},
+ OutputDir: tmpDir,
+ Slug: "forced-slug",
+ NarrateEnabled: true,
+ })
+ runner.assemblePDF = func(outputDir, titleSlug string, imagePaths []string) (string, error) {
+ path := filepath.Join(outputDir, titleSlug+".pdf")
+ return path, os.WriteFile(path, []byte("pdf"), 0o644)
+ }
+ if err := runner.Run(context.Background(), filepath.Join(tmpDir, "vocab.txt")); err == nil {
+ t.Fatal("expected vocab read error for missing file")
+ }
+ if err := os.WriteFile(filepath.Join(tmpDir, "vocab.txt"), []byte("ябълка = apple\nкнига = book\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := runner.Run(context.Background(), filepath.Join(tmpDir, "vocab.txt")); err != nil {
+ t.Fatalf("Runner.Run() error = %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(tmpDir, "comics", "forced-slug", "forced-slug.pdf")); err != nil {
+ t.Fatalf("pdf missing: %v", err)
+ }
+}
+
+type fakePromptRenderer struct{}
+
+func (fakePromptRenderer) RenderPrompt(name string, data any) (string, error) {
+ switch name {
+ case storySystemPromptTemplate:
+ return "system prompt", nil
+ case storyPromptTemplate, storyFullPromptTemplate:
+ return "prompt", nil
+ case coverPromptTemplate, storyPagePromptTemplate, galleryPagePromptTemplate, backCoverPromptTemplate:
+ return "image prompt", nil
+ case blurbSystemTemplate, introSystemTemplate, conclusionSystemTemplate:
+ return "teaser prompt", nil
+ default:
+ return "", errors.New("unexpected template")
+ }
+}
+
+type fakeTextProvider struct{ text string }
+
+func (f fakeTextProvider) Name() string { return "fake-text" }
+func (f fakeTextProvider) IsAvailable() error { return nil }
+func (f fakeTextProvider) GenerateText(_ context.Context, _ string) (string, error) {
+ return f.text, nil
+}
+
+type fakeImageProvider struct{ t *testing.T }
+
+func (f fakeImageProvider) Name() string { return "fake-image" }
+func (f fakeImageProvider) IsAvailable() error { return nil }
+func (f fakeImageProvider) GenerateImage(_ context.Context, _ string, outputFile string) error {
+ if err := os.WriteFile(outputFile, []byte("png"), 0o644); err != nil {
+ f.t.Fatal(err)
+ }
+ return nil
+}
+
+type fakeTTSProvider struct{}
+
+func (fakeTTSProvider) Name() string { return "fake-tts" }
+func (fakeTTSProvider) IsAvailable() error { return nil }
+func (fakeTTSProvider) GenerateAudio(_ context.Context, _ string, outputFile string) error {
+ return os.WriteFile(outputFile, []byte("mp3"), 0o644)
+}
diff --git a/internal/comic/generator.go b/internal/comic/generator.go
new file mode 100644
index 0000000..34c60c5
--- /dev/null
+++ b/internal/comic/generator.go
@@ -0,0 +1,141 @@
+package comic
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "codeberg.org/snonux/comicforge/internal/provider"
+)
+
+// GeneratorConfig configures story generation.
+type GeneratorConfig struct {
+ TextProvider provider.TextProvider
+ Prompts PromptRenderer
+ Language string
+ Script string
+ Theme string
+ Genres []string
+}
+
+// Generator produces the comic story text, bible, title, and panel script.
+type Generator struct {
+ textProvider provider.TextProvider
+ prompts PromptRenderer
+ language string
+ script string
+ theme string
+ genres []string
+ initErr error
+}
+
+var _ = (*Generator)(nil)
+
+// NewGenerator creates a new story generator.
+func NewGenerator(cfg *GeneratorConfig) *Generator {
+ g := &Generator{
+ language: "Bulgarian",
+ script: "Cyrillic",
+ genres: defaultStoryGenres,
+ }
+ if cfg == nil {
+ g.initErr = errors.New("generator config is required")
+ return g
+ }
+ g.textProvider = cfg.TextProvider
+ g.prompts = cfg.Prompts
+ g.language = orDefault(cfg.Language, g.language)
+ g.script = orDefault(cfg.Script, g.script)
+ g.theme = cfg.Theme
+ if len(cfg.Genres) > 0 {
+ g.genres = append([]string(nil), cfg.Genres...)
+ }
+ if g.textProvider == nil {
+ g.initErr = fmt.Errorf("%w: text provider", ErrMissingProvider)
+ }
+ if g.prompts == nil {
+ g.initErr = errors.Join(g.initErr, fmt.Errorf("%w: prompt renderer", ErrMissingProvider))
+ }
+ return g
+}
+
+// Generate produces only the story text.
+func (g *Generator) Generate(ctx context.Context, entries []WordEntry) (string, error) {
+ if err := g.ready(); err != nil {
+ return "", err
+ }
+ prompt, err := g.renderStoryPrompt(storyPromptTemplate, entries)
+ if err != nil {
+ return "", err
+ }
+ ctx, cancel := withTimeout(ctx, storyTimeout)
+ defer cancel()
+ text, err := g.textProvider.GenerateText(ctx, prompt)
+ if err != nil {
+ return "", fmt.Errorf("generate story: %w", err)
+ }
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return "", fmt.Errorf("no story content returned")
+ }
+ return text, nil
+}
+
+// GenerateFull produces the combined story, bible, title, and panel script.
+func (g *Generator) GenerateFull(ctx context.Context, entries []WordEntry) (GenerateResult, error) {
+ if err := g.ready(); err != nil {
+ return GenerateResult{}, err
+ }
+ prompt, err := g.renderStoryPrompt(storyFullPromptTemplate, entries)
+ if err != nil {
+ return GenerateResult{}, err
+ }
+ ctx, cancel := withTimeout(ctx, storyTimeout)
+ defer cancel()
+ text, err := g.textProvider.GenerateText(ctx, prompt)
+ if err != nil {
+ return GenerateResult{}, fmt.Errorf("generate story bundle: %w", err)
+ }
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return GenerateResult{}, fmt.Errorf("no content returned")
+ }
+ return parseGenerateResult(text), nil
+}
+
+func (g *Generator) ready() error {
+ if g == nil {
+ return errors.New("generator is nil")
+ }
+ if g.initErr != nil {
+ return g.initErr
+ }
+ return nil
+}
+
+func (g *Generator) renderStoryPrompt(templateName string, entries []WordEntry) (string, error) {
+ genre := resolveGenre(g.theme, g.genres)
+ data := map[string]any{
+ "Language": g.language,
+ "Script": g.script,
+ "Genre": genre,
+ "Words": buildWordList(entries, ""),
+ "StoryBibleSeparator": storyBibleSeparator,
+ "StoryTitleSeparator": storyTitleSeparator,
+ "StoryPanelSeparator": storyPanelSeparator,
+ }
+
+ systemPrompt, err := g.prompts.RenderPrompt(storySystemPromptTemplate, map[string]any{
+ "Language": g.language,
+ "Script": g.script,
+ })
+ if err != nil {
+ return "", err
+ }
+ userPrompt, err := g.prompts.RenderPrompt(templateName, data)
+ if err != nil {
+ return "", err
+ }
+ return systemPrompt + "\n\n" + userPrompt, nil
+}
diff --git a/internal/comic/helpers.go b/internal/comic/helpers.go
new file mode 100644
index 0000000..0458db5
--- /dev/null
+++ b/internal/comic/helpers.go
@@ -0,0 +1,85 @@
+package comic
+
+import (
+ "strings"
+)
+
+// slugify converts a comic title into a safe file-name component.
+func slugify(title string) string {
+ title = strings.ToLower(strings.TrimSpace(title))
+ var b strings.Builder
+ prevHyphen := false
+ for _, r := range title {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ b.WriteRune(r)
+ prevHyphen = false
+ case r == ' ' || r == '-' || r == '_':
+ if !prevHyphen && b.Len() > 0 {
+ b.WriteByte('-')
+ prevHyphen = true
+ }
+ }
+ }
+
+ slug := strings.TrimRight(b.String(), "-")
+ if slug == "" {
+ return "comic"
+ }
+ return slug
+}
+
+// splitIntoSections divides text into n sections, preferring paragraph boundaries.
+func splitIntoSections(text string, n int) []string {
+ paragraphs := splitParagraphs(text)
+ if len(paragraphs) >= n {
+ return distributeParagraphs(paragraphs, n)
+ }
+ return splitByChars(text, n)
+}
+
+// splitParagraphs returns non-empty paragraphs separated by blank lines.
+func splitParagraphs(text string) []string {
+ var out []string
+ for _, paragraph := range strings.Split(text, "\n\n") {
+ if paragraph = strings.TrimSpace(paragraph); paragraph != "" {
+ out = append(out, paragraph)
+ }
+ }
+ return out
+}
+
+func distributeParagraphs(paragraphs []string, n int) []string {
+ sections := make([]string, n)
+ size, rem, idx := len(paragraphs)/n, len(paragraphs)%n, 0
+ for i := range n {
+ count := size
+ if i < rem {
+ count++
+ }
+ sections[i] = strings.Join(paragraphs[idx:idx+count], "\n\n")
+ idx += count
+ }
+ return sections
+}
+
+func splitByChars(text string, n int) []string {
+ size := len(text) / n
+ sections := make([]string, n)
+ for i := range n {
+ start := i * size
+ end := start + size
+ if i == n-1 {
+ end = len(text)
+ }
+ sections[i] = strings.TrimSpace(text[start:end])
+ }
+ return sections
+}
+
+func orDefault(value, fallback string) string {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ return fallback
+}
diff --git a/internal/comic/narrator.go b/internal/comic/narrator.go
new file mode 100644
index 0000000..461eb42
--- /dev/null
+++ b/internal/comic/narrator.go
@@ -0,0 +1,360 @@
+package comic
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "codeberg.org/snonux/comicforge/internal/provider"
+)
+
+// NarratorConfig configures narration generation.
+type NarratorConfig struct {
+ TextProvider provider.TextProvider
+ MainProvider provider.TTSProvider
+ ConclusionProvider provider.TTSProvider
+ Prompts PromptRenderer
+ VoiceName string
+ Language string
+ Script string
+}
+
+// Narrator generates intro, story, and conclusion narration.
+type Narrator struct {
+ textProvider provider.TextProvider
+ mainProvider provider.TTSProvider
+ conclusionProvider provider.TTSProvider
+ prompts PromptRenderer
+ voiceName string
+ language string
+ script string
+ initErr error
+}
+
+// NewNarrator creates a narration pipeline.
+func NewNarrator(cfg *NarratorConfig) *Narrator {
+ n := &Narrator{
+ language: "Bulgarian",
+ script: "Cyrillic",
+ }
+ if cfg == nil {
+ n.initErr = fmt.Errorf("narrator config is required")
+ return n
+ }
+ n.textProvider = cfg.TextProvider
+ n.mainProvider = cfg.MainProvider
+ n.conclusionProvider = cfg.ConclusionProvider
+ n.prompts = cfg.Prompts
+ n.voiceName = cfg.VoiceName
+ n.language = orDefault(cfg.Language, n.language)
+ n.script = orDefault(cfg.Script, n.script)
+ if n.conclusionProvider == nil {
+ n.conclusionProvider = n.mainProvider
+ }
+ if n.mainProvider == nil {
+ n.initErr = fmt.Errorf("%w: main TTS provider", ErrMissingProvider)
+ }
+ if n.prompts == nil {
+ n.initErr = errorsJoin(n.initErr, fmt.Errorf("%w: prompt renderer", ErrMissingProvider))
+ }
+ return n
+}
+
+// Narrate generates a cinematic MP3 narration of storyText and saves it to outputFile.
+func (n *Narrator) Narrate(ctx context.Context, storyText, outputFile string) error {
+ if err := n.ready(); err != nil {
+ return err
+ }
+
+ tmpDir, err := os.MkdirTemp("", "comicforge-narration-*")
+ if err != nil {
+ return fmt.Errorf("create temp dir: %w", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ var allPaths []string
+ if introPath, ok := n.narrateIntro(ctx, storyText, tmpDir); ok {
+ allPaths = append(allPaths, introPath)
+ }
+ chunkPaths, err := n.narrateMainStory(ctx, storyText, tmpDir)
+ if err != nil {
+ return err
+ }
+ allPaths = append(allPaths, chunkPaths...)
+ if conclusionPath, ok := n.narrateConclusion(ctx, storyText, tmpDir); ok {
+ allPaths = append(allPaths, conclusionPath)
+ }
+
+ combinedPath := filepath.Join(tmpDir, "combined.mp3")
+ if len(allPaths) == 1 {
+ combinedPath = allPaths[0]
+ } else if err := concatenateMP3s(allPaths, combinedPath, tmpDir); err != nil {
+ return err
+ }
+ return convertToStereo(combinedPath, outputFile)
+}
+
+func (n *Narrator) ready() error {
+ if n == nil {
+ return fmt.Errorf("narrator is nil")
+ }
+ if n.initErr != nil {
+ return n.initErr
+ }
+ return nil
+}
+
+func (n *Narrator) narrateMainStory(ctx context.Context, storyText, tmpDir string) ([]string, error) {
+ chunks := splitIntoNarrationChunks(storyText, narratorChunkWords)
+ fmt.Printf(" Splitting narration into %d chunks for consistent voice quality...\n", len(chunks))
+
+ var paths []string
+ for i, chunk := range chunks {
+ path := filepath.Join(tmpDir, fmt.Sprintf("chunk_%03d.mp3", i+1))
+ fmt.Printf(" Narrating chunk %d/%d...\n", i+1, len(chunks))
+ if err := n.narrateChunkWith(ctx, n.mainProvider, cinematicInstruction+chunk, path); err != nil {
+ return nil, fmt.Errorf("narrate chunk %d: %w", i+1, err)
+ }
+ paths = append(paths, path)
+ }
+ return paths, nil
+}
+
+func (n *Narrator) narrateIntro(ctx context.Context, storyText, tmpDir string) (string, bool) {
+ intro := n.buildIntro(ctx, storyText)
+ if intro == "" {
+ return "", false
+ }
+ introRaw := filepath.Join(tmpDir, "intro_narration.mp3")
+ if err := n.narrateChunkWith(ctx, n.conclusionProvider, cinematicInstruction+intro, introRaw); err != nil {
+ fmt.Printf(" Warning: intro narration failed: %v\n", err)
+ return "", false
+ }
+
+ introWithMusic := filepath.Join(tmpDir, "intro_with_music.mp3")
+ if err := mixAmbientMusic(introRaw, introWithMusic, tmpDir); err != nil {
+ fmt.Printf(" Warning: intro music mix failed (%v) — using narration only\n", err)
+ return introRaw, true
+ }
+ return introWithMusic, true
+}
+
+func (n *Narrator) buildIntro(ctx context.Context, storyText string) string {
+ return n.buildTeaser(ctx, introSystemTemplate, storyText)
+}
+
+func (n *Narrator) narrateConclusion(ctx context.Context, storyText, tmpDir string) (string, bool) {
+ conclusion := n.buildConclusion(ctx, storyText)
+ if conclusion == "" {
+ return "", false
+ }
+
+ chunks := splitIntoNarrationChunks(conclusion, narratorChunkWords)
+ var paths []string
+ for i, chunk := range chunks {
+ path := filepath.Join(tmpDir, fmt.Sprintf("conclusion_%03d.mp3", i+1))
+ if err := n.narrateChunkWith(ctx, n.conclusionProvider, cinematicInstruction+chunk, path); err != nil {
+ fmt.Printf(" Warning: conclusion narration failed: %v\n", err)
+ return "", false
+ }
+ paths = append(paths, path)
+ }
+
+ conclusionNarration := filepath.Join(tmpDir, "conclusion_narration.mp3")
+ if len(paths) == 1 {
+ conclusionNarration = paths[0]
+ } else if err := concatenateMP3s(paths, conclusionNarration, tmpDir); err != nil {
+ fmt.Printf(" Warning: conclusion concat failed: %v\n", err)
+ return paths[len(paths)-1], true
+ }
+
+ conclusionWithMusic := filepath.Join(tmpDir, "conclusion_with_music.mp3")
+ if err := mixAmbientMusic(conclusionNarration, conclusionWithMusic, tmpDir); err != nil {
+ fmt.Printf(" Warning: background music mix failed (%v) — using narration only\n", err)
+ return conclusionNarration, true
+ }
+ return conclusionWithMusic, true
+}
+
+func (n *Narrator) buildConclusion(ctx context.Context, storyText string) string {
+ return n.buildTeaser(ctx, conclusionSystemTemplate, storyText)
+}
+
+func (n *Narrator) buildTeaser(ctx context.Context, templateName, storyText string) string {
+ if n.textProvider == nil {
+ return ""
+ }
+ systemPrompt, err := n.prompts.RenderPrompt(templateName, map[string]any{
+ "StoryText": storyText,
+ "Language": n.language,
+ "Script": n.script,
+ })
+ if err != nil {
+ fmt.Printf(" Warning: text prompt render failed: %v\n", err)
+ return ""
+ }
+ prompt := systemPrompt + "\n\n" + storyText
+ callCtx, cancel := withTimeout(ctx, helperTimeout)
+ defer cancel()
+ text, err := n.textProvider.GenerateText(callCtx, prompt)
+ if err != nil {
+ fmt.Printf(" Warning: teaser generation failed: %v\n", err)
+ return ""
+ }
+ return strings.TrimSpace(text)
+}
+
+func (n *Narrator) narrateChunkWith(ctx context.Context, provider provider.TTSProvider, text, outputFile string) error {
+ callCtx, cancel := withTimeout(ctx, narratorTimeout)
+ defer cancel()
+ return provider.GenerateAudio(callCtx, text, outputFile)
+}
+
+const (
+ cinematicInstruction = `You are a dramatic cinematic narrator performing a story written in BULGARIAN.
+IMPORTANT: This text is in the BULGARIAN language — NOT Russian, NOT Serbian, NOT any other Slavic language.
+Pronounce every word using authentic BULGARIAN phonology and accent. Bulgarian vowels are clear and distinct;
+do not apply Russian stress patterns or Russian vowel reduction. The letter 'ъ' in Bulgarian is a mid-central
+vowel (like the 'u' in "but"), not the Russian reduced schwa.
+Deliver this as a professional movie trailer narrator would: deep, resonant, and commanding.
+Use long dramatic pauses before key moments. Build tension with slower, deliberate pacing,
+then accelerate through action. Drop your voice low and gravelly for mysterious or serious
+passages; let warmth and energy rise for joyful or triumphant ones. Breathe life into every
+sentence — this should sound like an epic Bulgarian film, not a reading exercise.
+
+`
+
+ introSystemTemplate = "narrator_intro_system.md"
+ conclusionSystemTemplate = "narrator_conclusion_system.md"
+)
+
+func splitIntoNarrationChunks(text string, targetWords int) []string {
+ paragraphs := splitParagraphs(text)
+ if len(paragraphs) == 0 {
+ return []string{strings.TrimSpace(text)}
+ }
+
+ var chunks []string
+ var current strings.Builder
+ currentWords := 0
+ for _, paragraph := range paragraphs {
+ paraWords := len(strings.Fields(paragraph))
+ if currentWords > 0 && currentWords+paraWords > targetWords {
<