From 214f57bff20564d3684b2634f26412c341e550fb Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Sat, 18 Apr 2026 15:15:59 +0700 Subject: [PATCH] Separate comic captions from generated images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captions were being baked into the image by the FLUX model, causing text to get cut off at the edges. Now the LLM generates scene and caption separately — the scene drives image gen (with explicit "no text in the image" instruction), and the caption is inserted as a styled
/
HTML block at the top of the post. Changes: - rewrite_via_llm() returns array{scene, caption} instead of string - build_article_prompt() and build_source_prompt() return {prompt, caption} - Style suffix v4: removes "caption text" instruction, adds "no text" directive - Image pipeline destructures prompt/caption, stores caption in attachment meta - prepend_caption_to_post() inserts figure+figcaption HTML into post content - Deduplicated generate_image_a/b into shared generate_single_image() - One-time v4 migration auto-updates existing style suffix on deploy Co-Authored-By: Claude Opus 4.6 --- includes/class-prautoblogger.php | 11 + includes/core/class-image-pipeline.php | 205 ++++++++----------- includes/core/class-image-prompt-builder.php | 87 ++++++-- prautoblogger.php | 7 +- tests/unit/Core/ImagePipelineTest.php | 8 + 5 files changed, 174 insertions(+), 144 deletions(-) diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 0b8f33e..454aac0 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -159,6 +159,17 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_style_suffix_v3', '1' ); } + // One-time migration (v4): remove caption-in-image instruction from style suffix. + // Captions are now inserted as HTML below the image, not baked into the image. + if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) { + $current = get_option( 'prautoblogger_image_style_suffix', '' ); + // Detect v3 style suffix (contains "caption text" instruction). + if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) { + update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX ); + } + update_option( 'prautoblogger_migrated_style_suffix_v4', '1' ); + } + // One-time migration: re-wrap existing encrypted values with "enc:" prefix. if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) { $enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ]; diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index 3c2e766..eba78cc 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -4,23 +4,15 @@ declare(strict_types=1); /** * Orchestrates image generation for published articles. * - * Generates two images per article (A/B experiment): - * - Image A: Article-driven prompt → featured image - * - Image B: Source-driven prompt → post meta (_prautoblogger_image_b_id) - * - * Each image is independently fallible — if A fails, still try B. If both fail, - * the article is published without images (graceful degradation). + * Generates two images per article (A/B): Image A (article-driven) as featured + * image, Image B (source-driven) in post meta. Each is independently fallible. * * Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post. - * Dependencies: PRAutoBlogger_Image_Provider_Interface (concrete provider), - * PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Media_Sideloader, - * PRAutoBlogger_Cost_Tracker, PRAutoBlogger_Logger. + * Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger. * * @see core/class-pipeline-runner.php — Calls this after publisher. - * @see core/class-publisher.php — Sets featured image + post meta. - * @see core/class-image-prompt-builder.php — Prompt generation. + * @see core/class-image-prompt-builder.php — Prompt generation (scene + caption). * @see core/class-image-media-sideloader.php — Media library integration. - * @see ARCHITECTURE.md — Image pipeline data flow. */ class PRAutoBlogger_Image_Pipeline { @@ -32,32 +24,16 @@ class PRAutoBlogger_Image_Pipeline { private const DEFAULT_WIDTH = 1200; private const DEFAULT_HEIGHT = 632; - /** - * The image provider (FLUX.1 via Cloudflare or another). - * - * @var PRAutoBlogger_Image_Provider_Interface - */ + /** @var PRAutoBlogger_Image_Provider_Interface Image gen provider (FLUX.1 etc). */ private PRAutoBlogger_Image_Provider_Interface $provider; - /** - * Prompt builder. - * - * @var PRAutoBlogger_Image_Prompt_Builder - */ + /** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */ private PRAutoBlogger_Image_Prompt_Builder $prompt_builder; - /** - * Media library sideloader. - * - * @var PRAutoBlogger_Image_Media_Sideloader - */ + /** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */ private PRAutoBlogger_Image_Media_Sideloader $sideloader; - /** - * Cost tracker for logging spend. - * - * @var PRAutoBlogger_Cost_Tracker - */ + /** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */ private PRAutoBlogger_Cost_Tracker $cost_tracker; /** @@ -134,8 +110,11 @@ class PRAutoBlogger_Image_Pipeline { return $result; } + // Build the article-driven prompt (returns scene + caption). + $article_prompt = $this->prompt_builder->build_article_prompt( $article_data ); + // Generate Image A (article-driven prompt). - $image_a_result = $this->generate_image_a( $post_id, $article_data ); + $image_a_result = $this->generate_image_a( $post_id, $article_prompt['prompt'] ); if ( ! is_wp_error( $image_a_result ) && isset( $image_a_result['attachment_id'] ) ) { $result['image_a_id'] = $image_a_result['attachment_id']; @@ -143,6 +122,13 @@ class PRAutoBlogger_Image_Pipeline { // process times out before Image B finishes or before the // caller gets to handle the return value. set_post_thumbnail( $post_id, $image_a_result['attachment_id'] ); + + // Store the caption as attachment meta for theme/display use. + if ( '' !== $article_prompt['caption'] ) { + update_post_meta( $image_a_result['attachment_id'], '_prautoblogger_image_caption', $article_prompt['caption'] ); + $this->prepend_caption_to_post( $post_id, $image_a_result['attachment_id'], $article_prompt['caption'] ); + } + PRAutoBlogger_Logger::instance()->info( sprintf( 'Set featured image (attachment %d) for post %d', $image_a_result['attachment_id'], $post_id ), 'image_pipeline' @@ -158,13 +144,20 @@ class PRAutoBlogger_Image_Pipeline { // Generate Image B (source-driven prompt) if source data is available. if ( null !== $source_data && ! empty( $source_data ) ) { - $image_b_result = $this->generate_image_b( $post_id, $source_data ); + $source_prompt = $this->prompt_builder->build_source_prompt( $source_data ); + $image_b_result = $this->generate_image_b( $post_id, $source_prompt['prompt'] ); if ( ! is_wp_error( $image_b_result ) && isset( $image_b_result['attachment_id'] ) ) { $result['image_b_id'] = $image_b_result['attachment_id']; // Store Image B reference immediately for the same // timeout-resilience reason as Image A above. update_post_meta( $post_id, '_prautoblogger_image_b_id', $image_b_result['attachment_id'] ); + + // Store the caption as attachment meta. + if ( '' !== $source_prompt['caption'] ) { + update_post_meta( $image_b_result['attachment_id'], '_prautoblogger_image_caption', $source_prompt['caption'] ); + } + PRAutoBlogger_Logger::instance()->info( sprintf( 'Stored Image B (attachment %d) for post %d', $image_b_result['attachment_id'], $post_id ), 'image_pipeline' @@ -187,55 +180,56 @@ class PRAutoBlogger_Image_Pipeline { return $result; } - /** - * Generate Image A from article content. - * - * @param int $post_id Post ID. - * @param array $article_data Article data. - * - * @return array{ - * attachment_id?: int, - * cost_usd: float, - * }|\WP_Error - */ - private function generate_image_a( int $post_id, array $article_data ) { - try { - // Build the article-driven prompt. - $prompt = $this->prompt_builder->build_article_prompt( $article_data ); + /** @see generate_single_image() — Image A wrapper. */ + private function generate_image_a( int $post_id, string $prompt ) { + return $this->generate_single_image( $post_id, $prompt, 'image_a', 'Image A' ); + } - // Estimate cost before generating. + /** @see generate_single_image() — Image B wrapper. */ + private function generate_image_b( int $post_id, string $prompt ) { + return $this->generate_single_image( $post_id, $prompt, 'image_b', 'Image B' ); + } + + /** + * Generate a single image: budget check → provider call → sideload → log. + * + * Shared implementation for Image A and Image B to avoid duplication. + * + * @param int $post_id Post ID. + * @param string $prompt Image generation prompt. + * @param string $slot Cost-tracking slot ('image_a' or 'image_b'). + * @param string $label Human-readable label for log messages. + * + * @return array{attachment_id?: int, cost_usd: float}|\WP_Error + */ + private function generate_single_image( int $post_id, string $prompt, string $slot, string $label ) { + try { $estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) { - return new \WP_Error( - 'budget_exceeded', - 'Image generation would exceed monthly budget.' - ); + return new \WP_Error( 'budget_exceeded', 'Image generation would exceed monthly budget.' ); } - // Generate the image. $image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); - // Sideload the image into media library. $attachment_id = $this->sideloader->sideload_image( $image_data, $post_id, - substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text. + substr( $prompt, 0, 100 ) ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } - // Log the cost. $this->cost_tracker->log_image_generation( $image_data['cost_usd'], $image_data['model'] ?? 'unknown', $post_id, - 'image_a' + $slot ); PRAutoBlogger_Logger::instance()->info( - sprintf( 'Image A generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ), + sprintf( '%s generated for post %d (attachment %d, cost $%.4f)', $label, $post_id, $attachment_id, $image_data['cost_usd'] ), 'image_pipeline' ); @@ -245,7 +239,7 @@ class PRAutoBlogger_Image_Pipeline { ]; } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->error( - sprintf( 'Image A %s: %s', get_class( $e ), $e->getMessage() ), + sprintf( '%s %s: %s', $label, get_class( $e ), $e->getMessage() ), 'image_pipeline' ); @@ -254,68 +248,39 @@ class PRAutoBlogger_Image_Pipeline { } /** - * Generate Image B from source data. + * Prepend a comic caption as a styled figcaption block to post content. * - * @param int $post_id Post ID. - * @param array $source_data Source Reddit data. + * Inserts a
block at the top of the post containing the featured + * image and a
with the comic punchline. Uses inline styles + * for portability across themes. * - * @return array{ - * attachment_id?: int, - * cost_usd: float, - * }|\WP_Error + * @param int $post_id Post ID. + * @param int $attachment_id Featured image attachment ID. + * @param string $caption Caption text (the comic punchline). */ - private function generate_image_b( int $post_id, array $source_data ) { - try { - // Build the source-driven prompt. - $prompt = $this->prompt_builder->build_source_prompt( $source_data ); - - // Estimate cost before generating. - $estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); - if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) { - return new \WP_Error( - 'budget_exceeded', - 'Image generation would exceed monthly budget.' - ); - } - - // Generate the image. - $image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); - - // Sideload the image into media library. - $attachment_id = $this->sideloader->sideload_image( - $image_data, - $post_id, - substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text. - ); - - if ( is_wp_error( $attachment_id ) ) { - return $attachment_id; - } - - // Log the cost. - $this->cost_tracker->log_image_generation( - $image_data['cost_usd'], - $image_data['model'] ?? 'unknown', - $post_id, - 'image_b' - ); - - PRAutoBlogger_Logger::instance()->info( - sprintf( 'Image B generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ), - 'image_pipeline' - ); - - return [ - 'attachment_id' => $attachment_id, - 'cost_usd' => $image_data['cost_usd'], - ]; - } catch ( \Throwable $e ) { - PRAutoBlogger_Logger::instance()->error( - sprintf( 'Image B %s: %s', get_class( $e ), $e->getMessage() ), - 'image_pipeline' - ); - - return new \WP_Error( 'image_generation_failed', $e->getMessage() ); + private function prepend_caption_to_post( int $post_id, int $attachment_id, string $caption ): void { + $post = get_post( $post_id ); + if ( ! $post ) { + return; } + + $img_url = wp_get_attachment_url( $attachment_id ); + $alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); + + // Build a self-contained figure block with the comic image and caption. + $figure = sprintf( + '
' + . '%s' + . '
— "%s"
' + . '
', + esc_url( $img_url ), + esc_attr( $alt ), + esc_html( $caption ) + ); + + wp_update_post( [ + 'ID' => $post_id, + 'post_content' => $figure . "\n\n" . $post->post_content, + ] ); } } diff --git a/includes/core/class-image-prompt-builder.php b/includes/core/class-image-prompt-builder.php index d6cc4b1..a9a0b90 100644 --- a/includes/core/class-image-prompt-builder.php +++ b/includes/core/class-image-prompt-builder.php @@ -67,7 +67,9 @@ PROMPT; * Build a visual prompt from finished article content. * * Tries LLM rewriting first; falls back to rule-based synthesis on failure. - * Appends the style suffix from plugin options. + * Separates the scene description (for image gen) from the caption text + * (for insertion below the image as HTML). Appends style suffix to the + * scene-only prompt so no text is rendered inside the image. * * @param array{ * post_title?: string, @@ -75,18 +77,21 @@ PROMPT; * suggested_title?: string, * } $article_data Article data with title and HTML content. * - * @return string Concise prompt with style suffix appended. + * @return array{prompt: string, caption: string} Image prompt (scene + style) and caption text. */ - public function build_article_prompt( array $article_data ): string { + public function build_article_prompt( array $article_data ): array { $title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product'; $content = $article_data['post_content'] ?? ''; $first_para = $this->extract_first_paragraph( $content ); - $concepts = $this->rewrite_via_llm( $title, $first_para ); + $parsed = $this->rewrite_via_llm( $title, $first_para ); $style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX ); - return trim( $concepts . ' ' . $style_suffix ); + return [ + 'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ), + 'caption' => $parsed['caption'], + ]; } /** @@ -101,22 +106,29 @@ PROMPT; * comments?: string[], * } $source_data Reddit source data with title and comments. * - * @return string Concise prompt with style suffix appended. + * @return array{prompt: string, caption: string} Image prompt (scene + style) and caption text. */ - public function build_source_prompt( array $source_data ): string { + public function build_source_prompt( array $source_data ): array { $title = $source_data['title'] ?? 'Reddit Discussion'; $comments = $source_data['comments'] ?? []; $context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : ''; - $concepts = $this->rewrite_via_llm( $title, $context ); + $parsed = $this->rewrite_via_llm( $title, $context ); $style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX ); - return trim( $concepts . ' ' . $style_suffix ); + return [ + 'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ), + 'caption' => $parsed['caption'], + ]; } /** - * Use a cheap LLM to distill title + context into a visual scene. + * Use a cheap LLM to distill title + context into a visual scene + caption. + * + * The LLM response contains a SCENE line and a CAPTION line separated by a + * blank line. This method parses them apart so the scene drives image gen + * (no text baked in) and the caption is inserted as HTML below the image. * * Falls back to rule-based synthesis if the LLM call fails for any * reason (network, auth, timeout, unexpected response shape). @@ -125,9 +137,9 @@ PROMPT; * * @param string $title Article or thread title. * @param string $context First paragraph or top comment. - * @return string Visual scene description (without style suffix). + * @return array{scene: string, caption: string} Scene for image gen, caption for HTML. */ - private function rewrite_via_llm( string $title, string $context ): string { + private function rewrite_via_llm( string $title, string $context ): array { $title = trim( sanitize_text_field( $title ) ); $context = trim( sanitize_text_field( $context ) ); @@ -157,7 +169,7 @@ PROMPT; ] ); - $scene = trim( $result['content'] ?? '' ); + $raw = trim( $result['content'] ?? '' ); // Log the rewrite cost so it shows in the analytics dashboard. ( new PRAutoBlogger_Cost_Tracker() )->log_api_call( @@ -176,12 +188,12 @@ PROMPT; ); PRAutoBlogger_Logger::instance()->debug( - sprintf( 'Image prompt rewritten (%d→%d chars, $%.6f): %s', strlen( $user_message ), strlen( $scene ), $cost, substr( $scene, 0, 120 ) ), + sprintf( 'Image prompt rewritten (%d→%d chars, $%.6f): %s', strlen( $user_message ), strlen( $raw ), $cost, substr( $raw, 0, 120 ) ), 'image_prompt_builder' ); - if ( '' !== $scene ) { - return $scene; + if ( '' !== $raw ) { + return $this->parse_scene_and_caption( $raw ); } } catch ( \Throwable $e ) { // LLM failure is not fatal — fall back to rule-based synthesis. @@ -194,6 +206,36 @@ PROMPT; return $this->synthesize_visual_concepts_fallback( $title, $context ); } + /** + * Parse the LLM response into separate scene and caption parts. + * + * Expected format from the LLM: + * Scene description text here. + * + * "Caption punchline here." + * + * Falls back gracefully: if no blank-line separator is found, the entire + * response becomes the scene with an empty caption. + * + * @param string $raw Raw LLM response text. + * @return array{scene: string, caption: string} + */ + private function parse_scene_and_caption( string $raw ): array { + // Split on blank line (the format the system prompt requests). + $parts = preg_split( '/\n\s*\n/', $raw, 2 ); + + if ( count( $parts ) >= 2 ) { + $scene = trim( $parts[0] ); + $caption = trim( $parts[1] ); + // Strip surrounding quotes from caption — the LLM wraps it in quotes. + $caption = trim( $caption, '"\'' ); + return [ 'scene' => $scene, 'caption' => $caption ]; + } + + // No blank-line separator found — treat entire response as scene. + return [ 'scene' => trim( $raw ), 'caption' => '' ]; + } + /** * Extract the first paragraph from HTML content. * @@ -222,16 +264,19 @@ PROMPT; * * @param string $title Main heading / topic. * @param string $supporting_text Additional context. - * @return string Synthesized visual prompt concept. + * @return array{scene: string, caption: string} Scene for image gen, caption for HTML. */ - private function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): string { + private function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array { // Fallback produces a simple comic concept when the LLM is unavailable. - $prompt = sprintf( - 'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s. Caption below reads: "Science is full of surprises."', + $scene = sprintf( + 'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.', $title ); - return trim( $prompt ); + return [ + 'scene' => trim( $scene ), + 'caption' => 'Science is full of surprises.', + ]; } /** diff --git a/prautoblogger.php b/prautoblogger.php index 217b3e7..23bf323 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -60,11 +60,12 @@ define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'openrouter' ); define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'google/gemini-2.5-flash-image' ); // Default style suffix appended to every image-gen prompt. Updated -// 2026-04-18: single-panel newspaper comic style. Users may override -// via Settings → Images → Style Suffix. +// 2026-04-18 v4: single-panel newspaper comic style. Caption is now +// inserted as HTML text below the image, NOT rendered inside it. +// Users may override via Settings → Images → Style Suffix. define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', - 'Style: a single-panel newspaper comic in classic black ink on white paper, simple clean linework like The Far Side or B.C., slightly yellowed newsprint texture, hand-drawn crosshatching for shading, bold panel border, caption text in a clean sans-serif font below the panel, warm and humorous tone, visible in a newspaper comics section.' + 'Style: a single-panel newspaper comic in classic black ink on white paper, simple clean linework like The Far Side or B.C., slightly yellowed newsprint texture, hand-drawn crosshatching for shading, bold panel border, warm and humorous tone, visible in a newspaper comics section. IMPORTANT: Do not render any text, captions, speech bubbles, or words inside the image.' ); /* diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index 5d340f6..fdcf96a 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -35,8 +35,16 @@ class ImagePipelineTest extends BaseTestCase { return trim( strip_tags( $str ) ); } ); Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'set_post_thumbnail' )->justReturn( true ); + Functions\when( 'get_post_meta' )->justReturn( '' ); + Functions\when( 'get_post' )->justReturn( (object) [ 'ID' => 1, 'post_content' => '

Test content

' ] ); + Functions\when( 'wp_update_post' )->justReturn( 1 ); + Functions\when( 'wp_get_attachment_url' )->justReturn( 'https://example.com/img.png' ); Functions\when( 'sanitize_title' )->returnArg(); Functions\when( 'esc_html__' )->returnArg(); + Functions\when( 'esc_url' )->returnArg(); + Functions\when( 'esc_attr' )->returnArg(); + Functions\when( 'esc_html' )->returnArg(); } /**