summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--cmd/comicforge/cli.go17
-rw-r--r--cmd/comicforge/cli_test.go50
-rw-r--r--internal/comic/artist.go16
-rw-r--r--internal/comic/comic_test.go100
-rw-r--r--internal/comic/types.go1
-rw-r--r--internal/config/config_test.go3
-rw-r--r--prompts/manual_prompt.md9
8 files changed, 194 insertions, 8 deletions
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}}