From d8291cb1554adc50311bbdc839288fde4c3f1a71 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 19:43:21 -0300 Subject: [PATCH] feat(ai-templates): tweet-card-with-photo style (blurred dark image background) --- app/Ai/Templates/AiTemplateRegistry.php | 1 + app/Ai/Templates/TweetCardImageTemplate.php | 163 ++++++++++++++++++ app/Ai/Templates/TweetCardTemplate.php | 2 +- app/Jobs/Ai/StreamPostCreation.php | 2 +- app/Services/Image/PostImagePipeline.php | 29 +++- app/Services/Image/TemplateImageGenerator.php | 157 ++++++++++++++--- lang/en/posts.php | 4 + lang/es/posts.php | 4 + lang/pt-BR/posts.php | 4 + .../post_content/tweet_card_image.blade.php | 41 +++++ .../tweet_card_image_carousel.blade.php | 52 ++++++ tests/Feature/Ai/StreamPostCreationTest.php | 69 ++++++++ .../Ai/Templates/AiTemplateRegistryTest.php | 4 +- .../Ai/Templates/TweetCardImageRenderTest.php | 150 ++++++++++++++++ .../Templates/TweetCardImageTemplateTest.php | 45 +++++ 15 files changed, 689 insertions(+), 38 deletions(-) create mode 100644 app/Ai/Templates/TweetCardImageTemplate.php create mode 100644 resources/views/prompts/post_content/tweet_card_image.blade.php create mode 100644 resources/views/prompts/post_content/tweet_card_image_carousel.blade.php create mode 100644 tests/Feature/Ai/Templates/TweetCardImageRenderTest.php create mode 100644 tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php index 712737a0..05222a9d 100644 --- a/app/Ai/Templates/AiTemplateRegistry.php +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -12,6 +12,7 @@ class AiTemplateRegistry private const TEMPLATES = [ ImageCardTemplate::class, TweetCardTemplate::class, + TweetCardImageTemplate::class, ]; /** @var array|null */ diff --git a/app/Ai/Templates/TweetCardImageTemplate.php b/app/Ai/Templates/TweetCardImageTemplate.php new file mode 100644 index 00000000..84271c95 --- /dev/null +++ b/app/Ai/Templates/TweetCardImageTemplate.php @@ -0,0 +1,163 @@ + */ + public function supportedFormats(): array + { + return []; + } + + public function generatorFormat(): string + { + return 'tweet_card'; + } + + public function promptView(TemplateContext $context): string + { + return $context->isCarousel + ? 'prompts.post_content.tweet_card_image_carousel' + : 'prompts.post_content.tweet_card_image'; + } + + /** + * @return array + */ + public function schema(JsonSchema $schema, TemplateContext $context): array + { + if ($context->isCarousel) { + $slideCount = $context->imageCount > 0 ? $context->imageCount : 1; + + return [ + 'caption' => $schema->string()->description('The caption for the carousel post (teases the content, encourages swiping).')->required(), + 'slides' => $schema->array() + ->items($schema->object(fn ($s) => [ + 'tweet_text' => $s->string() + ->description('The tweet-style text for this slide. First-person, punchy, max ~560 characters.') + ->required(), + 'image_keywords' => $s->array() + ->items($schema->string()) + ->description('2-4 English keywords for a background photo that fits this slide\'s theme. The photo will be blurred and darkened behind the card.') + ->required(), + ])) + ->min($slideCount) + ->max($slideCount) + ->description("Exactly {$slideCount} slides, each a self-contained tweet-card. First slide must hook the reader.") + ->required(), + ]; + } + + return [ + 'tweet_text' => $schema->string() + ->description('The post body, written as a punchy first-person X/Twitter-style take. Paragraph breaks (\\n\\n) allowed. Max ~560 characters.') + ->required(), + 'image_keywords' => $schema->array() + ->items($schema->string()) + ->description('2-4 English keywords for a background photo that fits the post\'s theme. The photo will be blurred and darkened behind the card.') + ->required(), + ]; + } + + /** + * @param array $structured + */ + public function assemble(array $structured, TemplateContext $context): GeneratedPost + { + if ($context->isCarousel) { + return $this->assembleCarousel($structured, $context); + } + + return $this->assembleSingle($structured, $context); + } + + /** + * @param array $structured + */ + private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost + { + $text = (string) data_get($structured, 'tweet_text', ''); + $imageKeywords = data_get($structured, 'image_keywords', []); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forTweetCard( + workspace: $context->workspace, + account: $context->socialAccount, + tweetText: $text, + imageKeywords: $imageKeywords, + ); + } + + return new GeneratedPost( + content: $text, + media: $media, + contentType: ContentType::tryFrom($context->format), + ); + } + + /** + * @param array $structured + */ + private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost + { + $caption = (string) data_get($structured, 'caption', ''); + + $slides = array_map( + fn ($slide) => [ + 'tweet_text' => (string) data_get($slide, 'tweet_text', ''), + 'image_keywords' => data_get($slide, 'image_keywords', []), + ], + data_get($structured, 'slides', []), + ); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forTweetCardCarousel( + workspace: $context->workspace, + account: $context->socialAccount, + slides: $slides, + ); + } + + return new GeneratedPost( + content: $caption, + media: $media, + contentType: ContentType::InstagramFeed, + ); + } +} diff --git a/app/Ai/Templates/TweetCardTemplate.php b/app/Ai/Templates/TweetCardTemplate.php index 69c4d89d..ded38467 100644 --- a/app/Ai/Templates/TweetCardTemplate.php +++ b/app/Ai/Templates/TweetCardTemplate.php @@ -137,7 +137,7 @@ private function assembleCarousel(array $structured, TemplateContext $context): $media = app(PostImagePipeline::class)->forTweetCardCarousel( workspace: $context->workspace, account: $context->socialAccount, - slideTexts: $slideTexts, + slides: $slideTexts, ); } diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 73e84729..70946076 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -118,7 +118,7 @@ public function handle(): void */ private function humanize(Workspace $workspace, array $structured, string $format, string $styleKey = 'image_card'): array { - if ($styleKey === 'tweet_card') { + if ($styleKey === 'tweet_card' || $styleKey === 'tweet_card_image') { return $structured; } diff --git a/app/Services/Image/PostImagePipeline.php b/app/Services/Image/PostImagePipeline.php index f4098a3e..0de6d4d8 100644 --- a/app/Services/Image/PostImagePipeline.php +++ b/app/Services/Image/PostImagePipeline.php @@ -84,11 +84,15 @@ public function forCarousel(Workspace $workspace, SocialAccount $account, array * Render a tweet-card image for the given text and return a one-element * media-item array, or an empty array when the generator renders nothing. * + * When $imageKeywords is non-null a blurred AI-photo background is used + * (tweet_card_image); null produces the solid brand-color background (tweet_card). + * + * @param array|null $imageKeywords * @return array> */ - public function forTweetCard(Workspace $workspace, SocialAccount $account, string $tweetText): array + public function forTweetCard(Workspace $workspace, SocialAccount $account, string $tweetText, ?array $imageKeywords = null): array { - $rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText); + $rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText, $imageKeywords); if (! $rendered) { return []; @@ -98,18 +102,29 @@ public function forTweetCard(Workspace $workspace, SocialAccount $account, strin } /** - * Render one tweet-card image per slide text and return the media-item array. + * Render one tweet-card image per slide and return the media-item array. * Slides that render nothing are skipped. * - * @param array $slideTexts + * Each entry in $slides is either a plain string (tweet text, solid background) + * or an array with keys `tweet_text` and optionally `image_keywords` (image bg). + * + * @param array> $slides * @return array> */ - public function forTweetCardCarousel(Workspace $workspace, SocialAccount $account, array $slideTexts): array + public function forTweetCardCarousel(Workspace $workspace, SocialAccount $account, array $slides): array { $media = []; - foreach ($slideTexts as $tweetText) { - $rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText); + foreach ($slides as $slide) { + if (is_string($slide)) { + $tweetText = $slide; + $imageKeywords = null; + } else { + $tweetText = (string) data_get($slide, 'tweet_text', ''); + $imageKeywords = data_get($slide, 'image_keywords'); + } + + $rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText, $imageKeywords); if ($rendered) { $media[] = $this->buildAiMediaItem($workspace, $rendered); diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index 92915295..a2a5b0e3 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -549,12 +549,18 @@ private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hex } /** - * Renders the post as a fake X/Twitter card on a solid brand-color background. - * Text + identity composition only — never calls the AI image client. + * Renders the post as a fake X/Twitter card. * + * When $imageKeywords is null (default), a solid brand-color background is used + * (original tweet_card behaviour). When $imageKeywords is a non-empty array, an + * AI-generated photo is fetched, blurred, darkened, and used as the background + * (tweet_card_image behaviour). Falls back to the solid colour when AI generation + * returns null so the render never hard-fails. + * + * @param array|null $imageKeywords * @return array{path: string, source_meta: array}|null */ - public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccount, string $tweetText): ?array + public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccount, string $tweetText, ?array $imageKeywords = null): ?array { $this->width = self::DEFAULT_WIDTH; $this->height = self::DEFAULT_HEIGHT; @@ -570,9 +576,127 @@ public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccou imagealphablending($core, true); imagesavealpha($core, false); - $pageBg = imagecolorallocate($core, $pr, $pg, $pb); - imagefill($core, 0, 0, $pageBg); + $useImageBackground = $imageKeywords !== null && $imageKeywords !== []; + if ($useImageBackground) { + $canvas = $this->applyTweetCardImageBackground($manager, $canvas, $core, $workspace, $imageKeywords); + $core = $canvas->core()->native(); + } else { + $pageBg = imagecolorallocate($core, $pr, $pg, $pb); + imagefill($core, 0, 0, $pageBg); + } + + $this->drawTweetCardContent($canvas, $core, $socialAccount, $tweetText); + + $template = $useImageBackground ? 'tweet_card_image' : 'tweet_card'; + $filename = "ai-images/tweet_{$template}_".uniqid('', true).'.webp'; + Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85))); + + RecordAiUsage::recordTemplate( + workspace: $workspace, + provider: 'internal', + metadata: [ + 'template' => $template, + 'width' => $this->width, + 'height' => $this->height, + ], + ); + + $sourceMeta = [ + 'template' => $template, + 'tweet_text' => $tweetText, + 'width' => $this->width, + 'height' => $this->height, + ]; + + if ($useImageBackground) { + $sourceMeta['keywords'] = array_values($imageKeywords); + } + + return [ + 'path' => $filename, + 'source_meta' => $sourceMeta, + ]; + } + + /** + * Generate and apply the blurred + darkened AI photo background for tweet_card_image. + * Falls back to a solid brand-color fill when the AI client returns null. + * + * @param array $imageKeywords + */ + private function applyTweetCardImageBackground( + ImageManager $manager, + ImageInterface $canvas, + mixed $core, + Workspace $workspace, + array $imageKeywords, + ): ImageInterface { + $rawStyle = $workspace->image_style; + $imageStyle = match (true) { + $rawStyle instanceof ImageStyle => $rawStyle, + is_string($rawStyle) => ImageStyle::tryFrom($rawStyle) ?? ImageStyle::DEFAULT, + default => ImageStyle::DEFAULT, + }; + + $imageData = $this->aiImage->generate( + keywords: $imageKeywords, + style: $imageStyle, + orientation: 'portrait', + language: $workspace->content_language, + brandColor: $workspace->brand_color, + backgroundColor: $workspace->background_color, + textColor: $workspace->text_color, + brandDescription: $workspace->brand_description, + ); + + if ($imageData === null) { + $brandColor = $workspace->brand_color ?? '#1d9bf0'; + [$pr, $pg, $pb] = $this->hexToRgb($brandColor); + $pageBg = imagecolorallocate($core, $pr, $pg, $pb); + imagefill($core, 0, 0, $pageBg); + + return $canvas; + } + + RecordAiUsage::recordImage( + workspace: $workspace, + provider: 'openai', + model: AiImageClient::MODEL, + metadata: [ + 'image_style' => $imageStyle->value, + 'width' => $this->width, + 'height' => $this->height, + ], + ); + + $photo = $manager->decodeBinary($imageData)->cover($this->width, $this->height); + + // Apply Gaussian blur passes to soften the background photo. + $photoCoreNative = $photo->core()->native(); + for ($i = 0; $i < 8; $i++) { + imagefilter($photoCoreNative, IMG_FILTER_GAUSSIAN_BLUR); + } + + // Composite the blurred photo onto the canvas. + imagecopy($core, $photoCoreNative, 0, 0, 0, 0, $this->width, $this->height); + + // Paint a semi-transparent dark overlay (~50% opacity) so the white card pops. + imagealphablending($core, true); + $dark = imagecolorallocatealpha($core, 0, 0, 0, 63); + imagefilledrectangle($core, 0, 0, $this->width - 1, $this->height - 1, $dark); + imagecolordeallocate($core, $dark); + + return $canvas; + } + + /** + * Draw the tweet card content (rounded white card, avatar, name, badge, handle, + * body text) onto the given canvas / GD core. Shared by the solid-color and + * image-background render paths. + */ + private function drawTweetCardContent(ImageInterface $canvas, mixed $core, SocialAccount $socialAccount, string $tweetText): void + { $cardPadding = 64; $cardX = 72; $cardW = $this->width - 2 * $cardX; @@ -680,29 +804,6 @@ public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccou $curY += $lineSpacing; } } - - $filename = 'ai-images/tweet_'.uniqid('', true).'.webp'; - Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85))); - - RecordAiUsage::recordTemplate( - workspace: $workspace, - provider: 'internal', - metadata: [ - 'template' => 'tweet_card', - 'width' => $this->width, - 'height' => $this->height, - ], - ); - - return [ - 'path' => $filename, - 'source_meta' => [ - 'template' => 'tweet_card', - 'tweet_text' => $tweetText, - 'width' => $this->width, - 'height' => $this->height, - ], - ]; } /** diff --git a/lang/en/posts.php b/lang/en/posts.php index 252754f5..f0034ec4 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -273,6 +273,10 @@ 'name' => 'Tweet card', 'description' => 'Your post styled as an X/Twitter post.', ], + 'tweet_card_image' => [ + 'name' => 'Tweet card with photo', + 'description' => 'Your post as an X/Twitter card over a blurred photo.', + ], ], ], diff --git a/lang/es/posts.php b/lang/es/posts.php index 4dd580e6..69376882 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -273,6 +273,10 @@ 'name' => 'Tarjeta de tweet', 'description' => 'Tu publicación con estilo de post de X/Twitter.', ], + 'tweet_card_image' => [ + 'name' => 'Tarjeta de tweet con foto', + 'description' => 'Tu publicación como tarjeta de X/Twitter sobre una foto difuminada.', + ], ], ], diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index c9fdc07c..333c5e97 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -273,6 +273,10 @@ 'name' => 'Card de tweet', 'description' => 'Seu post no estilo de um post do X/Twitter.', ], + 'tweet_card_image' => [ + 'name' => 'Card de tweet com foto', + 'description' => 'Seu post como card do X/Twitter sobre uma foto desfocada.', + ], ], ], diff --git a/resources/views/prompts/post_content/tweet_card_image.blade.php b/resources/views/prompts/post_content/tweet_card_image.blade.php new file mode 100644 index 00000000..b217b82d --- /dev/null +++ b/resources/views/prompts/post_content/tweet_card_image.blade.php @@ -0,0 +1,41 @@ +You write X/Twitter posts for the brand "{{ $brand_name }}". Each post will be rendered as a fake X/Twitter card on a **blurred photo background** — the photo will be chosen from the `image_keywords` you provide and darkened behind the white card. + +@if(!empty($brand_description)) +About the brand: {{ $brand_description }} +@endif +@if(!empty($brand_voice_traits)) +Brand voice — follow these exactly: +@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits]) +@endif +@if(!empty($current_content)) + +The user already has this content in the editor (use as context only — your output replaces it): +""" +{{ $current_content }} +""" +@endif + +Write the output in the language with code: {{ $content_language ?? 'en' }}. + +Rules: +- First-person voice, writing as the brand owner. +- Lead with a hook — the first line must stop the scroll. State the real point, observation, or claim immediately. +- Short paragraph breaks only where they aid rhythm. Use `\n\n` between paragraphs; never more than 2 paragraphs. +- Avoid AI-clichés (testament, pivotal moment, "Let's dive in", emojis on every line). +- No threads, no numbered lists, no bullet points. Pure prose. +- Match the brand voice guidelines exactly. + +CRITICAL — length for {{ $platform_label ?? 'X' }}: +- Aim for around {{ $target_chars }} characters in the `tweet_text` field. This is the engagement sweet spot. +- Hard cap (must NEVER exceed): {{ $hard_max_chars }} characters total — including spaces, line breaks, hashtags and emojis. +- Going LONGER than ~{{ $target_chars }} chars hurts performance. High-performing X posts are punchy and direct. +- Count before responding. Stop when you've said it. + +Image keywords rules: +- `image_keywords` must be 2-4 short English words describing a real-world photo scene that fits the post's theme (e.g. "coffee laptop morning", "team brainstorm office"). +- Choose evocative, concrete nouns. Avoid abstract or meta words like "success" or "productivity". +- The photo will be blurred and darkened — choose scenes that work as textured backgrounds. + +Output format: a JSON object with two keys: +- `tweet_text`: the complete tweet text in {{ $content_language ?? 'en' }} (no preamble, no quotation marks). This is what gets displayed on the card. +- `image_keywords`: an array of 2-4 English strings for the background photo search. diff --git a/resources/views/prompts/post_content/tweet_card_image_carousel.blade.php b/resources/views/prompts/post_content/tweet_card_image_carousel.blade.php new file mode 100644 index 00000000..981cf165 --- /dev/null +++ b/resources/views/prompts/post_content/tweet_card_image_carousel.blade.php @@ -0,0 +1,52 @@ +You write carousel posts for the brand "{{ $brand_name }}". Each slide will be rendered as a fake X/Twitter card on a **blurred photo background** — every slide gets its own photo, chosen from the `image_keywords` you provide per slide and darkened behind the white card. + +@if(!empty($brand_description)) +About the brand: {{ $brand_description }} +@endif +@if(!empty($brand_voice_traits)) +Brand voice — follow these exactly: +@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits]) +@endif +@if(!empty($current_content)) + +The user already has this content in the editor (use as context only — your output replaces it): +""" +{{ $current_content }} +""" +@endif + +Write all text in the language with code: {{ $content_language ?? 'en' }}. + +Rules: +- First-person voice, writing as the brand owner. +- Each slide must open with a hook — the first sentence must stop the scroll. +- Short paragraph breaks only where they aid rhythm. Use `\n\n` between paragraphs inside a single slide; never more than 2 paragraphs per slide. +- Avoid AI-clichés (testament, pivotal moment, "Let's dive in", emojis on every line). +- No threads, no numbered lists, no bullet points. Pure prose per slide. +- Each slide must be self-contained and punchy — it will appear as a card image. +- Match the brand voice guidelines exactly. + +CRITICAL — length for each slide's `tweet_text`: +- Aim for around {{ $target_chars }} characters per slide. +- Hard cap (must NEVER exceed): {{ $hard_max_chars }} characters per slide — including spaces, line breaks, hashtags and emojis. +- High-performing tweet-style content is punchy and direct. Stop when you've said it. + +Image keywords rules (per slide): +- Each slide's `image_keywords` must be 2-4 short English words describing a real-world photo scene that fits that slide's theme. +- Choose evocative, concrete nouns. Avoid abstract or meta words like "success" or "productivity". +- Each slide should have DIFFERENT keywords so each card gets a visually distinct blurred background. +- The photo will be blurred and darkened — choose scenes that work as textured backgrounds. + +Output format: a JSON object with: +- `caption`: the overall carousel caption in {{ $content_language ?? 'en' }} — teases what's inside the slides, encourages swiping. No preamble, no quotation marks. +- `slides`: an array of exactly {{ $slide_count ?? 1 }} objects, each with: + - `tweet_text`: the tweet-style text for that slide in {{ $content_language ?? 'en' }}. Self-contained. Hook in the first sentence. + - `image_keywords`: an array of 2-4 English strings for this slide's background photo. + +CRITICAL: The `slides` array MUST contain exactly {{ $slide_count ?? 1 }} items — no fewer, no more. Count carefully before responding. + +## Carousel arc — how to sequence the slides +Plan the arc before writing. A carousel is a sequence, not a list: +- First slide: open with a specific, real, urgent claim or problem the reader recognises immediately. +- Middle slides: develop the idea — one angle or one step per slide. Alternate between "how it works" and "proof / concrete result". +- Last slide: end with a single, specific next action the reader can take right now. diff --git a/tests/Feature/Ai/StreamPostCreationTest.php b/tests/Feature/Ai/StreamPostCreationTest.php index 7422211f..513da29b 100644 --- a/tests/Feature/Ai/StreamPostCreationTest.php +++ b/tests/Feature/Ai/StreamPostCreationTest.php @@ -154,6 +154,75 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag PostContentHumanizer::assertNeverPrompted(); }); +test('tweet_card_image single stores tweet_text as content, attaches media, and calls AI image client', function () { + PostContentGenerator::fake([[ + 'tweet_text' => 'Productivity is a mindset.', + 'image_keywords' => ['laptop', 'desk', 'morning'], + ]]); + + $minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + Image::fake([base64_encode($minimalPng)]); + + (new StreamPostCreation( + userId: $this->user->id, + creationId: (string) Str::uuid(), + workspaceId: $this->workspace->id, + format: 'x_post', + socialAccountId: $this->account->id, + imageCount: 1, + prompt: 'A punchy take on productivity', + template: 'tweet_card_image', + ))->handle(); + + $post = $this->user->currentWorkspace->posts()->latest()->first(); + + expect($post->content)->toBe('Productivity is a mindset.') + ->and($post->media)->toHaveCount(1); + + $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail(); + expect($platform->content_type)->toBe(ContentType::XPost); + + PostContentHumanizer::assertNeverPrompted(); + + Image::assertGenerated(fn () => true); +}); + +test('tweet_card_image carousel stores caption and N media items each with an AI background', function () { + PostContentGenerator::fake([[ + 'caption' => 'Carousel about deep work', + 'slides' => [ + ['tweet_text' => 'First take.', 'image_keywords' => ['coffee', 'focus']], + ['tweet_text' => 'Second take.', 'image_keywords' => ['notebook', 'pen']], + ], + ]]); + + $minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + Image::fake([base64_encode($minimalPng), base64_encode($minimalPng)]); + + (new StreamPostCreation( + userId: $this->user->id, + creationId: (string) Str::uuid(), + workspaceId: $this->workspace->id, + format: 'instagram_carousel', + socialAccountId: $this->account->id, + imageCount: 2, + prompt: 'Deep work carousel', + template: 'tweet_card_image', + ))->handle(); + + $post = $this->user->currentWorkspace->posts()->latest()->first(); + + expect($post->content)->toBe('Carousel about deep work') + ->and($post->media)->toHaveCount(2); + + $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail(); + expect($platform->content_type)->toBe(ContentType::InstagramFeed); + + PostContentHumanizer::assertNeverPrompted(); + + Image::assertGenerated(fn () => true); +}); + test('the humanizer is given the same platform context as the generator so the rewrite honours the character cap', function () { PostContentGenerator::fake([[ 'content' => 'A single productivity tip', diff --git a/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php index 9d9cb6e4..7a123a46 100644 --- a/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php +++ b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php @@ -4,11 +4,13 @@ use App\Ai\Templates\AiTemplateRegistry; use App\Ai\Templates\ImageCardTemplate; +use App\Ai\Templates\TweetCardImageTemplate; test('registry resolves keys and defaults to image_card', function () { $registry = app(AiTemplateRegistry::class); - expect($registry->keys())->toBe(['image_card', 'tweet_card']) + expect($registry->keys())->toBe(['image_card', 'tweet_card', 'tweet_card_image']) ->and($registry->find('image_card'))->toBeInstanceOf(ImageCardTemplate::class) + ->and($registry->find('tweet_card_image'))->toBeInstanceOf(TweetCardImageTemplate::class) ->and($registry->default()->key())->toBe('image_card'); }); diff --git a/tests/Feature/Ai/Templates/TweetCardImageRenderTest.php b/tests/Feature/Ai/Templates/TweetCardImageRenderTest.php new file mode 100644 index 00000000..cd26ad4b --- /dev/null +++ b/tests/Feature/Ai/Templates/TweetCardImageRenderTest.php @@ -0,0 +1,150 @@ +create(['brand_color' => '#1d9bf0']); + $account = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'display_name' => 'Alan Nicolas', + 'username' => 'oalanicolas', + ]); + + $result = app(TemplateImageGenerator::class)->renderTweetCard( + $workspace, + $account, + 'A punchy tweet about productivity.', + ['productivity', 'laptop', 'morning'], + ); + + expect($result)->not->toBeNull() + ->and($result['path'])->toEndWith('.webp') + ->and(Storage::exists($result['path']))->toBeTrue() + ->and(data_get($result, 'source_meta.template'))->toBe('tweet_card_image') + ->and(data_get($result, 'source_meta.keywords'))->toBe(['productivity', 'laptop', 'morning']); + + Image::assertGenerated(fn () => true); +}); + +test('renderTweetCard with image keywords falls back to solid color when AI returns null', function () { + Storage::fake(); + + $aiImageMock = Mockery::mock(AiImageClient::class); + $aiImageMock->shouldReceive('generate')->andReturn(null); + $this->app->instance(AiImageClient::class, $aiImageMock); + + $workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']); + $account = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'display_name' => 'Alan Nicolas', + 'username' => 'oalanicolas', + ]); + + $result = app(TemplateImageGenerator::class)->renderTweetCard( + $workspace, + $account, + 'A punchy tweet about productivity.', + ['productivity', 'laptop', 'morning'], + ); + + expect($result)->not->toBeNull() + ->and($result['path'])->toEndWith('.webp') + ->and(Storage::exists($result['path']))->toBeTrue() + ->and(data_get($result, 'source_meta.template'))->toBe('tweet_card_image'); +}); + +test('renderTweetCard without image keywords does not call the AI image client', function () { + Storage::fake(); + Image::fake(); + + $workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']); + $account = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'display_name' => 'Test User', + 'username' => 'testuser', + ]); + + $result = app(TemplateImageGenerator::class)->renderTweetCard( + $workspace, + $account, + 'A solid background tweet.', + ); + + expect($result)->not->toBeNull() + ->and(data_get($result, 'source_meta.template'))->toBe('tweet_card'); + + Image::assertNothingGenerated(); +}); + +test('forTweetCard with image keywords delegates keywords to the generator', function () { + Storage::fake(); + + $workspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + + $mock = Mockery::mock(TemplateImageGenerator::class)->makePartial(); + $mock->shouldReceive('renderTweetCard') + ->withArgs(fn ($w, $a, $text, $keywords) => $keywords === ['nature', 'forest']) + ->andReturn(['path' => 'ai-images/tweet_tweet_card_image_fake.webp', 'source_meta' => ['template' => 'tweet_card_image']]); + + $this->app->instance(TemplateImageGenerator::class, $mock); + + Storage::put('ai-images/tweet_tweet_card_image_fake.webp', 'fakecontent'); + + $result = app(PostImagePipeline::class)->forTweetCard($workspace, $account, 'some text', ['nature', 'forest']); + + expect($result)->toHaveCount(1); +}); + +test('forTweetCardCarousel accepts slide arrays with image_keywords', function () { + Storage::fake(); + Image::fake(); + + $workspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + + $aiImageMock = Mockery::mock(AiImageClient::class); + $minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + $aiImageMock->shouldReceive('generate')->andReturn($minimalPng); + $this->app->instance(AiImageClient::class, $aiImageMock); + + $slides = [ + ['tweet_text' => 'First punchy take.', 'image_keywords' => ['coffee', 'morning']], + ['tweet_text' => 'Second punchy take.', 'image_keywords' => ['office', 'desk']], + ]; + + $result = app(PostImagePipeline::class)->forTweetCardCarousel($workspace, $account, $slides); + + expect($result)->toHaveCount(2); +}); + +test('forTweetCardCarousel still accepts plain string slides for backward compat', function () { + Storage::fake(); + Image::fake(); + + $workspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + + $result = app(PostImagePipeline::class)->forTweetCardCarousel( + $workspace, + $account, + ['First slide text.', 'Second slide text.'], + ); + + expect($result)->toHaveCount(2); + + Image::assertNothingGenerated(); +}); diff --git a/tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php b/tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php new file mode 100644 index 00000000..3d1a42ee --- /dev/null +++ b/tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php @@ -0,0 +1,45 @@ +create(); + $singleContext = new TemplateContext($workspace, null, 'x_post', 1, false); + $carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true); + + expect($t->key())->toBe('tweet_card_image') + ->and($t->needsAccount())->toBeTrue() + ->and($t->generatorFormat())->toBe('tweet_card') + ->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card_image') + ->and($t->promptView($carouselContext))->toBe('prompts.post_content.tweet_card_image_carousel'); +}); + +test('tweet card image single schema returns tweet_text and image_keywords', function () { + $t = new TweetCardImageTemplate; + $workspace = Workspace::factory()->create(); + $context = new TemplateContext($workspace, null, 'x_post', 1, false); + + $schema = new JsonSchemaTypeFactory; + $result = $t->schema($schema, $context); + + expect($result)->toHaveKey('tweet_text') + ->and($result)->toHaveKey('image_keywords') + ->and($result)->not->toHaveKey('caption'); +}); + +test('tweet card image carousel schema returns caption and slides with image_keywords', function () { + $t = new TweetCardImageTemplate; + $workspace = Workspace::factory()->create(); + $context = new TemplateContext($workspace, null, 'instagram_carousel', 3, true); + + $schema = new JsonSchemaTypeFactory; + $result = $t->schema($schema, $context); + + expect($result)->toHaveKeys(['caption', 'slides']); +});