summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-22 08:20:25 +0300
committerPaul Buetow <paul@buetow.org>2026-04-22 08:20:25 +0300
commitd5f5e52db275aa41a0cbaae760eb23ce8e24b160 (patch)
tree3eacb82b6f509eb7b1f5b56af588854cdf20ae19
parente809e691649923b87ff4c13366069e69c67d2d7b (diff)
Rework comic output layout
-rw-r--r--README.md17
-rw-r--r--cmd/comicforge/cli.go2
-rw-r--r--internal/comic/comic_test.go28
-rw-r--r--internal/comic/runner.go37
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/<slug>/` 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/<slug>/`, 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/<slug>/`
+- vocabulary recap in `comics/assets/<slug>/`
+- theme file in `comics/assets/<slug>/`
+- comic page PNGs in `comics/assets/<slug>/`
+- gallery PNG copies in `comics/gallery/`
+- PDF output in `comics/PDF/` when page rendering succeeds
+- narration MP3 in `comics/assets/<slug>/` 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)