feat(ai-templates): tweet-card-with-photo style (blurred dark image background)

This commit is contained in:
Paulo Castellano 2026-06-17 19:43:21 -03:00
parent b10cef7a9b
commit d8291cb155
15 changed files with 689 additions and 38 deletions

View file

@ -12,6 +12,7 @@ class AiTemplateRegistry
private const TEMPLATES = [
ImageCardTemplate::class,
TweetCardTemplate::class,
TweetCardImageTemplate::class,
];
/** @var array<int, AiContentTemplate>|null */

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Ai\Templates;
use App\Enums\PostPlatform\ContentType;
use App\Services\Image\PostImagePipeline;
use Illuminate\Contracts\JsonSchema\JsonSchema;
class TweetCardImageTemplate implements AiContentTemplate
{
public function key(): string
{
return 'tweet_card_image';
}
public function name(): string
{
return 'posts.ai.templates.tweet_card_image.name';
}
public function description(): string
{
return 'posts.ai.templates.tweet_card_image.description';
}
public function previewAsset(): string
{
return '/images/ai-templates/tweet-card-image.png';
}
public function needsAccount(): bool
{
return true;
}
/** @return array<int, string> */
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<string, mixed>
*/
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<string, mixed> $structured
*/
public function assemble(array $structured, TemplateContext $context): GeneratedPost
{
if ($context->isCarousel) {
return $this->assembleCarousel($structured, $context);
}
return $this->assembleSingle($structured, $context);
}
/**
* @param array<string, mixed> $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<string, mixed> $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,
);
}
}

View file

@ -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,
);
}

View file

@ -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;
}

View file

@ -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<int, string>|null $imageKeywords
* @return array<int, array<string, mixed>>
*/
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<int, string> $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<int, string|array<string, mixed>> $slides
* @return array<int, array<string, mixed>>
*/
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);

View file

@ -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<int, string>|null $imageKeywords
* @return array{path: string, source_meta: array<string, mixed>}|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<int, string> $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,
],
];
}
/**

View file

@ -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.',
],
],
],

View file

@ -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.',
],
],
],

View file

@ -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.',
],
],
],

View file

@ -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.

View file

@ -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.

View file

@ -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',

View file

@ -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');
});

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Ai\AiImageClient;
use App\Services\Image\PostImagePipeline;
use App\Services\Image\TemplateImageGenerator;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Image;
test('renderTweetCard with image keywords generates an AI image background', function () {
Storage::fake();
$minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
Image::fake([base64_encode($minimalPng)]);
$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')
->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();
});

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Ai\Templates\TemplateContext;
use App\Ai\Templates\TweetCardImageTemplate;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
test('tweet card image template identity', function () {
$t = new TweetCardImageTemplate;
$workspace = Workspace::factory()->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']);
});