feat(tweet-card): solid-background tweet card image layout

This commit is contained in:
Paulo Castellano 2026-06-17 17:32:10 -03:00
parent 8dfdb2427a
commit 0d4f3b90d7
3 changed files with 226 additions and 0 deletions

View file

@ -80,6 +80,23 @@ public function forCarousel(Workspace $workspace, SocialAccount $account, array
return $media;
}
/**
* 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.
*
* @return array<int, array<string, mixed>>
*/
public function forTweetCard(Workspace $workspace, SocialAccount $account, string $tweetText): array
{
$rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText);
if (! $rendered) {
return [];
}
return [$this->buildAiMediaItem($workspace, $rendered)];
}
/**
* Resolve the AI image dimensions for the given content type, falling back
* to the generator defaults (4:5 portrait) when no content type is known.

View file

@ -548,6 +548,186 @@ private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hex
imagecolordeallocate($core, $color);
}
/**
* 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.
*
* @return array{path: string, source_meta: array<string, mixed>}|null
*/
public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccount, string $tweetText): ?array
{
$this->width = self::DEFAULT_WIDTH;
$this->height = self::DEFAULT_HEIGHT;
$manager = new ImageManager(Driver::class);
$brandColor = $workspace->brand_color ?? '#1d9bf0';
[$pr, $pg, $pb] = $this->hexToRgb($brandColor);
$canvas = $manager->createImage($this->width, $this->height);
$core = $canvas->core()->native();
imagealphablending($core, true);
imagesavealpha($core, false);
$pageBg = imagecolorallocate($core, $pr, $pg, $pb);
imagefill($core, 0, 0, $pageBg);
$cardPadding = 64;
$cardX = 72;
$cardW = $this->width - 2 * $cardX;
$cardRadius = 24;
$fontBold = $this->fontPath('Inter-Bold.ttf');
$fontMedium = $this->fontPath('Inter-Medium.ttf');
$fontLight = $this->fontPath('Inter-Light.ttf');
$avatarSize = 56;
$headerH = $avatarSize + 2 * $cardPadding;
$nameSize = 28;
$handleSize = 22;
$bodySize = 30;
$bodyLineHeight = 1.55;
$paragraphGap = (int) round($bodySize * $bodyLineHeight * 0.6);
$textMaxWidth = $cardW - 2 * $cardPadding - $avatarSize - 24;
$paragraphs = array_filter(array_map('trim', explode("\n\n", $tweetText)), fn ($p) => $p !== '');
$allBodyLines = [];
foreach ($paragraphs as $i => $para) {
$lines = ($fontMedium ? $this->wrapText($para, $fontMedium, $bodySize, $cardW - 2 * $cardPadding) : [str_replace("\n", ' ', $para)]);
if ($i > 0) {
$allBodyLines[] = '';
}
foreach ($lines as $line) {
$allBodyLines[] = $line;
}
}
$bodyBlockH = 0;
$lineSpacing = (int) round($bodySize * $bodyLineHeight);
foreach ($allBodyLines as $line) {
$bodyBlockH += ($line === '') ? $paragraphGap : $lineSpacing;
}
$cardContentH = $headerH + 24 + $bodyBlockH + $cardPadding;
$cardH = max(400, $cardContentH);
$cardY = (int) (($this->height - $cardH) / 2);
$this->drawRoundedRect($core, $cardX, $cardY, $cardX + $cardW, $cardY + $cardH, $cardRadius, '#ffffff');
$avatarX = $cardX + $cardPadding;
$avatarY = $cardY + $cardPadding;
$avatarBinary = $this->fetchAvatarBinary($socialAccount);
if ($avatarBinary !== null) {
$this->drawCircularAvatar($canvas, $avatarBinary, $avatarX, $avatarY, $avatarSize);
}
$nameX = $avatarX + $avatarSize + 16;
$nameY = $avatarY + 4;
if ($fontBold) {
$canvas->text($socialAccount->display_name ?? '', $nameX, $nameY, function (FontFactory $font) use ($fontBold, $nameSize) {
$font->filename($fontBold);
$font->size($nameSize);
$font->color('#0f1419');
$font->align('left', 'top');
});
}
$verifiedPath = public_path('images/ai-templates/verified.png');
if ($fontBold && file_exists($verifiedPath)) {
$displayNameText = $socialAccount->display_name ?? '';
$nameBox = $fontBold ? imagettfbbox($nameSize, 0, $fontBold, $displayNameText) : [0, 0, 0, 0, 0, 0, 0, 0];
$nameWidth = abs($nameBox[2] - $nameBox[0]);
$badgeSize = (int) round($nameSize * 1.1);
$badgeX = $nameX + $nameWidth + 6;
$badgeY = $nameY + (int) round(($nameSize - $badgeSize) / 2);
$verifiedSrc = @imagecreatefrompng($verifiedPath);
if ($verifiedSrc) {
$verifiedResized = imagecreatetruecolor($badgeSize, $badgeSize);
imagealphablending($verifiedResized, false);
imagesavealpha($verifiedResized, true);
$transparent = imagecolorallocatealpha($verifiedResized, 0, 0, 0, 127);
imagefill($verifiedResized, 0, 0, $transparent);
imagecopyresampled($verifiedResized, $verifiedSrc, 0, 0, 0, 0, $badgeSize, $badgeSize, imagesx($verifiedSrc), imagesy($verifiedSrc));
imagealphablending($core, true);
imagecopy($core, $verifiedResized, $badgeX, $badgeY, 0, 0, $badgeSize, $badgeSize);
imagedestroy($verifiedSrc);
imagedestroy($verifiedResized);
}
}
$handleY = $nameY + (int) round($nameSize * 1.35);
if ($fontLight) {
$canvas->text('@'.($socialAccount->username ?? ''), $nameX, $handleY, function (FontFactory $font) use ($fontLight, $handleSize) {
$font->filename($fontLight);
$font->size($handleSize);
$font->color('#536471');
$font->align('left', 'top');
});
}
$bodyStartY = $cardY + $headerH + 8;
$bodyX = $cardX + $cardPadding;
if ($fontMedium) {
$ascent = (int) round($bodySize * 0.82);
$curY = $bodyStartY + $ascent;
foreach ($allBodyLines as $line) {
if ($line === '') {
$curY += $paragraphGap;
continue;
}
$color = $this->allocateColor($core, '#0f1419');
imagettftext($core, $bodySize, 0, $bodyX, $curY, $color, $fontMedium, $line);
$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,
],
];
}
/**
* Draw a filled rounded rectangle on the GD resource.
*/
private function drawRoundedRect($core, int $x1, int $y1, int $x2, int $y2, int $radius, string $hexColor): void
{
[$r, $g, $b] = $this->hexToRgb($hexColor);
$color = imagecolorallocate($core, $r, $g, $b);
imagefilledrectangle($core, $x1 + $radius, $y1, $x2 - $radius, $y2, $color);
imagefilledrectangle($core, $x1, $y1 + $radius, $x2, $y2 - $radius, $color);
imagefilledellipse($core, $x1 + $radius, $y1 + $radius, $radius * 2, $radius * 2, $color);
imagefilledellipse($core, $x2 - $radius, $y1 + $radius, $radius * 2, $radius * 2, $color);
imagefilledellipse($core, $x1 + $radius, $y2 - $radius, $radius * 2, $radius * 2, $color);
imagefilledellipse($core, $x2 - $radius, $y2 - $radius, $radius * 2, $radius * 2, $color);
}
private function fontPath(string $filename): ?string
{
$path = base_path('resources/fonts/'.$filename);

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\TemplateImageGenerator;
use Illuminate\Support\Facades\Storage;
test('renderTweetCard produces a stored webp without calling the AI image client', function () {
Storage::fake();
$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í cai a ficha.\n\nIsso tem nome: você está sendo subsidiado.",
);
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');
});