From b714194839f4245a5a8fb4ec1282453ea7b77e62 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 19 Apr 2026 23:03:06 +0300 Subject: x4: fix comic render failure handling and audio fallback --- internal/comic/artist.go | 20 +++++++--- internal/comic/comic_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++++ internal/comic/narrator.go | 10 +++-- internal/comic/runner.go | 2 +- 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) -- cgit v1.2.3