From f9baad3a11e48a6d438d3e614e1ca1ee45c1547d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 22 Apr 2026 08:42:56 +0300 Subject: Fix manual prompt mode flags --- README.md | 6 ++- cmd/comicforge/cli.go | 17 ++++--- cmd/comicforge/cli_test.go | 50 +++++++++++++++++++++ internal/comic/artist.go | 16 ++++++- internal/comic/comic_test.go | 100 +++++++++++++++++++++++++++++++++++++++++ internal/comic/types.go | 1 + internal/config/config_test.go | 3 ++ prompts/manual_prompt.md | 9 ++++ 8 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 prompts/manual_prompt.md diff --git a/README.md b/README.md index 63aea84..4f65385 100644 --- a/README.md +++ b/README.md @@ -119,13 +119,13 @@ 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 +- `--style` and `--theme` override story generation hints and are also applied as context in manual prompt mode - `--slug` forces the output folder name - `--narrate` enables narration output - `--narrator-voice` picks the Gemini narration voice - `--text-provider`, `--image-provider`, `--tts-provider` override provider names - `--text-model`, `--image-model`, `--image-text-model`, `--tts-model` override model IDs -- `--ultra-realistic` and `--no-ultra-realistic` control the rendering mode +- `--ultra-realistic` and `--no-ultra-realistic` control the rendering mode for both story and manual prompt mode - `--version` prints the application version Example: @@ -148,3 +148,5 @@ comicforge --prompt "a robot reading a newspaper" --output out --slug manual-rob ``` This writes a single image to `out/comics/assets/manual-robot/prompt.png`. + +Manual prompt mode can be combined with `--style`, `--theme`, and the ultra-realistic flags to shape the generated image prompt. diff --git a/cmd/comicforge/cli.go b/cmd/comicforge/cli.go index 1a7f1ec..47ec2df 100644 --- a/cmd/comicforge/cli.go +++ b/cmd/comicforge/cli.go @@ -83,6 +83,9 @@ func newRootCommandWithDeps(deps commandDeps) *cobra.Command { _, _ = fmt.Fprintln(cmd.OutOrStdout(), version.Version) return nil } + if err := validateUltraRealisticFlags(flags); err != nil { + return err + } if cmd.Flags().Changed("prompt") && strings.TrimSpace(flags.prompt) == "" { return fmt.Errorf("--prompt is required when set") } @@ -105,8 +108,8 @@ func newRootCommandWithDeps(deps commandDeps) *cobra.Command { 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") - cmd.Flags().StringVar(&flags.style, "style", "", "comic art style override") - cmd.Flags().StringVar(&flags.theme, "theme", "", "story theme override") + cmd.Flags().StringVar(&flags.style, "style", "", "comic art style override for story and prompt mode") + cmd.Flags().StringVar(&flags.theme, "theme", "", "story theme override; used as prompt context in manual prompt mode") cmd.Flags().BoolVar(&flags.ultraRealistic, "ultra-realistic", false, "force photorealistic rendering") cmd.Flags().BoolVar(&flags.noUltraRealistic, "no-ultra-realistic", false, "disable photorealistic rendering") cmd.Flags().BoolVar(&flags.narrateEnabled, "narrate", false, "generate narration after the comic") @@ -127,9 +130,6 @@ func runCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags if ctx == nil { ctx = context.Background() } - if flags.noUltraRealistic && flags.ultraRealistic { - return fmt.Errorf("only one of --ultra-realistic and --no-ultra-realistic may be set") - } cfg, err := deps.loadConfig(flags.configPath) if err != nil { @@ -224,6 +224,13 @@ func runPromptCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, return runner.RunPrompt(ctx, flags.prompt) } +func validateUltraRealisticFlags(flags cliFlags) error { + if flags.noUltraRealistic && flags.ultraRealistic { + return fmt.Errorf("only one of --ultra-realistic and --no-ultra-realistic may be set") + } + return nil +} + 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 048d22e..54c5ea8 100644 --- a/cmd/comicforge/cli_test.go +++ b/cmd/comicforge/cli_test.go @@ -300,6 +300,44 @@ func TestRootCommandRejectsConflictingUltraFlags(t *testing.T) { } } +func TestRootCommandRejectsConflictingUltraFlagsInPromptMode(t *testing.T) { + cmd := newRootCommandWithDeps(commandDeps{ + loadConfig: func(string) (*config.Config, error) { + t.Fatal("loadConfig should not be called when ultra-realistic flags conflict") + return nil, nil + }, + newTextProvider: func(*config.Config) (provider.TextProvider, error) { + t.Fatal("newTextProvider should not be called when ultra-realistic flags conflict") + return noopProvider{}, nil + }, + newImageProvider: func(*config.Config) (provider.ImageProvider, error) { + t.Fatal("newImageProvider should not be called when ultra-realistic flags conflict") + return noopProvider{}, nil + }, + newTTSProvider: func(*config.Config, string) (provider.TTSProvider, error) { + t.Fatal("newTTSProvider should not be called when ultra-realistic flags conflict") + return noopProvider{}, nil + }, + newRunner: func(*comic.RunnerConfig) comic.StoryRunner { + t.Fatal("newRunner should not be called when ultra-realistic flags conflict") + return &recordingRunner{} + }, + }) + cmd.SetArgs([]string{ + "--prompt", "draw a robot", + "--ultra-realistic", + "--no-ultra-realistic", + }) + + err := cmd.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("ExecuteContext() error = nil, want conflict error") + } + if !strings.Contains(err.Error(), "only one of --ultra-realistic and --no-ultra-realistic may be set") { + t.Fatalf("ExecuteContext() error = %v, want conflict error", err) + } +} + func TestRootCommandProviderFlagsDefaultToGemini(t *testing.T) { cmd := newRootCommand() for _, flagName := range []string{"text-provider", "image-provider", "tts-provider"} { @@ -399,6 +437,9 @@ func TestRootCommandPromptModeSkipsVocabFlow(t *testing.T) { "--prompt", "a robot reading a newspaper", "--output", filepath.Join(tmpDir, "out"), "--slug", "manual-robot", + "--style", "noir", + "--theme", "mystery", + "--ultra-realistic", }) if err := cmd.ExecuteContext(context.Background()); err != nil { @@ -425,6 +466,15 @@ func TestRootCommandPromptModeSkipsVocabFlow(t *testing.T) { if got, want := gotRunnerCfg.Slug, "manual-robot"; got != want { t.Fatalf("slug = %q, want %q", got, want) } + if got, want := gotRunnerCfg.Style, "noir"; got != want { + t.Fatalf("style = %q, want %q", got, want) + } + if got, want := gotRunnerCfg.Theme, "mystery"; got != want { + t.Fatalf("theme = %q, want %q", got, want) + } + if gotRunnerCfg.UltraRealistic == nil || !*gotRunnerCfg.UltraRealistic { + t.Fatalf("ultra realistic = %#v, want true", gotRunnerCfg.UltraRealistic) + } } func TestRootCommandRejectsPromptAndVocabTogether(t *testing.T) { diff --git a/internal/comic/artist.go b/internal/comic/artist.go index 13826ee..7ccf084 100644 --- a/internal/comic/artist.go +++ b/internal/comic/artist.go @@ -214,7 +214,11 @@ func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, labe } func (a *Artist) generatePromptImage(ctx context.Context, prompt, outputFile string) error { - return a.generateWithRetryAndValidation(ctx, prompt, outputFile, "manual prompt image", nil, nil) + renderedPrompt, err := a.prompts.RenderPrompt(manualPromptTemplate, a.manualPromptData(prompt)) + if err != nil { + return fmt.Errorf("render manual prompt: %w", err) + } + return a.generateWithRetryAndValidation(ctx, renderedPrompt, outputFile, "manual prompt image", nil, nil) } type imageOutputValidator func(context.Context, string, string, string) error @@ -379,6 +383,16 @@ func (a *Artist) backPromptData(storyText, style, bible, blurb string) map[strin } } +func (a *Artist) manualPromptData(prompt string) map[string]any { + return map[string]any{ + "Prompt": strings.TrimSpace(prompt), + "Style": localizedStylePrompt(a.style, a.language, a.script), + "Theme": a.theme, + "RenderingRequirement": a.renderingRequirement(), + "RenderingRequirementEnd": a.renderingRequirementEnd(), + } +} + func (a *Artist) renderingRequirement() string { if a.ultraRealistic { text, err := a.prompts.RenderPrompt(renderingRequirementPrompt, nil) diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go index 29f2a31..866df64 100644 --- a/internal/comic/comic_test.go +++ b/internal/comic/comic_test.go @@ -3,6 +3,7 @@ package comic import ( "context" "errors" + "fmt" "os" "path/filepath" "strings" @@ -485,6 +486,52 @@ func TestRunnerRunPromptRejectsEmptyPrompt(t *testing.T) { } } +func TestRunnerRunPromptAppliesStyleThemeAndUltraContext(t *testing.T) { + t.Parallel() + + originalLeakValidation := validateImagePromptLeakageFn + validateImagePromptLeakageFn = func(context.Context, string, string, string) error { return nil } + t.Cleanup(func() { + validateImagePromptLeakageFn = originalLeakValidation + }) + + provider := &capturingImageProvider{t: t} + renderer := &recordingPromptRenderer{} + runner := NewRunner(&RunnerConfig{ + ImageProvider: provider, + Prompts: renderer, + OutputDir: t.TempDir(), + Slug: "manual-robot", + Style: "noir", + Theme: "mystery", + UltraRealistic: boolPtr(true), + 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 provider.lastPrompt == "" { + t.Fatal("image prompt was not captured") + } + if !strings.Contains(provider.lastPrompt, "Create a single image based on this prompt") { + t.Fatalf("prompt = %q, want manual prompt template", provider.lastPrompt) + } + if !strings.Contains(provider.lastPrompt, "a robot reading a newspaper") { + t.Fatalf("prompt = %q, want raw user prompt", provider.lastPrompt) + } + if !strings.Contains(provider.lastPrompt, "Visual style: noir.") { + t.Fatalf("prompt = %q, want style context", provider.lastPrompt) + } + if !strings.Contains(provider.lastPrompt, "Theme context: mystery.") { + t.Fatalf("prompt = %q, want theme context", provider.lastPrompt) + } + if !strings.Contains(provider.lastPrompt, "FINAL STYLE LOCK — PHOTOREALISM") { + t.Fatalf("prompt = %q, want ultra-realistic context", provider.lastPrompt) + } +} + func TestNewRunnerUsesRealisticWeightWhenUltraRealisticUnset(t *testing.T) { t.Parallel() @@ -847,6 +894,12 @@ func (fakePromptRenderer) RenderPrompt(name string, data any) (string, error) { return "system prompt", nil case storyPromptTemplate, storyFullPromptTemplate: return "prompt", nil + case renderingRequirementPrompt: + return "ULTRA-REALISTIC MODE for this image:\n • The image must look like a real photograph or a frame from a high-budget live-action film.\n • Skin, hair, fabric, metal, and environments must look natural and real, with no drawn or painterly effect.\n • Avoid cartoon, anime, manga, comic-line-art, or obviously illustrated rendering.\n • Text elements, if required by the composition, should look like part of a photographed physical cover, sign, or poster.\n • For gallery images, the whole image must be entirely photographic.\n", nil + case renderingRequirementEndPrompt: + return "FINAL STYLE LOCK — PHOTOREALISM: the entire image must look camera-captured. If anything looks drawn or painted, the result is wrong. Do not drift toward comic art between panels or in gallery images.", nil + case manualPromptTemplate: + return renderManualPromptForTest(data), nil case coverPromptTemplate, storyPagePromptTemplate, galleryPagePromptTemplate, backCoverPromptTemplate: return "image prompt", nil case blurbSystemTemplate, introSystemTemplate, conclusionSystemTemplate: @@ -880,6 +933,12 @@ func (r *recordingPromptRenderer) RenderPrompt(name string, data any) (string, e return "system prompt", nil case storyPromptTemplate, storyFullPromptTemplate: return "prompt", nil + case renderingRequirementPrompt: + return "ULTRA-REALISTIC MODE for this image:\n • The image must look like a real photograph or a frame from a high-budget live-action film.\n • Skin, hair, fabric, metal, and environments must look natural and real, with no drawn or painterly effect.\n • Avoid cartoon, anime, manga, comic-line-art, or obviously illustrated rendering.\n • Text elements, if required by the composition, should look like part of a photographed physical cover, sign, or poster.\n • For gallery images, the whole image must be entirely photographic.\n", nil + case renderingRequirementEndPrompt: + return "FINAL STYLE LOCK — PHOTOREALISM: the entire image must look camera-captured. If anything looks drawn or painted, the result is wrong. Do not drift toward comic art between panels or in gallery images.", nil + case manualPromptTemplate: + return renderManualPromptForTest(data), nil case coverPromptTemplate, storyPagePromptTemplate, galleryPagePromptTemplate, backCoverPromptTemplate: return "image prompt", nil case blurbSystemTemplate, introSystemTemplate, conclusionSystemTemplate: @@ -898,6 +957,32 @@ func isImagePromptTemplate(name string) bool { } } +func renderManualPromptForTest(data any) string { + m, _ := data.(map[string]any) + if m == nil { + return "Create a single image based on this prompt:" + } + var sb strings.Builder + sb.WriteString("Create a single image based on this prompt:\n\n") + sb.WriteString(fmt.Sprint(m["Prompt"])) + sb.WriteString("\n\n") + if style, _ := m["Style"].(string); strings.TrimSpace(style) != "" { + fmt.Fprintf(&sb, "Visual style: %s.\n", style) + } + if theme, _ := m["Theme"].(string); strings.TrimSpace(theme) != "" { + fmt.Fprintf(&sb, "Theme context: %s.\n", theme) + } + if requirement, _ := m["RenderingRequirement"].(string); strings.TrimSpace(requirement) != "" { + sb.WriteString(requirement) + sb.WriteString("\n") + } + sb.WriteString("No text, captions, logos, borders, panels, or UI elements.") + if requirementEnd, _ := m["RenderingRequirementEnd"].(string); strings.TrimSpace(requirementEnd) != "" { + sb.WriteString(requirementEnd) + } + return sb.String() +} + type fakeTextProvider struct{ text string } func (f fakeTextProvider) Name() string { return "fake-text" } @@ -936,6 +1021,21 @@ func (f fakeImageProvider) GenerateImage(_ context.Context, _ string, outputFile return nil } +type capturingImageProvider struct { + t *testing.T + lastPrompt string +} + +func (p *capturingImageProvider) Name() string { return "capturing-image" } +func (p *capturingImageProvider) IsAvailable() error { return nil } +func (p *capturingImageProvider) GenerateImage(_ context.Context, prompt, outputFile string) error { + p.lastPrompt = prompt + if err := os.WriteFile(outputFile, []byte("png"), 0o644); err != nil { + p.t.Fatal(err) + } + return nil +} + type refTrackingImageProvider struct { t *testing.T referenceCounts []int diff --git a/internal/comic/types.go b/internal/comic/types.go index 063d38b..f6c8bcd 100644 --- a/internal/comic/types.go +++ b/internal/comic/types.go @@ -16,6 +16,7 @@ const ( storySystemPromptTemplate = "story_system.md" storyPromptTemplate = "story_prompt.md" storyFullPromptTemplate = "story_full_prompt.md" + manualPromptTemplate = "manual_prompt.md" coverPromptTemplate = "cover_prompt.md" storyPagePromptTemplate = "story_page_prompt.md" galleryPagePromptTemplate = "gallery_page_prompt.md" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 60152be..f825f58 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -190,6 +190,8 @@ func TestEmbeddedPromptTemplatesRender(t *testing.T) { "ScriptName": "кирилица", "Genre": "a mystery with a surprising twist", "Style": "cinematic realism", + "Theme": "mystery", + "Prompt": "a robot reading a newspaper", "Words": "- ябълка\n- книга\n", "Bible": "Мира: млада жена, кафява коса, сини очи, червено палто.\n", "Subtitle": "КомиксФордж Приключения", @@ -230,6 +232,7 @@ func TestEmbeddedPromptTemplatesRender(t *testing.T) { "narrator_conclusion_system.md", "rendering_requirement.md", "rendering_requirement_end.md", + "manual_prompt.md", } { t.Run(name, func(t *testing.T) { got, err := cfg.RenderPrompt(name, data) diff --git a/prompts/manual_prompt.md b/prompts/manual_prompt.md new file mode 100644 index 0000000..177bfca --- /dev/null +++ b/prompts/manual_prompt.md @@ -0,0 +1,9 @@ +Create a single image based on this prompt: + +{{.Prompt}} + +{{if .Style}}Visual style: {{.Style}}. +{{end}}{{if .Theme}}Theme context: {{.Theme}}. +{{end}}{{if .RenderingRequirement}}{{.RenderingRequirement}} +{{end}}No text, captions, logos, borders, panels, or UI elements. +The image must stay faithful to the prompt while incorporating the style and theme context above.{{if .RenderingRequirement}}{{.RenderingRequirementEnd}}{{end}} -- cgit v1.2.3