diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/comic/artist.go | 342 | ||||
| -rw-r--r-- | internal/comic/comic_test.go | 210 | ||||
| -rw-r--r-- | internal/comic/generator.go | 141 | ||||
| -rw-r--r-- | internal/comic/helpers.go | 85 | ||||
| -rw-r--r-- | internal/comic/narrator.go | 360 | ||||
| -rw-r--r-- | internal/comic/pdf.go | 30 | ||||
| -rw-r--r-- | internal/comic/runner.go | 278 | ||||
| -rw-r--r-- | internal/comic/templates.go | 1 | ||||
| -rw-r--r-- | internal/comic/types.go | 265 | ||||
| -rw-r--r-- | internal/tts/gemini.go | 140 |
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 { + chunks = append(chunks, strings.TrimSpace(current.String())) + current.Reset() + currentWords = 0 + } + if current.Len() > 0 { + current.WriteString("\n\n") + } + current.WriteString(paragraph) + currentWords += paraWords + } + if current.Len() > 0 { + chunks = append(chunks, strings.TrimSpace(current.String())) + } + return chunks +} + +func mixAmbientMusic(narrationFile, outputFile, tmpDir string) error { + ffmpegPath, err := exec.LookPath("ffmpeg") |
