From d5f5e52db275aa41a0cbaae760eb23ce8e24b160 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 22 Apr 2026 08:20:25 +0300 Subject: Rework comic output layout --- README.md | 17 +++++++++-------- cmd/comicforge/cli.go | 2 +- internal/comic/comic_test.go | 28 ++++++++++++++++++++-------- internal/comic/runner.go | 37 ++++++++++++++++++++++++------------- 4 files changed, 54 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index eea6533..8a70a58 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![ComicForge logo](assets/comicforge-logo.png) -ComicForge turns a vocabulary file into a generated comic package. It uses Gemini-backed providers to write a story, draw comic pages, and optionally produce narration. The CLI writes the finished assets into `./comics//` and can also assemble a PDF. +ComicForge turns a vocabulary file into a generated comic package. It uses Gemini-backed providers to write a story, draw comic pages, and optionally produce narration. The CLI writes comic assets into `./comics/assets//`, gallery copies into `./comics/gallery/`, and final PDFs into `./comics/PDF/`. ## What It Does @@ -10,12 +10,13 @@ ComicForge reads a vocabulary list, generates a story from those words, renders Generated output includes: -- story text -- vocabulary recap -- theme file -- comic page PNGs -- PDF output when page rendering succeeds -- narration MP3 when narration is enabled and a TTS provider is available +- story text in `comics/assets//` +- vocabulary recap in `comics/assets//` +- theme file in `comics/assets//` +- comic page PNGs in `comics/assets//` +- gallery PNG copies in `comics/gallery/` +- PDF output in `comics/PDF/` when page rendering succeeds +- narration MP3 in `comics/assets//` when narration is enabled and a TTS provider is available ## Installation @@ -135,4 +136,4 @@ comicforge \ --narrate ``` -The generated files are written under `out/comics/demo-comic/`. +The generated files are written under `out/comics/assets/demo-comic/`, with the gallery copied to `out/comics/gallery/` and the PDF written to `out/comics/PDF/`. diff --git a/cmd/comicforge/cli.go b/cmd/comicforge/cli.go index a87a226..c241b2c 100644 --- a/cmd/comicforge/cli.go +++ b/cmd/comicforge/cli.go @@ -93,7 +93,7 @@ func newRootCommandWithDeps(deps commandDeps) *cobra.Command { cmd.Flags().StringVar(&flags.vocab, "vocab", "", "path to the vocabulary input file") cmd.Flags().StringVar(&flags.configPath, "config", "", "config file (default: search ~/.config/comicforge, $HOME, and .)") cmd.Flags().StringVar(&flags.promptsDir, "prompts-dir", "", "directory containing prompt templates") - cmd.Flags().StringVar(&flags.outputDir, "output", ".", "output directory for generated comic assets") + cmd.Flags().StringVar(&flags.outputDir, "output", ".", "root output directory for generated comic data") cmd.Flags().StringVar(&flags.style, "style", "", "comic art style override") cmd.Flags().StringVar(&flags.theme, "theme", "", "story theme override") cmd.Flags().BoolVar(&flags.ultraRealistic, "ultra-realistic", false, "force photorealistic rendering") diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go index 0714769..c99bedd 100644 --- a/internal/comic/comic_test.go +++ b/internal/comic/comic_test.go @@ -183,17 +183,26 @@ func TestValidateGeneratedResultUsesExcelStylePanelLabels(t *testing.T) { } } -func TestComicOutputDirDoesNotDuplicateComicsSegment(t *testing.T) { +func TestComicAssetAndPDFDirs(t *testing.T) { t.Parallel() - if got, want := comicOutputDir("comics", "slug"), filepath.Join("comics", "slug"); got != want { - t.Fatalf("comicOutputDir() = %q, want %q", got, want) + if got, want := comicAssetsDir("comics", "slug"), filepath.Join("comics", "assets", "slug"); got != want { + t.Fatalf("comicAssetsDir() = %q, want %q", got, want) } - if got, want := comicOutputDir(".", "slug"), filepath.Join(".", "comics", "slug"); got != want { - t.Fatalf("comicOutputDir() = %q, want %q", got, want) + if got, want := comicAssetsDir(".", "slug"), filepath.Join(".", "comics", "assets", "slug"); got != want { + t.Fatalf("comicAssetsDir() = %q, want %q", got, want) } - if got, want := comicOutputDir("/tmp/out", "slug"), filepath.Join("/tmp/out", "comics", "slug"); got != want { - t.Fatalf("comicOutputDir() = %q, want %q", got, want) + if got, want := comicAssetsDir("/tmp/out", "slug"), filepath.Join("/tmp/out", "comics", "assets", "slug"); got != want { + t.Fatalf("comicAssetsDir() = %q, want %q", got, want) + } + if got, want := comicsPDFDir("comics"), filepath.Join("comics", "PDF"); got != want { + t.Fatalf("comicsPDFDir() = %q, want %q", got, want) + } + if got, want := comicsPDFDir("."), filepath.Join(".", "comics", "PDF"); got != want { + t.Fatalf("comicsPDFDir() = %q, want %q", got, want) + } + if got, want := comicsPDFDir("/tmp/out"), filepath.Join("/tmp/out", "comics", "PDF"); got != want { + t.Fatalf("comicsPDFDir() = %q, want %q", got, want) } } @@ -409,9 +418,12 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) { 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 { + if _, err := os.Stat(filepath.Join(tmpDir, "comics", "PDF", "forced-slug.pdf")); err != nil { t.Fatalf("pdf missing: %v", err) } + if _, err := os.Stat(filepath.Join(tmpDir, "comics", "assets", "forced-slug", "forced-slug_story.txt")); err != nil { + t.Fatalf("story missing: %v", err) + } } func TestNewRunnerUsesRealisticWeightWhenUltraRealisticUnset(t *testing.T) { diff --git a/internal/comic/runner.go b/internal/comic/runner.go index cc45e53..b1381fb 100644 --- a/internal/comic/runner.go +++ b/internal/comic/runner.go @@ -146,19 +146,22 @@ func (r *Runner) Run(ctx context.Context, batchFile string) error { fmt.Printf(" Comic title: %q (slug: %s)\n", result.Title, slug) } - comicsDir := comicOutputDir(dir, slug) - if err := os.MkdirAll(comicsDir, 0o755); err != nil { - return fmt.Errorf("create comics dir %s: %w", comicsDir, err) + assetsDir := comicAssetsDir(dir, slug) + if err := os.MkdirAll(assetsDir, 0o755); err != nil { + return fmt.Errorf("create comics assets dir %s: %w", assetsDir, err) } - r.artist.outputDir = comicsDir + if err := os.MkdirAll(comicsPDFDir(dir), 0o755); err != nil { + return fmt.Errorf("create comics pdf dir %s: %w", comicsPDFDir(dir), err) + } + r.artist.outputDir = assetsDir - if err := r.saveStoryText(result.StoryText, slug, comicsDir); err != nil { + if err := r.saveStoryText(result.StoryText, slug, assetsDir); err != nil { return err } - if err := r.saveVocabularyFile(result.StoryText, entries, slug, comicsDir); err != nil { + if err := r.saveVocabularyFile(result.StoryText, entries, slug, assetsDir); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not write vocabulary file: %v\n", err) } - if err := r.saveThemeFile(slug, comicsDir); err != nil { + if err := r.saveThemeFile(slug, assetsDir); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not write theme file: %v\n", err) } @@ -169,12 +172,12 @@ func (r *Runner) Run(ctx context.Context, batchFile string) error { for _, path := range paths { fmt.Printf("Comic page saved: %s\n", path) } - rootDir := filepath.Dir(filepath.Dir(r.artist.outputDir)) + rootDir := comicsRootDir(dir) 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) + pdfPath, err := r.assemblePDF(comicsPDFDir(dir), slug, paths) if err != nil { fmt.Fprintf(os.Stderr, "Warning: PDF assembly failed: %v\n", err) } else { @@ -186,15 +189,23 @@ func (r *Runner) Run(ctx context.Context, batchFile string) error { fmt.Println("Narration skipped (enable narration in config to produce audio).") return nil } - return r.handleNarration(ctx, result.StoryText, slug, comicsDir) + return r.handleNarration(ctx, result.StoryText, slug, assetsDir) } -func comicOutputDir(outputRoot, slug string) string { +func comicsRootDir(outputRoot string) string { root := orDefault(outputRoot, ".") if filepath.Base(filepath.Clean(root)) == "comics" { - return filepath.Join(root, slug) + return root } - return filepath.Join(root, "comics", slug) + return filepath.Join(root, "comics") +} + +func comicAssetsDir(outputRoot, slug string) string { + return filepath.Join(comicsRootDir(outputRoot), "assets", slug) +} + +func comicsPDFDir(outputRoot string) string { + return filepath.Join(comicsRootDir(outputRoot), "PDF") } var _ StoryRunner = (*Runner)(nil) -- cgit v1.2.3