feat(ai-templates): tweet-card-with-photo style (blurred dark image background)
This commit is contained in:
parent
b10cef7a9b
commit
d8291cb155
15 changed files with 689 additions and 38 deletions
|
|
@ -12,6 +12,7 @@ class AiTemplateRegistry
|
|||
private const TEMPLATES = [
|
||||
ImageCardTemplate::class,
|
||||
TweetCardTemplate::class,
|
||||
TweetCardImageTemplate::class,
|
||||
];
|
||||
|
||||
/** @var array<int, AiContentTemplate>|null */
|
||||
|
|
|
|||
163
app/Ai/Templates/TweetCardImageTemplate.php
Normal file
163
app/Ai/Templates/TweetCardImageTemplate.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
150
tests/Feature/Ai/Templates/TweetCardImageRenderTest.php
Normal file
150
tests/Feature/Ai/Templates/TweetCardImageRenderTest.php
Normal 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();
|
||||
});
|
||||
45
tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php
Normal file
45
tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php
Normal 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']);
|
||||
});
|
||||
Loading…
Reference in a new issue