summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-19 23:03:06 +0300
committerPaul Buetow <paul@buetow.org>2026-04-19 23:03:06 +0300
commitb714194839f4245a5a8fb4ec1282453ea7b77e62 (patch)
treea8a9bc5bbc0951d9ecb3c3c1cf269174f674596c
parent31897cc877545d7c39c3dc56227bf3f1690965ee (diff)
x4: fix comic render failure handling and audio fallback
-rw-r--r--internal/comic/artist.go20
-rw-r--r--internal/comic/comic_test.go92
-rw-r--r--internal/comic/narrator.go10
-rw-r--r--internal/comic/runner.go2
4 files changed, 114 insertions, 10 deletions
diff --git a/internal/comic/artist.go b/internal/comic/artist.go
index c21e633..279aa1d 100644
--- a/internal/comic/artist.go
+++ b/internal/comic/artist.go
@@ -44,6 +44,8 @@ type Artist struct {
initErr error
}
+var sleep = time.Sleep
+
// NewArtist creates an Artist.
func NewArtist(cfg *ArtistConfig) *Artist {
a := &Artist{
@@ -105,7 +107,9 @@ func (a *Artist) DrawComicPages(ctx context.Context, storyText, bible, titleSlug
}
var paths []string
- if p, err := a.renderPage(ctx, titleSlug+"_cover", coverPromptTemplate, a.coverPromptData(storyText, style, resolvedBible), "cover page"); err == nil && p != "" {
+ if p, err := a.renderPage(ctx, titleSlug+"_cover", coverPromptTemplate, a.coverPromptData(storyText, style, resolvedBible), "cover page"); err != nil {
+ return nil, err
+ } else if p != "" {
paths = append(paths, p)
}
@@ -113,7 +117,9 @@ func (a *Artist) DrawComicPages(ctx context.Context, storyText, bible, titleSlug
for i, section := range sections {
pageNum := i + 1
data := a.storyPagePromptData(section, pageNum, style, resolvedBible, entries, panelScript)
- if p, err := a.renderPage(ctx, fmt.Sprintf("%s_page_%d", titleSlug, pageNum), storyPagePromptTemplate, data, fmt.Sprintf("story page %d", pageNum)); err == nil && p != "" {
+ if p, err := a.renderPage(ctx, fmt.Sprintf("%s_page_%d", titleSlug, pageNum), storyPagePromptTemplate, data, fmt.Sprintf("story page %d", pageNum)); err != nil {
+ return nil, err
+ } else if p != "" {
paths = append(paths, p)
}
}
@@ -121,12 +127,16 @@ func (a *Artist) DrawComicPages(ctx context.Context, storyText, bible, titleSlug
for i := 0; i < a.galleryPages; i++ {
galleryNum := i + 1
data := a.galleryPromptData(style, resolvedBible, galleryNum)
- if p, err := a.renderPage(ctx, fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum), galleryPagePromptTemplate, data, fmt.Sprintf("gallery page %d/%d", galleryNum, a.galleryPages)); err == nil && p != "" {
+ if p, err := a.renderPage(ctx, fmt.Sprintf("%s_gallery_%d", titleSlug, galleryNum), galleryPagePromptTemplate, data, fmt.Sprintf("gallery page %d/%d", galleryNum, a.galleryPages)); err != nil {
+ return nil, err
+ } else if p != "" {
paths = append(paths, p)
}
}
- if p, err := a.renderPage(ctx, titleSlug+"_back", backCoverPromptTemplate, a.backPromptData(storyText, style, resolvedBible, blurb), "back cover"); err == nil && p != "" {
+ if p, err := a.renderPage(ctx, titleSlug+"_back", backCoverPromptTemplate, a.backPromptData(storyText, style, resolvedBible, blurb), "back cover"); err != nil {
+ return nil, err
+ } else if p != "" {
paths = append(paths, p)
}
return paths, nil
@@ -170,7 +180,7 @@ func (a *Artist) generateWithRetry(ctx context.Context, prompt, outputFile, labe
if attempt < attempts {
pause := pageRetryBase * time.Duration(attempt)
fmt.Printf(" Warning: %s attempt %d/%d failed (%v), retrying in %s...\n", label, attempt, attempts, err, pause)
- time.Sleep(pause)
+ sleep(pause)
continue
}
return fmt.Errorf("%s failed after %d attempts: %w", label, attempts, err)
diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go
index 9c1dc23..ed263d8 100644
--- a/internal/comic/comic_test.go
+++ b/internal/comic/comic_test.go
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
)
func TestSlugify(t *testing.T) {
@@ -118,6 +119,26 @@ func TestGeneratorGenerateFull(t *testing.T) {
}
}
+func TestDrawComicPagesReturnsErrorWhenRenderFails(t *testing.T) {
+ originalSleep := sleep
+ sleep = func(time.Duration) {}
+ t.Cleanup(func() {
+ sleep = originalSleep
+ })
+
+ artist := NewArtist(&ArtistConfig{
+ ImageProvider: failingImageProvider{},
+ Prompts: fakePromptRenderer{},
+ OutputDir: t.TempDir(),
+ UltraRealistic: false,
+ })
+
+ _, err := artist.DrawComicPages(context.Background(), "story", "bible", "slug", []WordEntry{{Word: "ябълка"}}, nil)
+ if err == nil {
+ t.Fatal("DrawComicPages() error = nil, want render failure")
+ }
+}
+
func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) {
t.Parallel()
@@ -165,6 +186,69 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) {
}
}
+func TestRunnerPropagatesRenderFailures(t *testing.T) {
+ originalSleep := sleep
+ sleep = func(time.Duration) {}
+ t.Cleanup(func() {
+ sleep = originalSleep
+ })
+
+ runner := NewRunner(&RunnerConfig{
+ TextProvider: fakeTextProvider{text: strings.Join([]string{"story", storyBibleSeparator, "bible", storyTitleSeparator, "Title", storyPanelSeparator, "P1-A: a", "P1-B: b", "P1-C: c", "P1-D: d", "P2-A: e", "P2-B: f", "P2-C: g", "P2-D: h", "P3-A: i", "P3-B: j", "P3-C: k", "P3-D: l", "P4-A: m", "P4-B: n", "P4-C: o", "P4-D: p", "P5-A: q", "P5-B: r", "P5-C: s", "P5-D: t"}, "\n")},
+ ImageProvider: failingImageProvider{},
+ MainTTSProvider: fakeTTSProvider{},
+ ConclusionTTSProvider: fakeTTSProvider{},
+ Prompts: fakePromptRenderer{},
+ OutputDir: t.TempDir(),
+ Slug: "forced-slug",
+ NarrateEnabled: false,
+ })
+ runner.assemblePDF = func(string, string, []string) (string, error) {
+ t.Fatal("assemblePDF should not be called on render failure")
+ return "", nil
+ }
+
+ vocabFile := filepath.Join(t.TempDir(), "vocab.txt")
+ if err := os.WriteFile(vocabFile, []byte("ябълка = apple\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := runner.Run(context.Background(), vocabFile); err == nil {
+ t.Fatal("Runner.Run() error = nil, want render failure")
+ }
+}
+
+func TestConvertToStereoFallsBackToCopyWhenFFmpegMissing(t *testing.T) {
+ originalLookPath := lookPath
+ lookPath = func(string) (string, error) {
+ return "", errors.New("missing ffmpeg")
+ }
+ t.Cleanup(func() {
+ lookPath = originalLookPath
+ })
+
+ tmpDir := t.TempDir()
+ inputFile := filepath.Join(tmpDir, "input.mp3")
+ outputFile := filepath.Join(tmpDir, "nested", "output.mp3")
+ if err := os.MkdirAll(filepath.Dir(outputFile), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(inputFile, []byte("audio-bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := convertToStereo(inputFile, outputFile); err != nil {
+ t.Fatalf("convertToStereo() error = %v", err)
+ }
+ got, err := os.ReadFile(outputFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "audio-bytes" {
+ t.Fatalf("convertToStereo() copied %q, want %q", got, "audio-bytes")
+ }
+}
+
type fakePromptRenderer struct{}
func (fakePromptRenderer) RenderPrompt(name string, data any) (string, error) {
@@ -201,6 +285,14 @@ func (f fakeImageProvider) GenerateImage(_ context.Context, _ string, outputFile
return nil
}
+type failingImageProvider struct{}
+
+func (failingImageProvider) Name() string { return "failing-image" }
+func (failingImageProvider) IsAvailable() error { return nil }
+func (failingImageProvider) GenerateImage(_ context.Context, _ string, _ string) error {
+ return errors.New("image render failed")
+}
+
type fakeTTSProvider struct{}
func (fakeTTSProvider) Name() string { return "fake-tts" }
diff --git a/internal/comic/narrator.go b/internal/comic/narrator.go
index 461eb42..1b4365f 100644
--- a/internal/comic/narrator.go
+++ b/internal/comic/narrator.go
@@ -11,6 +11,8 @@ import (
"codeberg.org/snonux/comicforge/internal/provider"
)
+var lookPath = exec.LookPath
+
// NarratorConfig configures narration generation.
type NarratorConfig struct {
TextProvider provider.TextProvider
@@ -260,7 +262,7 @@ func splitIntoNarrationChunks(text string, targetWords int) []string {
}
func mixAmbientMusic(narrationFile, outputFile, tmpDir string) error {
- ffmpegPath, err := exec.LookPath("ffmpeg")
+ ffmpegPath, err := lookPath("ffmpeg")
if err != nil {
return fmt.Errorf("ffmpeg not found")
}
@@ -308,7 +310,7 @@ func generateAmbientPad(ffmpegPath, outputFile string) error {
}
func concatenateMP3s(chunkPaths []string, outputFile, tmpDir string) error {
- ffmpegPath, err := exec.LookPath("ffmpeg")
+ ffmpegPath, err := lookPath("ffmpeg")
if err != nil {
return fmt.Errorf("ffmpeg not found — required for multi-chunk narration: %w", err)
}
@@ -338,10 +340,10 @@ func concatenateMP3s(chunkPaths []string, outputFile, tmpDir string) error {
}
func convertToStereo(inputFile, outputFile string) error {
- ffmpegPath, err := exec.LookPath("ffmpeg")
+ ffmpegPath, err := lookPath("ffmpeg")
if err != nil {
fmt.Println(" Warning: ffmpeg not found, narration will be mono")
- return os.Rename(inputFile, outputFile)
+ return copyFile(inputFile, outputFile)
}
cmd := exec.Command(ffmpegPath,
diff --git a/internal/comic/runner.go b/internal/comic/runner.go
index ea3651e..6ade637 100644
--- a/internal/comic/runner.go
+++ b/internal/comic/runner.go
@@ -144,7 +144,7 @@ func (r *Runner) Run(ctx context.Context, batchFile string) error {
paths, err := r.artist.DrawComicPages(ctx, result.StoryText, result.Bible, slug, entries, result.PanelScript)
if err != nil {
- fmt.Fprintf(os.Stderr, "Warning: comic page generation failed: %v\n", err)
+ return fmt.Errorf("comic page generation failed: %w", err)
}
for _, path := range paths {
fmt.Printf("Comic page saved: %s\n", path)