summaryrefslogtreecommitdiff
path: root/internal/comic/runner.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/comic/runner.go')
-rw-r--r--internal/comic/runner.go278
1 files changed, 278 insertions, 0 deletions
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
+}