From 31897cc877545d7c39c3dc56227bf3f1690965ee Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 19 Apr 2026 22:57:23 +0300 Subject: x4: port comic core into comicforge --- internal/comic/artist.go | 342 ++++++++++++++++++++++++++++++++++++++++ internal/comic/comic_test.go | 210 +++++++++++++++++++++++++ internal/comic/generator.go | 141 +++++++++++++++++ internal/comic/helpers.go | 85 ++++++++++ internal/comic/narrator.go | 360 +++++++++++++++++++++++++++++++++++++++++++ internal/comic/pdf.go | 30 ++++ internal/comic/runner.go | 278 +++++++++++++++++++++++++++++++++ internal/comic/templates.go | 1 + internal/comic/types.go | 265 +++++++++++++++++++++++++++++++ internal/tts/gemini.go | 140 +++++++++++++++++ 10 files changed, 1852 insertions(+) create mode 100644 internal/comic/artist.go create mode 100644 internal/comic/comic_test.go create mode 100644 internal/comic/generator.go create mode 100644 internal/comic/helpers.go create mode 100644 internal/comic/narrator.go create mode 100644 internal/comic/pdf.go create mode 100644 internal/comic/runner.go create mode 100644 internal/comic/templates.go create mode 100644 internal/comic/types.go create mode 100644 internal/tts/gemini.go 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") + if err != nil { + return fmt.Errorf("ffmpeg not found") + } + + musicPath := filepath.Join(tmpDir, "ambient_pad.mp3") + if err := generateAmbientPad(ffmpegPath, musicPath); err != nil { + return err + } + + cmd := exec.Command(ffmpegPath, + "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-i", narrationFile, + "-i", musicPath, + "-filter_complex", "[0:a][1:a]amix=inputs=2:weights=1 0.3:duration=first[aout]", + "-map", "[aout]", + "-ac", "2", + "-codec:a", "libmp3lame", "-q:a", "2", + outputFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("background music mix failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func generateAmbientPad(ffmpegPath, outputFile string) error { + droneExpr := "0.04*sin(65*2*PI*t)+0.03*sin(98*2*PI*t)+0.02*sin(130*2*PI*t)" + cmd := exec.Command(ffmpegPath, + "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", + "-i", fmt.Sprintf("aevalsrc=%s:sample_rate=44100", droneExpr), + "-f", "lavfi", "-i", "anoisesrc=color=pink:amplitude=0.008", + "-filter_complex", "[0:a][1:a]amix=inputs=2:duration=first[mixed];[mixed]afade=t=in:st=0:d=4[aout]", + "-map", "[aout]", + "-t", "300", + "-ac", "2", + outputFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ambient pad generation failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func concatenateMP3s(chunkPaths []string, outputFile, tmpDir string) error { + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + return fmt.Errorf("ffmpeg not found — required for multi-chunk narration: %w", err) + } + + listPath := filepath.Join(tmpDir, "concat_list.txt") + var sb strings.Builder + for _, path := range chunkPaths { + sb.WriteString(fmt.Sprintf("file '%s'\n", path)) + } + if err := os.WriteFile(listPath, []byte(sb.String()), 0o600); err != nil { + return fmt.Errorf("write concat list: %w", err) + } + + cmd := exec.Command(ffmpegPath, + "-nostdin", "-hide_banner", "-loglevel", "error", + "-y", + "-f", "concat", "-safe", "0", + "-i", listPath, + "-codec:a", "copy", + outputFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg concat failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func convertToStereo(inputFile, outputFile string) error { + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + fmt.Println(" Warning: ffmpeg not found, narration will be mono") + return os.Rename(inputFile, outputFile) + } + + cmd := exec.Command(ffmpegPath, + "-nostdin", "-hide_banner", "-loglevel", "error", + "-y", + "-i", inputFile, + "-ac", "2", + "-codec:a", "libmp3lame", "-q:a", "2", + outputFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg stereo conversion failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/comic/pdf.go b/internal/comic/pdf.go new file mode 100644 index 0000000..23a7e11 --- /dev/null +++ b/internal/comic/pdf.go @@ -0,0 +1,30 @@ +package comic + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +// AssembleComicPDF combines comic pages into a PDF using ImageMagick. +func AssembleComicPDF(outputDir, titleSlug string, imagePaths []string) (string, error) { + if len(imagePaths) == 0 { + return "", fmt.Errorf("no comic images to assemble into PDF") + } + if _, err := exec.LookPath("convert"); err != nil { + return "", fmt.Errorf("ImageMagick 'convert' not found — install ImageMagick to generate the PDF") + } + + pdfPath := filepath.Join(outputDir, titleSlug+".pdf") + args := []string{"-density", "150"} + args = append(args, imagePaths...) + args = append(args, pdfPath) + + cmd := exec.Command("convert", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("convert failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return pdfPath, nil +} diff --git a/internal/comic/runner.go b/internal/comic/runner.go new file mode 100644 index 0000000..ea3651e --- /dev/null +++ b/internal/comic/runner.go @@ -0,0 +1,278 @@ +package comic + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "codeberg.org/snonux/comicforge/internal/provider" + "codeberg.org/snonux/comicforge/internal/vocab" +) + +const ttsTodoContent = `# Story Narration - Fallback Placeholder +# +# Comic narration was not produced (missing provider or generation error). +# +# To generate narration manually, run again with a configured TTS provider, or +# use a text-to-speech backend of your choice and save the result as story_narration.mp3. +` + +// RunnerConfig holds orchestration settings for the comic pipeline. +type RunnerConfig struct { + TextProvider provider.TextProvider + ImageProvider provider.ImageProvider + MainTTSProvider provider.TTSProvider + ConclusionTTSProvider provider.TTSProvider + Prompts PromptRenderer + OutputDir string + Style string + Theme string + Language string + Script string + Slug string + NarrateEnabled bool + UltraRealistic *bool + StoryPages int + GalleryPages int + PanelsPerPage int +} + +// Runner orchestrates the full pipeline. +type Runner struct { + config *RunnerConfig + generator *Generator + artist *Artist + narrator *Narrator + assemblePDF func(outputDir, titleSlug string, imagePaths []string) (string, error) +} + +// NewRunner wires together the generator, artist, and narrator. +func NewRunner(cfg *RunnerConfig) *Runner { + r := &Runner{config: cfg} + r.assemblePDF = AssembleComicPDF + if cfg == nil { + return r + } + + ultra := pickUltraRealistic() + if cfg.UltraRealistic != nil { + ultra = *cfg.UltraRealistic + } + + r.generator = NewGenerator(&GeneratorConfig{ + TextProvider: cfg.TextProvider, + Prompts: cfg.Prompts, + Language: cfg.Language, + Script: cfg.Script, + Theme: cfg.Theme, + }) + r.artist = NewArtist(&ArtistConfig{ + ImageProvider: cfg.ImageProvider, + TextProvider: cfg.TextProvider, + Prompts: cfg.Prompts, + OutputDir: cfg.OutputDir, + Style: cfg.Style, + Theme: cfg.Theme, + Language: cfg.Language, + Script: cfg.Script, + UltraRealistic: ultra, + StoryPages: cfg.StoryPages, + GalleryPages: cfg.GalleryPages, + PanelsPerPage: cfg.PanelsPerPage, + }) + r.narrator = NewNarrator(&NarratorConfig{ + TextProvider: cfg.TextProvider, + MainProvider: cfg.MainTTSProvider, + ConclusionProvider: cfg.ConclusionTTSProvider, + Prompts: cfg.Prompts, + Language: cfg.Language, + Script: cfg.Script, + }) + return r +} + +// Run reads the batch file, generates a story, renders comic pages, and optionally narrates it. +func (r *Runner) Run(ctx context.Context, batchFile string) error { + if r == nil || r.config == nil { + return fmt.Errorf("runner config is required") + } + if r.generator == nil || r.artist == nil { + return fmt.Errorf("runner providers are not configured") + } + + dir := orDefault(r.config.OutputDir, ".") + entries, err := vocab.ReadVocabularyFile(batchFile) + if err != nil { + return fmt.Errorf("failed to read batch file: %w", err) + } + if len(entries) == 0 { + return fmt.Errorf("batch file %q contains no words", batchFile) + } + + fmt.Printf("Generating story for %d words...\n", len(entries)) + result, err := r.generator.GenerateFull(ctx, entries) + if err != nil { + return fmt.Errorf("story generation failed: %w", err) + } + + slug := slugify(result.Title) + if strings.TrimSpace(r.config.Slug) != "" { + slug = r.config.Slug + fmt.Printf(" Comic title: %q (slug forced: %s)\n", result.Title, slug) + } else if result.Title != "" { + fmt.Printf(" Comic title: %q (slug: %s)\n", result.Title, slug) + } + + comicsDir := filepath.Join(dir, "comics", slug) + if err := os.MkdirAll(comicsDir, 0o755); err != nil { + return fmt.Errorf("create comics dir %s: %w", comicsDir, err) + } + r.artist.outputDir = comicsDir + + if err := r.saveStoryText(result.StoryText, slug, comicsDir); err != nil { + return err + } + if err := r.saveVocabularyFile(result.StoryText, entries, slug, comicsDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not write vocabulary file: %v\n", err) + } + if err := r.saveThemeFile(slug, comicsDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not write theme file: %v\n", err) + } + + paths, err := r.artist.DrawComicPages(ctx, result.StoryText, result.Bible, slug, entries, result.PanelScript) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: comic page generation failed: %v\n", err) + } + for _, path := range paths { + fmt.Printf("Comic page saved: %s\n", path) + } + 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 { + pdfPath, err := r.assemblePDF(r.artist.outputDir, slug, paths) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: PDF assembly failed: %v\n", err) + } else { + fmt.Printf("Comic PDF saved: %s\n", pdfPath) + } + } + + if !r.config.NarrateEnabled { + fmt.Println("Narration skipped (enable narration in config to produce audio).") + return nil + } + return r.handleNarration(ctx, result.StoryText, slug, comicsDir) +} + +var _ StoryRunner = (*Runner)(nil) + +func (r *Runner) handleNarration(ctx context.Context, storyText, titleSlug, dir string) error { + if r.narrator == nil || r.narrator.mainProvider == nil || r.narrator.initErr != nil { + return r.saveTTSPlaceholder(titleSlug, dir) + } + mp3Path := filepath.Join(dir, titleSlug+"_narration.mp3") + fmt.Printf("Generating cinematic narration (voice: %s)...\n", r.narrator.voiceName) + if err := r.narrator.Narrate(ctx, storyText, mp3Path); err != nil { + fmt.Fprintf(os.Stderr, "Warning: narration failed: %v\n", err) + return r.saveTTSPlaceholder(titleSlug, dir) + } + fmt.Printf("Narration saved: %s\n", mp3Path) + return nil +} + +func (r *Runner) saveStoryText(text, titleSlug, dir string) error { + path := filepath.Join(dir, titleSlug+"_story.txt") + if err := os.WriteFile(path, []byte(text+"\n"), 0o644); err != nil { + return fmt.Errorf("failed to write story file: %w", err) + } + fmt.Printf("Story saved: %s\n", path) + return nil +} + +func (r *Runner) saveVocabularyFile(storyText string, entries []vocab.WordEntry, titleSlug, dir string) error { + path := filepath.Join(dir, titleSlug+"_comic_vocabulary.txt") + var sb strings.Builder + sb.WriteString("# Vocabulary Words\n\n") + for _, entry := range entries { + word := strings.TrimSpace(entry.Word) + if word == "" { + word = strings.TrimSpace(entry.Translation) + } + if entry.Translation != "" && entry.Word != "" { + sb.WriteString(fmt.Sprintf(" %s - %s\n", entry.Word, entry.Translation)) + continue + } + sb.WriteString(fmt.Sprintf(" %s\n", word)) + } + sb.WriteString("\n# Story Text\n\n") + sb.WriteString(strings.TrimSpace(storyText)) + sb.WriteString("\n") + if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil { + return fmt.Errorf("failed to write vocabulary file: %w", err) + } + fmt.Printf("Vocabulary saved: %s\n", path) + return nil +} + +func (r *Runner) saveThemeFile(titleSlug, dir string) error { + path := filepath.Join(dir, titleSlug+"_theme.txt") + theme := "" + if r.config != nil { + theme = r.config.Theme + } + if err := os.WriteFile(path, []byte(theme+"\n"), 0o644); err != nil { + return fmt.Errorf("failed to write theme file: %w", err) + } + fmt.Printf("Theme saved: %s\n", path) + return nil +} + +func (r *Runner) saveTTSPlaceholder(titleSlug, dir string) error { + path := filepath.Join(dir, titleSlug+"_tts_todo.txt") + if err := os.WriteFile(path, []byte(ttsTodoContent), 0o644); err != nil { + return fmt.Errorf("failed to write TTS placeholder: %w", err) + } + fmt.Printf("TTS placeholder saved: %s\n", path) + return nil +} + +func copyGalleryPNGsToComicsGallery(outputRoot, comicDir string) error { + destDir := filepath.Join(outputRoot, "comics", "gallery") + if err := os.MkdirAll(destDir, 0o755); 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/comic/templates.go b/internal/comic/templates.go new file mode 100644 index 0000000..839f6d1 --- /dev/null +++ b/internal/comic/templates.go @@ -0,0 +1 @@ +package comic diff --git a/internal/comic/types.go b/internal/comic/types.go new file mode 100644 index 0000000..59b410c --- /dev/null +++ b/internal/comic/types.go @@ -0,0 +1,265 @@ +package comic + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "strings" + "time" + + "codeberg.org/snonux/comicforge/internal/vocab" +) + +const ( + storySystemPromptTemplate = "story_system.md" + storyPromptTemplate = "story_prompt.md" + storyFullPromptTemplate = "story_full_prompt.md" + coverPromptTemplate = "cover_prompt.md" + storyPagePromptTemplate = "story_page_prompt.md" + galleryPagePromptTemplate = "gallery_page_prompt.md" + backCoverPromptTemplate = "back_cover_prompt.md" + panelScriptPromptTemplate = "panel_script_prompt.md" + bibleSystemTemplate = "bible_system.md" + blurbSystemTemplate = "blurb_system.md" + renderingRequirementPrompt = "rendering_requirement.md" + renderingRequirementEndPrompt = "rendering_requirement_end.md" +) + +const ( + storyGeminiModel = "gemini-2.5-flash" + + storyTimeout = 120 * time.Second + + // 8192 tokens for story-only generation (thinking + visible story). + storyMaxTokens = int32(8192) + + // 16384 total for the combined story+bible call, with thinking capped. + storyFullMaxTokens = int32(16384) + + // Caps the internal chain-of-thought so visible output is still produced. + storyFullThinkingBudget = int32(8192) + + storyBibleSeparator = "---CHARACTER GUIDE---" + storyTitleSeparator = "---COMIC TITLE---" + storyPanelSeparator = "---PANEL SCRIPT---" + + storyPagesInScript = 5 + storyPanelsPerPage = 4 + + pageMaxRetries = 5 + pageRetryBase = 15 * time.Second + comicPageAspectRatio = "16:9" + comicPromptMaxChars = 900 + helperTimeout = 90 * time.Second + helperMaxTokens = int32(8192) + helperRetryPause = 15 * time.Second + narratorTimeout = 3 * time.Minute + narratorChunkWords = 100 +) + +var ( + // ErrMissingProvider is returned when a required provider is not configured. + ErrMissingProvider = errors.New("missing provider") + + defaultStoryGenres = []string{ + "a warm realistic slice-of-life story", + "a heartfelt family drama", + "an exciting science-fiction adventure", + "a thrilling action-adventure story", + "a mystery with a surprising twist", + "a funny comedy with silly misunderstandings", + "a fantasy quest in a magical world", + "a spooky but kid-friendly horror story", + "a space exploration adventure", + "a superhero origin story", + } + + realisticStyles = []string{ + "ultra-realistic DSLR photography, cinematic 35mm lens, natural lighting, hyper-detailed textures", + "cinematic still photography, golden-hour lighting, shallow depth of field, photojournalism quality", + "hyper-realistic photography, studio-quality lighting, sharp focus, true-to-life colours and textures", + } + + comicStyles = []string{ + "ultra realistic comic strip with photographic detail and dramatic lighting", + "classic American comic book with bold ink outlines, halftone dots, and primary colors", + "Japanese manga with clean linework, expressive eyes, and speed lines", + "retro 1960s pop art in the style of Roy Lichtenstein with thick outlines and Ben-Day dots", + "watercolor illustration with soft washes, delicate linework, and pastel tones", + "European bande dessinée with detailed backgrounds, clear lines, and rich flat colors", + "noir black-and-white graphic novel with heavy shadows and high contrast", + "children's picture book with bright, friendly illustrations and thick outlines", + "painterly oil-on-canvas comic with loose brushwork and vivid impressionist colors", + "cyberpunk neon art with glowing outlines, dark backgrounds, and electric accent colors", + } + + galleryPoses = []string{ + "extreme close-up portrait: face and shoulders filling the entire frame, dramatic three-quarter lighting, intense gaze directly at the viewer, fine detail on eyes and expression", + "dynamic action pose: full body, low-angle shot looking up at the main character against the sky or setting backdrop, confident stance, hair and clothing caught in motion", + "atmospheric mid-shot: waist-up, the main character silhouetted or lit by the ambient environment (bioluminescence, sunset, neon glow), looking off into the distance with a sense of wonder or resolve", + "profile close-up: side view of face and upper body, soft rim lighting tracing the jawline and hair, contemplative expression, rich background bokeh", + "power stance full-body: the main character seen from the front at eye level, arms relaxed but ready, environment filling the frame behind them, golden-hour or dramatic storm light", + } +) + +// PromptRenderer renders external or embedded prompt templates. +type PromptRenderer interface { + RenderPrompt(name string, data any) (string, error) +} + +// WordEntry is the vocabulary input consumed by the comic pipeline. +type WordEntry = vocab.WordEntry + +// GenerateResult holds the story text, character bible, comic title, and panel script. +type GenerateResult struct { + StoryText string + Bible string + Title string + PanelScript [][]string +} + +// StoryRunner runs the comic generation pipeline from a vocabulary file. +type StoryRunner interface { + Run(ctx context.Context, batchFile string) error +} + +func pickStoryGenre(genres []string) string { + if len(genres) == 0 { + genres = defaultStoryGenres + } + if len(genres) == 1 { + return genres[0] + } + if rand.Float64() < 0.4 { + return genres[rand.IntN(min(2, len(genres)))] + } + if len(genres) <= 2 { + return genres[rand.IntN(len(genres))] + } + return genres[2+rand.IntN(len(genres)-2)] +} + +func resolveGenre(theme string, genres []string) string { + if strings.TrimSpace(theme) != "" { + return theme + } + return pickStoryGenre(genres) +} + +func pickUltraRealistic() bool { + return rand.Float64() < 0.5 +} + +func pickStyle(styles []string, ultraRealistic bool) string { + if len(styles) == 0 { + if ultraRealistic { + styles = realisticStyles + } else { + styles = comicStyles + } + } + if ultraRealistic { + return styles[rand.IntN(len(styles))] + } + if len(styles) == 1 { + return styles[0] + } + if rand.Float64() < 0.9 { + return styles[0] + } + return styles[1+rand.IntN(len(styles)-1)] +} + +func parseGenerateResult(combined string) GenerateResult { + bibleIdx := strings.Index(combined, storyBibleSeparator) + if bibleIdx < 0 { + return GenerateResult{StoryText: strings.TrimSpace(combined)} + } + + story := strings.TrimSpace(combined[:bibleIdx]) + afterBible := strings.TrimSpace(combined[bibleIdx+len(storyBibleSeparator):]) + + titleIdx := strings.Index(afterBible, storyTitleSeparator) + if titleIdx < 0 { + return GenerateResult{StoryText: story, Bible: strings.TrimSpace(afterBible)} + } + + bible := strings.TrimSpace(afterBible[:titleIdx]) + afterTitle := strings.TrimSpace(afterBible[titleIdx+len(storyTitleSeparator):]) + + panelIdx := strings.Index(afterTitle, storyPanelSeparator) + title := afterTitle + panelText := "" + if panelIdx >= 0 { + title = afterTitle[:panelIdx] + panelText = afterTitle[panelIdx+len(storyPanelSeparator):] + } + if nl := strings.IndexByte(title, '\n'); nl >= 0 { + title = title[:nl] + } + title = strings.TrimSpace(title) + + return GenerateResult{ + StoryText: story, + Bible: bible, + Title: title, + PanelScript: parsePanelScript(panelText), + } +} + +func parsePanelScript(text string) [][]string { + script := make([][]string, storyPagesInScript) + for i := range script { + script[i] = make([]string, storyPanelsPerPage) + } + + panelIndex := map[byte]int{'A': 0, 'B': 1, 'C': 2, 'D': 3} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if len(line) < 7 || line[0] != 'P' || line[2] != '-' || line[4] != ':' { + continue + } + page := int(line[1] - '1') + panel, ok := panelIndex[line[3]] + if !ok || page < 0 || page >= storyPagesInScript { + continue + } + script[page][panel] = strings.TrimSpace(line[5:]) + } + return script +} + +func buildWordList(entries []WordEntry, header string) string { + var sb strings.Builder + sb.WriteString(header) + if header != "" { + sb.WriteString("Words to include:\n") + } + for i, entry := range entries { + word := strings.TrimSpace(entry.Word) + if word == "" { + word = strings.TrimSpace(entry.Translation) + } + if entry.Translation != "" && entry.Word != "" { + sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, entry.Word, entry.Translation)) + continue + } + sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, word)) + } + return sb.String() +} + +func withTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(ctx, timeout) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/tts/gemini.go b/internal/tts/gemini.go new file mode 100644 index 0000000..1836be4 --- /dev/null +++ b/internal/tts/gemini.go @@ -0,0 +1,140 @@ +package tts + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/genai" + + "codeberg.org/snonux/comicforge/internal/provider" +) + +const ( + // DefaultModel is the Gemini TTS model used for narration. + DefaultModel = "gemini-2.5-flash-preview-tts" +) + +// GeminiConfig configures the Gemini TTS provider. +type GeminiConfig struct { + APIKey string + Model string + Voice string +} + +// GeminiProvider generates MP3 audio with Gemini TTS. +type GeminiProvider struct { + client *genai.Client + model string + voice string + err error +} + +var _ provider.TTSProvider = (*GeminiProvider)(nil) + +// NewGeminiProvider creates a Gemini TTS provider. +func NewGeminiProvider(cfg *GeminiConfig) *GeminiProvider { + g := &GeminiProvider{model: DefaultModel} + if cfg == nil { + g.err = fmt.Errorf("tts config is required") + return g + } + g.model = defaultOr(cfg.Model, DefaultModel) + g.voice = cfg.Voice + if strings.TrimSpace(cfg.APIKey) == "" { + g.err = fmt.Errorf("Google API key is required for TTS") + return g + } + client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + APIKey: cfg.APIKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + g.err = fmt.Errorf("create Gemini client: %w", err) + return g + } + g.client = client + return g +} + +// Name returns the provider name. +func (g *GeminiProvider) Name() string { return "gemini" } + +// IsAvailable reports whether the provider was initialized successfully. +func (g *GeminiProvider) IsAvailable() error { + if g == nil { + return fmt.Errorf("tts provider is nil") + } + return g.err +} + +// GenerateAudio writes MP3 audio for the provided text to outputFile. +func (g *GeminiProvider) GenerateAudio(ctx context.Context, text, outputFile string) error { + if g == nil { + return fmt.Errorf("tts provider is nil") + } + if ctx == nil { + ctx = context.Background() + } + if g.err != nil { + return g.err + } + if strings.TrimSpace(text) == "" { + return fmt.Errorf("text is required") + } + if strings.TrimSpace(outputFile) == "" { + return fmt.Errorf("output file is required") + } + voiceName := g.voice + if strings.TrimSpace(voiceName) == "" { + voiceName = "Aoede" + } + + speechCfg := &genai.SpeechConfig{ + VoiceConfig: &genai.VoiceConfig{ + PrebuiltVoiceConfig: &genai.PrebuiltVoiceConfig{VoiceName: voiceName}, + }, + LanguageCode: "bg-BG", + } + resp, err := g.client.Models.GenerateContent(ctx, g.model, genai.Text(text), &genai.GenerateContentConfig{ + ResponseModalities: []string{"AUDIO"}, + SpeechConfig: speechCfg, + }) + if err != nil { + return fmt.Errorf("generate audio: %w", err) + } + data, mimeType, err := extractAudio(resp) + if err != nil { + return err + } + if strings.TrimSpace(outputFile) == "" { + return fmt.Errorf("output file is required") + } + if err := os.WriteFile(outputFile, data, 0o644); err != nil { + return fmt.Errorf("write audio: %w", err) + } + if mimeType != "" && !strings.HasPrefix(mimeType, "audio/") { + return fmt.Errorf("unexpected audio mime type %q", mimeType) + } + return nil +} + +func extractAudio(resp *genai.GenerateContentResponse) ([]byte, string, error) { + if resp == nil || len(resp.Candidates) == 0 || resp.Candidates[0] == nil || resp.Candidates[0].Content == nil { + return nil, "", fmt.Errorf("no audio returned") + } + for _, part := range resp.Candidates[0].Content.Parts { + if part != nil && part.InlineData != nil && len(part.InlineData.Data) > 0 { + return part.InlineData.Data, part.InlineData.MIMEType, nil + } + } + return nil, "", fmt.Errorf("audio payload missing") +} + +func defaultOr(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} -- cgit v1.2.3