summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md11
-rw-r--r--cmd/comicforge/cli.go51
-rw-r--r--cmd/comicforge/cli_test.go148
-rw-r--r--internal/comic/artist.go14
-rw-r--r--internal/comic/comic_test.go53
-rw-r--r--internal/comic/runner.go39
-rw-r--r--internal/comic/types.go4
7 files changed, 313 insertions, 7 deletions
diff --git a/README.md b/README.md
index 8a70a58..63aea84 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@ ComicForge turns a vocabulary file into a generated comic package. It uses Gemin
ComicForge reads a vocabulary list, generates a story from those words, renders comic pages, and saves supporting files alongside the comic. By default it uses Gemini for text, image, and text-to-speech generation.
+It also supports a manual prompt mode for generating a single image directly from a prompt, without the vocabulary/story pipeline.
+
Generated output includes:
- story text in `comics/assets/<slug>/`
@@ -115,6 +117,7 @@ Vocabulary lines can be:
Useful flags:
- `--output` sets the root output directory
+- `--prompt` generates a single image from a direct prompt and skips the story flow
- `--prompts-dir` overrides the prompt template directory
- `--style` and `--theme` override story generation hints
- `--slug` forces the output folder name
@@ -137,3 +140,11 @@ comicforge \
```
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/`.
+
+For manual prompt mode:
+
+```bash
+comicforge --prompt "a robot reading a newspaper" --output out --slug manual-robot
+```
+
+This writes a single image to `out/comics/assets/manual-robot/prompt.png`.
diff --git a/cmd/comicforge/cli.go b/cmd/comicforge/cli.go
index c241b2c..1a7f1ec 100644
--- a/cmd/comicforge/cli.go
+++ b/cmd/comicforge/cli.go
@@ -28,6 +28,7 @@ type commandDeps struct {
type cliFlags struct {
vocab string
+ prompt string
configPath string
promptsDir string
outputDir string
@@ -82,8 +83,17 @@ func newRootCommandWithDeps(deps commandDeps) *cobra.Command {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), version.Version)
return nil
}
+ if cmd.Flags().Changed("prompt") && strings.TrimSpace(flags.prompt) == "" {
+ return fmt.Errorf("--prompt is required when set")
+ }
+ if strings.TrimSpace(flags.prompt) != "" && strings.TrimSpace(flags.vocab) != "" {
+ return fmt.Errorf("only one of --prompt and --vocab may be set")
+ }
+ if strings.TrimSpace(flags.prompt) != "" {
+ return runPromptCommand(cmd.Context(), cmd, deps, flags)
+ }
if strings.TrimSpace(flags.vocab) == "" {
- return fmt.Errorf("--vocab is required")
+ return fmt.Errorf("--vocab is required unless --prompt is set")
}
return runCommand(cmd.Context(), cmd, deps, flags)
},
@@ -91,6 +101,7 @@ func newRootCommandWithDeps(deps commandDeps) *cobra.Command {
cmd.Flags().BoolVarP(&flags.version, "version", "v", false, "print version information")
cmd.Flags().StringVar(&flags.vocab, "vocab", "", "path to the vocabulary input file")
+ cmd.Flags().StringVar(&flags.prompt, "prompt", "", "manual prompt for generating a single image")
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", ".", "root output directory for generated comic data")
@@ -175,6 +186,44 @@ func runCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags
return runner.Run(ctx, flags.vocab)
}
+func runPromptCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags cliFlags) error {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ cfg, err := deps.loadConfig(flags.configPath)
+ if err != nil {
+ return err
+ }
+
+ applyConfigOverrides(cmd, cfg, flags)
+ imageProvider, err := deps.newImageProvider(cfg)
+ if err != nil {
+ return fmt.Errorf("build image provider: %w", err)
+ }
+
+ runner := deps.newRunner(&comic.RunnerConfig{
+ ImageProvider: imageProvider,
+ Prompts: cfg,
+ OutputDir: flags.outputDir,
+ Style: flags.style,
+ ComicStyles: cfg.Styles.Comic,
+ RealisticStyles: cfg.Styles.Realistic,
+ Theme: flags.theme,
+ Language: cfg.Language.Story,
+ Script: cfg.Language.Script,
+ Slug: flags.slug,
+ UltraRealistic: resolveUltraRealistic(flags),
+ RealisticWeight: cfg.Story.RealisticWeight,
+ AspectRatio: cfg.Comic.AspectRatio,
+ PromptMaxChars: cfg.Comic.PromptMaxChars,
+ PageMaxRetries: cfg.Comic.PageMaxRetries,
+ PageRetryBase: time.Duration(cfg.Comic.PageRetryBaseSeconds) * time.Second,
+ })
+
+ return runner.RunPrompt(ctx, flags.prompt)
+}
+
func applyConfigOverrides(cmd *cobra.Command, cfg *config.Config, flags cliFlags) {
if cfg == nil {
return
diff --git a/cmd/comicforge/cli_test.go b/cmd/comicforge/cli_test.go
index fd7904c..048d22e 100644
--- a/cmd/comicforge/cli_test.go
+++ b/cmd/comicforge/cli_test.go
@@ -363,6 +363,140 @@ func TestRootCommandSkipsTTSProviderWhenNarrationDisabled(t *testing.T) {
}
}
+func TestRootCommandPromptModeSkipsVocabFlow(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ var textCalled bool
+ var ttsCalled bool
+ var gotRunnerCfg *comic.RunnerConfig
+ runner := &recordingRunner{}
+
+ cmd := newRootCommandWithDeps(commandDeps{
+ loadConfig: func(string) (*config.Config, error) {
+ return config.DefaultConfig(), nil
+ },
+ newTextProvider: func(*config.Config) (provider.TextProvider, error) {
+ textCalled = true
+ return noopProvider{}, nil
+ },
+ newImageProvider: func(cfg *config.Config) (provider.ImageProvider, error) {
+ return noopProvider{}, nil
+ },
+ newTTSProvider: func(*config.Config, string) (provider.TTSProvider, error) {
+ ttsCalled = true
+ return noopProvider{}, nil
+ },
+ newRunner: func(cfg *comic.RunnerConfig) comic.StoryRunner {
+ gotRunnerCfg = cfg
+ runner.cfg = cfg
+ return runner
+ },
+ })
+ buf := &bytes.Buffer{}
+ cmd.SetOut(buf)
+ cmd.SetErr(buf)
+ cmd.SetArgs([]string{
+ "--prompt", "a robot reading a newspaper",
+ "--output", filepath.Join(tmpDir, "out"),
+ "--slug", "manual-robot",
+ })
+
+ if err := cmd.ExecuteContext(context.Background()); err != nil {
+ t.Fatalf("ExecuteContext() error = %v\noutput:\n%s", err, buf.String())
+ }
+ if textCalled {
+ t.Fatal("newTextProvider was called, want prompt mode to skip story flow")
+ }
+ if ttsCalled {
+ t.Fatal("newTTSProvider was called, want prompt mode to skip narration setup")
+ }
+ if gotRunnerCfg == nil {
+ t.Fatal("runner config was not captured")
+ }
+ if got, want := runner.prompt, "a robot reading a newspaper"; got != want {
+ t.Fatalf("prompt = %q, want %q", got, want)
+ }
+ if got, want := runner.promptRuns, 1; got != want {
+ t.Fatalf("prompt runs = %d, want %d", got, want)
+ }
+ if got, want := gotRunnerCfg.OutputDir, filepath.Join(tmpDir, "out"); got != want {
+ t.Fatalf("output dir = %q, want %q", got, want)
+ }
+ if got, want := gotRunnerCfg.Slug, "manual-robot"; got != want {
+ t.Fatalf("slug = %q, want %q", got, want)
+ }
+}
+
+func TestRootCommandRejectsPromptAndVocabTogether(t *testing.T) {
+ cmd := newRootCommandWithDeps(commandDeps{
+ loadConfig: func(string) (*config.Config, error) {
+ return config.DefaultConfig(), nil
+ },
+ newTextProvider: func(*config.Config) (provider.TextProvider, error) {
+ t.Fatal("newTextProvider should not be called when flags conflict")
+ return noopProvider{}, nil
+ },
+ newImageProvider: func(*config.Config) (provider.ImageProvider, error) {
+ t.Fatal("newImageProvider should not be called when flags conflict")
+ return noopProvider{}, nil
+ },
+ newTTSProvider: func(*config.Config, string) (provider.TTSProvider, error) {
+ t.Fatal("newTTSProvider should not be called when flags conflict")
+ return noopProvider{}, nil
+ },
+ newRunner: func(*comic.RunnerConfig) comic.StoryRunner {
+ t.Fatal("newRunner should not be called when flags conflict")
+ return &recordingRunner{}
+ },
+ })
+ cmd.SetArgs([]string{
+ "--vocab", "words.txt",
+ "--prompt", "draw a robot",
+ })
+
+ err := cmd.ExecuteContext(context.Background())
+ if err == nil {
+ t.Fatal("ExecuteContext() error = nil, want conflict error")
+ }
+ if !strings.Contains(err.Error(), "only one of --prompt and --vocab may be set") {
+ t.Fatalf("ExecuteContext() error = %v, want conflict error", err)
+ }
+}
+
+func TestRootCommandRejectsEmptyPromptFlag(t *testing.T) {
+ cmd := newRootCommandWithDeps(commandDeps{
+ loadConfig: func(string) (*config.Config, error) {
+ t.Fatal("loadConfig should not be called for empty prompt validation")
+ return nil, nil
+ },
+ newTextProvider: func(*config.Config) (provider.TextProvider, error) {
+ t.Fatal("newTextProvider should not be called for empty prompt validation")
+ return noopProvider{}, nil
+ },
+ newImageProvider: func(*config.Config) (provider.ImageProvider, error) {
+ t.Fatal("newImageProvider should not be called for empty prompt validation")
+ return noopProvider{}, nil
+ },
+ newTTSProvider: func(*config.Config, string) (provider.TTSProvider, error) {
+ t.Fatal("newTTSProvider should not be called for empty prompt validation")
+ return noopProvider{}, nil
+ },
+ newRunner: func(*comic.RunnerConfig) comic.StoryRunner {
+ t.Fatal("newRunner should not be called for empty prompt validation")
+ return &recordingRunner{}
+ },
+ })
+ cmd.SetArgs([]string{"--prompt", ""})
+
+ err := cmd.ExecuteContext(context.Background())
+ if err == nil {
+ t.Fatal("ExecuteContext() error = nil, want empty prompt error")
+ }
+ if !strings.Contains(err.Error(), "--prompt is required when set") {
+ t.Fatalf("ExecuteContext() error = %v, want empty prompt error", err)
+ }
+}
+
func mustLookupFlag(t *testing.T, cmd *cobra.Command, name string) *pflag.Flag {
t.Helper()
@@ -374,9 +508,11 @@ func mustLookupFlag(t *testing.T, cmd *cobra.Command, name string) *pflag.Flag {
}
type recordingRunner struct {
- cfg *comic.RunnerConfig
- batchFile string
- runs int
+ cfg *comic.RunnerConfig
+ batchFile string
+ prompt string
+ runs int
+ promptRuns int
}
func (r *recordingRunner) Run(_ context.Context, batchFile string) error {
@@ -385,6 +521,12 @@ func (r *recordingRunner) Run(_ context.Context, batchFile string) error {
return nil
}
+func (r *recordingRunner) RunPrompt(_ context.Context, prompt string) error {
+ r.prompt = prompt
+ r.promptRuns++
+ return nil
+}
+
type noopProvider struct{}
func (noopProvider) Name() string { return "noop" }
diff --git a/internal/comic/artist.go b/internal/comic/artist.go
index b089769..13826ee 100644
--- a/internal/comic/artist.go
+++ b/internal/comic/artist.go
@@ -210,12 +210,22 @@ func (a *Artist) renderPage(ctx context.Context, fileName, templateName string,
}
func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, label string, refs [][]byte) error {
+ return a.generateWithRetryAndValidation(ctx, prompt, outputFile, label, refs, validateImagePromptLeakageFn)
+}
+
+func (a *Artist) generatePromptImage(ctx context.Context, prompt, outputFile string) error {
+ return a.generateWithRetryAndValidation(ctx, prompt, outputFile, "manual prompt image", nil, nil)
+}
+
+type imageOutputValidator func(context.Context, string, string, string) error
+
+func (a *Artist) generateWithRetryAndValidation(ctx context.Context, prompt, outputFile, label string, refs [][]byte, validator imageOutputValidator) error {
attempts := a.pageMaxRetries
for attempt := 1; attempt <= attempts; attempt++ {
callCtx, cancel := withTimeout(ctx, helperTimeout)
err := a.generateImage(callCtx, prompt, outputFile, refs)
- if err == nil {
- if leakErr := validateImagePromptLeakageFn(callCtx, outputFile, label, a.script); leakErr != nil {
+ if err == nil && validator != nil {
+ if leakErr := validator(callCtx, outputFile, label, a.script); leakErr != nil {
_ = os.Remove(outputFile)
err = leakErr
}
diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go
index 4d0e019..29f2a31 100644
--- a/internal/comic/comic_test.go
+++ b/internal/comic/comic_test.go
@@ -436,6 +436,55 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) {
}
}
+func TestRunnerRunPromptWritesSingleAsset(t *testing.T) {
+ t.Parallel()
+
+ originalLeakValidation := validateImagePromptLeakageFn
+ validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil }
+ t.Cleanup(func() {
+ validateImagePromptLeakageFn = originalLeakValidation
+ })
+
+ tmpDir := t.TempDir()
+ runner := NewRunner(&RunnerConfig{
+ ImageProvider: fakeImageProvider{t: t},
+ Prompts: fakePromptRenderer{},
+ OutputDir: tmpDir,
+ Slug: "manual-robot",
+ UltraRealistic: boolPtr(false),
+ PageMaxRetries: 1,
+ PageRetryBase: time.Second,
+ })
+
+ if err := runner.RunPrompt(context.Background(), "a robot reading a newspaper"); err != nil {
+ t.Fatalf("RunPrompt() error = %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(tmpDir, "comics", "assets", "manual-robot", "prompt.png")); err != nil {
+ t.Fatalf("prompt image missing: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(tmpDir, "comics", "PDF")); !os.IsNotExist(err) {
+ t.Fatalf("PDF directory exists unexpectedly: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(tmpDir, "comics", "gallery")); !os.IsNotExist(err) {
+ t.Fatalf("gallery directory exists unexpectedly: %v", err)
+ }
+}
+
+func TestRunnerRunPromptRejectsEmptyPrompt(t *testing.T) {
+ t.Parallel()
+
+ runner := NewRunner(&RunnerConfig{
+ ImageProvider: fakeImageProvider{t: t},
+ Prompts: fakePromptRenderer{},
+ OutputDir: t.TempDir(),
+ UltraRealistic: boolPtr(false),
+ })
+
+ if err := runner.RunPrompt(context.Background(), " "); err == nil {
+ t.Fatal("RunPrompt() error = nil, want validation failure")
+ }
+}
+
func TestNewRunnerUsesRealisticWeightWhenUltraRealisticUnset(t *testing.T) {
t.Parallel()
@@ -931,3 +980,7 @@ func (p *recordingTTSProvider) GenerateAudio(_ context.Context, text, outputFile
p.calls = append(p.calls, text)
return os.WriteFile(outputFile, []byte("mp3"), 0o644)
}
+
+func boolPtr(v bool) *bool {
+ return &v
+}
diff --git a/internal/comic/runner.go b/internal/comic/runner.go
index 8be8e2c..a9411ef 100644
--- a/internal/comic/runner.go
+++ b/internal/comic/runner.go
@@ -192,6 +192,45 @@ func (r *Runner) Run(ctx context.Context, batchFile string) error {
return r.handleNarration(ctx, result.StoryText, slug, assetsDir)
}
+// RunPrompt renders a single image from a direct user prompt.
+func (r *Runner) RunPrompt(ctx context.Context, prompt string) error {
+ if r == nil || r.config == nil {
+ return fmt.Errorf("runner config is required")
+ }
+ if r.artist == nil {
+ return fmt.Errorf("runner providers are not configured")
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ prompt = strings.TrimSpace(prompt)
+ if prompt == "" {
+ return fmt.Errorf("prompt is required")
+ }
+
+ dir := orDefault(r.config.OutputDir, ".")
+ slug := strings.TrimSpace(r.config.Slug)
+ if slug == "" {
+ slug = slugify(prompt)
+ if slug == "" || slug == "comic" {
+ slug = "manual-prompt"
+ }
+ }
+
+ 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 = assetsDir
+ outputFile := filepath.Join(assetsDir, "prompt.png")
+ if err := r.artist.generatePromptImage(ctx, prompt, outputFile); err != nil {
+ return fmt.Errorf("prompt image generation failed: %w", err)
+ }
+ fmt.Printf("Prompt image saved: %s\n", outputFile)
+ return nil
+}
+
func comicsRootDir(outputRoot string) string {
root := orDefault(outputRoot, ".")
if filepath.Base(filepath.Clean(root)) == "comics" {
diff --git a/internal/comic/types.go b/internal/comic/types.go
index 2f9f03a..063d38b 100644
--- a/internal/comic/types.go
+++ b/internal/comic/types.go
@@ -111,9 +111,11 @@ type GenerateResult struct {
PanelScript [][]string
}
-// StoryRunner runs the comic generation pipeline from a vocabulary file.
+// StoryRunner runs the comic generation pipeline from either a vocabulary file
+// or a direct manual prompt.
type StoryRunner interface {
Run(ctx context.Context, batchFile string) error
+ RunPrompt(ctx context.Context, prompt string) error
}
func pickStoryGenre(genres []string) string {