From 15639ba3508ab92599bd1884ba07139c73b2dac0 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 22 Apr 2026 08:29:14 +0300 Subject: Add manual prompt image mode --- README.md | 11 ++++ cmd/comicforge/cli.go | 51 ++++++++++++++- cmd/comicforge/cli_test.go | 148 ++++++++++++++++++++++++++++++++++++++++++- internal/comic/artist.go | 14 +++- internal/comic/comic_test.go | 53 ++++++++++++++++ internal/comic/runner.go | 39 ++++++++++++ internal/comic/types.go | 4 +- 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//` @@ -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 { -- cgit v1.2.3