* Fix OpenRouter support by using laravel/ai default provider config. PR #216 patched Lab::OpenRouter into every agent match, but laravel/ai already resolves config('ai.default') — including openrouter — when agents omit provider(). Those matches also forced unknown providers to Gemini and BrandAnalyzerRunner checked a non-existent services.openrouter key. Remove the duplicated provider() overrides, gate availability on ai.providers.*.key, and document OPENROUTER_API_KEY. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * chore(deps): bump laravel/ai to v0.10.3 for native OpenRouter support v0.5.1's OpenRouter driver only covered text/embeddings via the legacy Prism gateway. v0.10.3 ships a native OpenRouter gateway with image, audio (TTS/STT), and web search support, and drops prism-php/prism as a dependency. ai-sdk-development skill docs refreshed via boost:update to match the installed version. * fix: honor AI_IMAGE_PROVIDER instead of hardcoding OpenAI's gpt-image-2 AiImageClient always passed model: 'gpt-image-2' to Image::of()->generate(), so AI_IMAGE_PROVIDER silently did nothing for any provider other than OpenAI — gemini/xai/openrouter would fail against an OpenAI-only model id and quietly fall back to a stock photo. Usage recording was hardcoded to provider 'openai' too, so credits were billed against the wrong model whenever a different provider actually ran. Drop the hardcoded model so generation falls through to the SDK's own config('ai.default_for_images') + per-provider default model, and read the actual provider/model back off the response's meta for usage recording and source_meta instead of assuming OpenAI. * refactor: extract AiImageClient into single-purpose steps, fix uncaught exception generate() built the prompt, called the SDK, and unpacked the response all in one block, with bytes extraction happening after the try/catch — so a response with an empty images collection threw an uncaught RuntimeException from ImageResponse::firstImage() instead of returning null as documented. Split into cleanKeywords(), buildPrompt(), resolveBrandContext(), and toResult(), and moved response unpacking inside the try block so any malformed response is treated as a failure like everything else. Added a regression test with an empty-images fake response. * fix: drop hardcoded default_text_model, resolve model per provider default_text_model was the only per-modality model override in ai.php — image, audio, transcription, embeddings, and reranking all just pick a provider and let it use its own default model. Text had a config-pinned model on top, forced into every agent's model() and into every usage log's model field regardless of which provider actually ran. Switching AI_TEXT_PROVIDER (e.g. to openrouter) kept sending OpenAI's model id to whichever provider ended up handling the request. Removed model() from all six agents so laravel/ai resolves the model from the active provider's own default (OpenAI's default is already 'gpt-5.4', so no behavior change there). Usage-recording call sites now read the actual provider/model back off the response's meta instead of assuming config('ai.default')/default_text_model. StreamPostContent needed the then() callback since broadcast()'s StreamableAgentResponse doesn't expose meta directly. * refactor: drop AiConfiguration wrapper, use data_get() for array reads AiConfiguration was a one-line static helper used by only two call sites, with no laravel/ai equivalent to lean on (confirmed AiManager and the Provider base class expose no isConfigured()/hasKey() check — the package's model is try-then-catch, not pre-flight checks). Inlined the filled(config(...)) check directly into HandleInertiaRequests and BrandAnalyzerRunner instead of keeping a class around one line of logic. Also swapped direct array-key reads for data_get() per project convention across every file touched by the recent AI provider/model fixes (agents' budget arrays, RunGenerateNode/StreamPostCreation's humanizer merge, RegeneratePostMediaImage's baseContext/copy/rendered access). Write/assignment sites (`$x['key'] = ...`) are left as-is — data_get() only reads. * feat: enhance AI configuration with new providers and options Added strict types declaration and updated Azure OpenAI API version. Introduced new 'bedrock' provider configuration with AWS credentials and role assumptions. Enhanced existing providers with additional options, including image deployment for Azure and OpenAI, and updated URLs for Gemini and Ollama. Added support for an 'openai-compatible' driver to broaden integration capabilities. * fix: remove trailing newline in AI configuration file * feat: add support for OpenRouter and ElevenLabs API keys in configuration Updated the production Docker Compose file and example environment file to include commented-out entries for OPENROUTER_API_KEY and ELEVENLABS_API_KEY. This enhances the configuration options for AI providers, allowing for easier integration of additional services. * feat: allow per-provider model overrides for every AI modality Adds a `models` array to each provider block, wired to env vars, so self-hosted operators can pin a specific text/image/audio/transcription/ embeddings/reranking model instead of relying on the package's built-in default for that provider. Only added for the modalities each provider actually implements (verified against laravel/ai's Provider classes). Azure is left untouched — it resolves models via deployment names (AZURE_OPENAI_DEPLOYMENT etc.), not raw model strings, which was already wired before this change. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
842 lines
31 KiB
PHP
842 lines
31 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Image;
|
|
|
|
use App\Enums\Workspace\ImageStyle;
|
|
use App\Models\SocialAccount;
|
|
use App\Models\Workspace;
|
|
use App\Services\Ai\AiImageClient;
|
|
use App\Services\Ai\RecordAiUsage;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Intervention\Image\Drivers\Gd\Driver;
|
|
use Intervention\Image\Encoders\WebpEncoder;
|
|
use Intervention\Image\ImageManager;
|
|
use Intervention\Image\Interfaces\ImageInterface;
|
|
use Intervention\Image\Typography\FontFactory;
|
|
|
|
class TemplateImageGenerator
|
|
{
|
|
public const DEFAULT_WIDTH = 1080;
|
|
|
|
public const DEFAULT_HEIGHT = 1350;
|
|
|
|
/** Active canvas width. Set per render call so templates can scale. */
|
|
private int $width = self::DEFAULT_WIDTH;
|
|
|
|
/** Active canvas height. Set per render call so templates can scale. */
|
|
private int $height = self::DEFAULT_HEIGHT;
|
|
|
|
public function __construct(
|
|
private BrandColorMapper $colorMapper,
|
|
private AiImageClient $aiImage,
|
|
) {}
|
|
|
|
/**
|
|
* Render a slide and return the storage path plus the source meta needed
|
|
* to regenerate it later. Returns null on failure (e.g. the AI client
|
|
* could not produce a usable image).
|
|
*
|
|
* @param array<int, string> $imageKeywords
|
|
* @return array{path: string, source_meta: array<string, mixed>}|null
|
|
*/
|
|
public function render(
|
|
Workspace $workspace,
|
|
SocialAccount $socialAccount,
|
|
string $title,
|
|
string $body,
|
|
array $imageKeywords,
|
|
int $width = self::DEFAULT_WIDTH,
|
|
int $height = self::DEFAULT_HEIGHT,
|
|
?string $backgroundPath = null,
|
|
bool $applyBrandVisuals = true,
|
|
): ?array {
|
|
$this->width = $width;
|
|
$this->height = $height;
|
|
|
|
$orientation = $this->orientationForCanvas();
|
|
$rawStyle = $workspace->image_style;
|
|
$imageStyle = match (true) {
|
|
$rawStyle instanceof ImageStyle => $rawStyle,
|
|
is_string($rawStyle) => ImageStyle::tryFrom($rawStyle) ?? ImageStyle::DEFAULT,
|
|
default => ImageStyle::DEFAULT,
|
|
};
|
|
$language = $workspace->content_language;
|
|
|
|
// When brand visuals are off (e.g. faithful curation), the AI background
|
|
// is generated without brand colours or identity — neutral imagery driven
|
|
// only by the post's own keywords.
|
|
$brandColor = $applyBrandVisuals ? $workspace->brand_color : null;
|
|
$backgroundColor = $applyBrandVisuals ? $workspace->background_color : null;
|
|
$textColor = $applyBrandVisuals ? $workspace->text_color : null;
|
|
$brandDescription = $applyBrandVisuals ? $workspace->brand_description : null;
|
|
|
|
$generatedNewBackground = false;
|
|
$resolvedBackgroundPath = null;
|
|
$imageData = null;
|
|
$imageProvider = null;
|
|
$imageModel = null;
|
|
|
|
if (is_string($backgroundPath) && trim($backgroundPath) !== '' && Storage::exists($backgroundPath)) {
|
|
$resolvedBackgroundPath = $backgroundPath;
|
|
$imageData = Storage::get($backgroundPath);
|
|
}
|
|
|
|
if (! is_string($imageData) || $imageData === '') {
|
|
$generated = $this->aiImage->generate(
|
|
keywords: $imageKeywords,
|
|
style: $imageStyle,
|
|
orientation: $orientation,
|
|
language: $language,
|
|
brandColor: $brandColor,
|
|
backgroundColor: $backgroundColor,
|
|
textColor: $textColor,
|
|
brandDescription: $brandDescription,
|
|
);
|
|
|
|
if ($generated === null) {
|
|
return null;
|
|
}
|
|
|
|
$imageData = data_get($generated, 'bytes');
|
|
$imageProvider = data_get($generated, 'provider');
|
|
$imageModel = data_get($generated, 'model');
|
|
|
|
$generatedNewBackground = true;
|
|
$resolvedBackgroundPath = $this->storeBackgroundImage($imageData);
|
|
}
|
|
|
|
$manager = new ImageManager(Driver::class);
|
|
|
|
$canvas = $this->renderTemplateA($manager, $imageData, $title, $body);
|
|
$canvas = $this->renderFooter($canvas, $socialAccount);
|
|
|
|
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
|
|
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
|
|
|
|
if ($generatedNewBackground) {
|
|
RecordAiUsage::recordImage(
|
|
workspace: $workspace,
|
|
provider: $imageProvider,
|
|
model: $imageModel,
|
|
metadata: [
|
|
'image_style' => $imageStyle->value,
|
|
'width' => $this->width,
|
|
'height' => $this->height,
|
|
],
|
|
);
|
|
}
|
|
|
|
RecordAiUsage::recordTemplate(
|
|
workspace: $workspace,
|
|
provider: 'internal',
|
|
metadata: [
|
|
'width' => $this->width,
|
|
'height' => $this->height,
|
|
],
|
|
);
|
|
|
|
return [
|
|
'path' => $filename,
|
|
'source_meta' => [
|
|
'keywords' => array_values($imageKeywords),
|
|
'style' => $imageStyle->value,
|
|
'language' => $language,
|
|
'model' => $imageModel,
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'width' => $this->width,
|
|
'height' => $this->height,
|
|
'brand_color' => $brandColor,
|
|
'background_color' => $backgroundColor,
|
|
'text_color' => $textColor,
|
|
'background_path' => $resolvedBackgroundPath,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function storeBackgroundImage(string $imageData): string
|
|
{
|
|
$manager = new ImageManager(Driver::class);
|
|
$background = $manager->decodeBinary($imageData)->cover($this->width, $this->height);
|
|
$backgroundPath = 'ai-images/'.uniqid('bg_', true).'.webp';
|
|
|
|
Storage::put($backgroundPath, (string) $background->encode(new WebpEncoder(quality: 85)));
|
|
|
|
return $backgroundPath;
|
|
}
|
|
|
|
/**
|
|
* Pick the closest aspect ratio for the active canvas so the AI image
|
|
* generator returns a photo that doesn't need heavy cropping.
|
|
*/
|
|
private function orientationForCanvas(): string
|
|
{
|
|
$ratio = $this->width / $this->height;
|
|
if ($ratio > 1.1) {
|
|
return 'landscape';
|
|
}
|
|
if ($ratio < 0.9) {
|
|
return 'portrait';
|
|
}
|
|
|
|
return 'squarish';
|
|
}
|
|
|
|
private function renderTemplateA(ImageManager $manager, string $imageData, string $title, string $body): ImageInterface
|
|
{
|
|
// Cover-fit Unsplash image to active canvas size.
|
|
$image = $manager->decodeBinary($imageData)->cover($this->width, $this->height);
|
|
|
|
// Smooth gradient mask: covers full image height, peaks at 0.9 alpha (linear).
|
|
$this->applyBottomGradient($image, 1.0, 0.9, 1.0);
|
|
|
|
$fontBold = $this->fontPath('Inter-Bold.ttf');
|
|
$fontMedium = $this->fontPath('Inter-Medium.ttf');
|
|
|
|
// Layout (bottom-up): footer area → body → title. All text rendered via raw GD
|
|
// for pixel-precise positioning. Same wrap+measure helper used for layout math.
|
|
$titleSize = 56;
|
|
$bodySize = 28;
|
|
$titleLineHeight = 1.25;
|
|
$bodyLineHeight = 1.55;
|
|
$footerReserved = 150;
|
|
$bodyMargin = 16;
|
|
$titleMargin = 36;
|
|
$padding = 60;
|
|
$maxWidth = $this->width - 2 * $padding;
|
|
|
|
$bodyLines = $fontMedium ? $this->wrapText($body, $fontMedium, $bodySize, $maxWidth) : [];
|
|
$titleLines = $fontBold ? $this->wrapText($title, $fontBold, $titleSize, $maxWidth) : [];
|
|
|
|
$bodyHeight = $this->measureBlockHeight($bodyLines, $bodySize, $bodyLineHeight);
|
|
$titleHeight = $this->measureBlockHeight($titleLines, $titleSize, $titleLineHeight);
|
|
|
|
$bodyTopY = $this->height - $footerReserved - $bodyMargin - $bodyHeight;
|
|
$titleTopY = $bodyTopY - $titleMargin - $titleHeight;
|
|
|
|
$core = $image->core()->native();
|
|
|
|
if ($fontBold && $titleLines) {
|
|
$this->renderTextLines($core, $titleLines, $fontBold, $titleSize, $titleLineHeight, '#ffffff', $padding, $titleTopY);
|
|
}
|
|
if ($fontMedium && $bodyLines) {
|
|
$this->renderTextLines($core, $bodyLines, $fontMedium, $bodySize, $bodyLineHeight, '#f5f5f5', $padding, $bodyTopY);
|
|
}
|
|
|
|
return $image;
|
|
}
|
|
|
|
/**
|
|
* Wrap text into lines that fit within $maxWidth using the given font.
|
|
* Respects explicit \n line breaks. Returns an array of line strings.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function wrapText(string $text, string $fontPath, int $fontSize, int $maxWidth): array
|
|
{
|
|
$lines = [];
|
|
foreach (explode("\n", $text) as $paragraph) {
|
|
$words = preg_split('/\s+/', trim($paragraph)) ?: [];
|
|
if (empty($words)) {
|
|
$lines[] = '';
|
|
|
|
continue;
|
|
}
|
|
$current = '';
|
|
foreach ($words as $word) {
|
|
$candidate = $current === '' ? $word : $current.' '.$word;
|
|
$box = imagettfbbox($fontSize, 0, $fontPath, $candidate);
|
|
$width = abs($box[2] - $box[0]);
|
|
if ($width > $maxWidth && $current !== '') {
|
|
$lines[] = $current;
|
|
$current = $word;
|
|
} else {
|
|
$current = $candidate;
|
|
}
|
|
}
|
|
if ($current !== '') {
|
|
$lines[] = $current;
|
|
}
|
|
}
|
|
|
|
return $lines;
|
|
}
|
|
|
|
/**
|
|
* Compute total visual height of a wrapped text block. We rely on the actual
|
|
* font ascent (from imagettfbbox of an x-height-tall sample) plus
|
|
* (n-1) * line_spacing for a tight fit.
|
|
*
|
|
* @param array<int, string> $lines
|
|
*/
|
|
private function measureBlockHeight(array $lines, int $fontSize, float $lineHeight): int
|
|
{
|
|
if (empty($lines)) {
|
|
return 0;
|
|
}
|
|
$lineSpacing = (int) round($fontSize * $lineHeight);
|
|
|
|
return $lineSpacing * count($lines);
|
|
}
|
|
|
|
/**
|
|
* Render an array of lines line-by-line via imagettftext at explicit y positions.
|
|
* $topY is where the first line's bounding box starts (top of first glyph row).
|
|
*
|
|
* @param array<int, string> $lines
|
|
*/
|
|
private function renderTextLines($core, array $lines, string $fontPath, int $fontSize, float $lineHeight, string $hexColor, int $x, int $topY): void
|
|
{
|
|
$color = $this->allocateColor($core, $hexColor);
|
|
$lineSpacing = (int) round($fontSize * $lineHeight);
|
|
// imagettftext positions the text at the BASELINE. The font's ascent for our
|
|
// body line height is roughly fontSize * 0.78 — we use that as the offset
|
|
// from $topY to the first baseline.
|
|
$ascent = (int) round($fontSize * 0.82);
|
|
$baselineY = $topY + $ascent;
|
|
|
|
foreach ($lines as $line) {
|
|
imagettftext($core, $fontSize, 0, $x, $baselineY, $color, $fontPath, $line);
|
|
$baselineY += $lineSpacing;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Allocate a GD color from a hex string (#rrggbb).
|
|
*
|
|
* @return int Color identifier suitable for GD draw functions.
|
|
*/
|
|
private function allocateColor($core, string $hex): int
|
|
{
|
|
[$r, $g, $b] = $this->hexToRgb($hex);
|
|
|
|
$color = imagecolorallocate($core, $r, $g, $b);
|
|
|
|
return $color === false ? imagecolorallocate($core, 255, 255, 255) : $color;
|
|
}
|
|
|
|
/**
|
|
* @return array{0: int, 1: int, 2: int}
|
|
*/
|
|
private function hexToRgb(string $hex): array
|
|
{
|
|
$hex = ltrim($hex, '#');
|
|
if (strlen($hex) === 3) {
|
|
$hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
|
|
}
|
|
|
|
return [
|
|
hexdec(substr($hex, 0, 2)),
|
|
hexdec(substr($hex, 2, 2)),
|
|
hexdec(substr($hex, 4, 2)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Paint a smooth black-to-transparent gradient over the bottom of the image
|
|
* directly on the GD resource. Avoids visible bands from stacked rectangles.
|
|
*
|
|
* @param float $easingPower 1.0 = linear, 2.0 = quadratic (slow start),
|
|
* <1.0 = ramps up faster at the top.
|
|
*/
|
|
private function applyBottomGradient(ImageInterface $image, float $heightFraction, float $maxAlpha, float $easingPower = 2.0): void
|
|
{
|
|
$maskHeight = (int) ($this->height * $heightFraction);
|
|
$maskStart = $this->height - $maskHeight;
|
|
|
|
$core = $image->core()->native(); // GD resource
|
|
imagealphablending($core, true);
|
|
|
|
for ($y = 0; $y < $maskHeight; $y++) {
|
|
$progress = $y / max(1, $maskHeight - 1);
|
|
$alphaFraction = (float) (pow($progress, $easingPower) * $maxAlpha);
|
|
// GD alpha is inverted: 0=opaque, 127=transparent.
|
|
$gdAlpha = (int) round(127 * (1 - $alphaFraction));
|
|
$color = imagecolorallocatealpha($core, 0, 0, 0, $gdAlpha);
|
|
imagefilledrectangle($core, 0, $maskStart + $y, $this->width - 1, $maskStart + $y, $color);
|
|
imagecolordeallocate($core, $color);
|
|
}
|
|
}
|
|
|
|
private function renderFooter(ImageInterface $canvas, SocialAccount $socialAccount): ImageInterface
|
|
{
|
|
// Footer uses Inter Light (300) in slate-grey — always legible on top
|
|
// of the bottom dark gradient applied by Template A.
|
|
$footerColor = '#9ca3af';
|
|
|
|
$username = $socialAccount->username ?? '';
|
|
$displayName = $socialAccount->display_label;
|
|
|
|
// Footer row anchored from the bottom: avatar + handle + displayName
|
|
// share the same vertical center so they line up cleanly.
|
|
$avatarSize = 48;
|
|
$avatarX = 60;
|
|
$rowCenterY = $this->height - 100; // 100px from the bottom edge
|
|
|
|
$avatarY = $rowCenterY - (int) ($avatarSize / 2);
|
|
|
|
$textX = $avatarX + $avatarSize + 16;
|
|
// intervention/image's `align('left', 'top')` positions text at its EM-box
|
|
// top. Inter's visual glyph midpoint sits roughly at top + size * 0.42, so
|
|
// we shift textY up by that amount to land its center on rowCenterY.
|
|
$textY = $rowCenterY - (int) round(24 * 0.42);
|
|
|
|
// Avatar (circular)
|
|
$avatarBinary = $this->fetchAvatarBinary($socialAccount);
|
|
if ($avatarBinary !== null) {
|
|
$this->drawCircularAvatar($canvas, $avatarBinary, $avatarX, $avatarY, $avatarSize);
|
|
}
|
|
|
|
$fontLight = $this->fontPath('Inter-Light.ttf');
|
|
if (! $fontLight || ! file_exists($fontLight)) {
|
|
return $canvas;
|
|
}
|
|
|
|
if ($username) {
|
|
$canvas->text('@'.$username, $textX, $textY, function (FontFactory $font) use ($fontLight, $footerColor) {
|
|
$font->filename($fontLight);
|
|
$font->size(24);
|
|
$font->color($footerColor);
|
|
$font->align('left', 'top');
|
|
});
|
|
}
|
|
|
|
if ($displayName) {
|
|
$canvas->text($displayName, $this->width - 60, $textY, function (FontFactory $font) use ($fontLight, $footerColor) {
|
|
$font->filename($fontLight);
|
|
$font->size(24);
|
|
$font->color($footerColor);
|
|
$font->align('right', 'top');
|
|
});
|
|
}
|
|
|
|
return $canvas;
|
|
}
|
|
|
|
/**
|
|
* Fetches the avatar binary via Storage (works with local, R2, S3 — whatever
|
|
* `filesystems.default` is). Returns null when there's no avatar or the read fails.
|
|
*/
|
|
private function fetchAvatarBinary(SocialAccount $socialAccount): ?string
|
|
{
|
|
$rawPath = $socialAccount->getRawOriginal('avatar_url');
|
|
if (! $rawPath) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
if (! Storage::exists($rawPath)) {
|
|
return null;
|
|
}
|
|
$contents = Storage::get($rawPath);
|
|
|
|
return $contents ?: null;
|
|
} catch (\Throwable $e) {
|
|
Log::warning('TemplateImageGenerator: avatar fetch failed', [
|
|
'account' => $socialAccount->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draws a circular avatar by overlaying a per-pixel alpha-masked GD truecolor
|
|
* onto the canvas. Pixels outside the inscribed circle become fully transparent.
|
|
*/
|
|
private function drawCircularAvatar(ImageInterface $canvas, string $avatarBinary, int $x, int $y, int $size): void
|
|
{
|
|
$core = $canvas->core()->native(); // GD resource
|
|
|
|
$src = @imagecreatefromstring($avatarBinary);
|
|
if (! $src) {
|
|
return;
|
|
}
|
|
|
|
// Square-crop center, then resize to $size x $size.
|
|
$sw = imagesx($src);
|
|
$sh = imagesy($src);
|
|
$crop = min($sw, $sh);
|
|
$cx = (int) (($sw - $crop) / 2);
|
|
$cy = (int) (($sh - $crop) / 2);
|
|
$resized = imagecreatetruecolor($size, $size);
|
|
imagealphablending($resized, false);
|
|
imagesavealpha($resized, true);
|
|
$transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
|
|
imagefill($resized, 0, 0, $transparent);
|
|
imagecopyresampled($resized, $src, 0, 0, $cx, $cy, $size, $size, $crop, $crop);
|
|
imagedestroy($src);
|
|
|
|
// Apply circular alpha mask.
|
|
$cx = $size / 2;
|
|
$cy = $size / 2;
|
|
$r = $size / 2;
|
|
for ($py = 0; $py < $size; $py++) {
|
|
for ($px = 0; $px < $size; $px++) {
|
|
$dx = $px + 0.5 - $cx;
|
|
$dy = $py + 0.5 - $cy;
|
|
$dist = sqrt($dx * $dx + $dy * $dy);
|
|
if ($dist > $r) {
|
|
imagesetpixel($resized, $px, $py, $transparent);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Composite onto the main canvas (alpha-aware).
|
|
imagealphablending($core, true);
|
|
imagecopy($core, $resized, $x, $y, 0, 0, $size, $size);
|
|
imagedestroy($resized);
|
|
}
|
|
|
|
/**
|
|
* Render text at a precise (x, baselineY) position with optional letter spacing.
|
|
* Letter spacing > 0 renders each character individually with extra pixels between glyphs.
|
|
*/
|
|
private function drawTextAt($core, string $text, string $fontPath, int $fontSize, string $hexColor, int $x, int $baselineY, int $letterSpacing = 0): void
|
|
{
|
|
$color = $this->allocateColor($core, $hexColor);
|
|
|
|
if ($letterSpacing <= 0) {
|
|
imagettftext($core, $fontSize, 0, $x, $baselineY, $color, $fontPath, $text);
|
|
|
|
return;
|
|
}
|
|
|
|
$cursor = $x;
|
|
$chars = mb_str_split($text);
|
|
foreach ($chars as $char) {
|
|
imagettftext($core, $fontSize, 0, $cursor, $baselineY, $color, $fontPath, $char);
|
|
$bbox = imagettfbbox($fontSize, 0, $fontPath, $char);
|
|
$cursor += abs($bbox[2] - $bbox[0]) + $letterSpacing;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Measure the on-screen width of a string when rendered with extra letter spacing.
|
|
*/
|
|
private function measureLetterSpacedWidth(string $text, string $fontPath, int $fontSize, int $letterSpacing): int
|
|
{
|
|
if ($letterSpacing <= 0) {
|
|
$bbox = imagettfbbox($fontSize, 0, $fontPath, $text);
|
|
|
|
return abs($bbox[2] - $bbox[0]);
|
|
}
|
|
|
|
$width = 0;
|
|
$chars = mb_str_split($text);
|
|
$count = count($chars);
|
|
foreach ($chars as $i => $char) {
|
|
$bbox = imagettfbbox($fontSize, 0, $fontPath, $char);
|
|
$width += abs($bbox[2] - $bbox[0]);
|
|
if ($i < $count - 1) {
|
|
$width += $letterSpacing;
|
|
}
|
|
}
|
|
|
|
return $width;
|
|
}
|
|
|
|
/**
|
|
* Draw a 1px horizontal line on the GD resource with optional alpha (0..1).
|
|
*/
|
|
private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hexColor, float $alpha = 1.0): void
|
|
{
|
|
[$r, $g, $b] = $this->hexToRgb($hexColor);
|
|
// GD alpha: 0 opaque, 127 transparent.
|
|
$gdAlpha = (int) round(127 * (1 - max(0.0, min(1.0, $alpha))));
|
|
imagealphablending($core, true);
|
|
$color = imagecolorallocatealpha($core, $r, $g, $b, $gdAlpha);
|
|
imageline($core, $x1, $y, $x2, $y, $color);
|
|
imagecolordeallocate($core, $color);
|
|
}
|
|
|
|
/**
|
|
* 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 $imageKeywords = null): ?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);
|
|
|
|
$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,
|
|
};
|
|
|
|
$generated = $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 ($generated === 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: data_get($generated, 'provider'),
|
|
model: data_get($generated, 'model'),
|
|
metadata: [
|
|
'image_style' => $imageStyle->value,
|
|
'width' => $this->width,
|
|
'height' => $this->height,
|
|
],
|
|
);
|
|
|
|
$photo = $manager->decodeBinary(data_get($generated, 'bytes'))->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;
|
|
$cardRadius = 24;
|
|
|
|
$fontBold = $this->fontPath('Inter-Bold.ttf');
|
|
$fontMedium = $this->fontPath('Inter-Medium.ttf');
|
|
$fontLight = $this->fontPath('Inter-Light.ttf');
|
|
|
|
$avatarSize = 72;
|
|
$headerH = $avatarSize + 2 * $cardPadding;
|
|
$nameSize = 28;
|
|
$handleSize = 22;
|
|
$bodySize = 30;
|
|
$bodyLineHeight = 1.55;
|
|
$paragraphGap = (int) round($bodySize * $bodyLineHeight * 0.6);
|
|
|
|
$paragraphs = array_values(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;
|
|
|
|
$displayNameText = $socialAccount->display_label;
|
|
$handleText = '@'.($socialAccount->username ?? '');
|
|
$nameBox = $fontBold ? imagettfbbox($nameSize, 0, $fontBold, $displayNameText) : [0, 0, 0, 0, 0, 0, 0, 0];
|
|
$handleBox = $fontLight ? imagettfbbox($handleSize, 0, $fontLight, $handleText) : [0, 0, 0, 0, 0, 0, 0, 0];
|
|
$nameWidth = abs($nameBox[2] - $nameBox[0]);
|
|
$nameAscent = (int) round(abs($nameBox[7]));
|
|
$handleAscent = (int) round(abs($handleBox[7]));
|
|
|
|
// Vertically center the two-line (name + handle) block against the avatar.
|
|
$handleSpacing = (int) round($nameSize * 1.4);
|
|
$blockHeight = $handleSpacing + $handleAscent;
|
|
$nameY = $avatarY + (int) round(($avatarSize - $blockHeight) / 2);
|
|
$nameBaselineY = $nameY + $nameAscent;
|
|
$handleBaselineY = $nameY + $handleSpacing + $handleAscent;
|
|
|
|
if ($fontBold && $displayNameText !== '') {
|
|
$this->drawTextAt($core, $displayNameText, $fontBold, $nameSize, '#0f1419', $nameX, $nameBaselineY);
|
|
}
|
|
|
|
$verifiedPath = public_path('images/ai-templates/verified.png');
|
|
if ($fontBold && $displayNameText !== '' && file_exists($verifiedPath)) {
|
|
$badgeSize = (int) round($nameSize * 1.15);
|
|
$badgeX = $nameX + $nameWidth + 8;
|
|
$badgeY = $nameY + (int) round(($nameAscent - $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);
|
|
}
|
|
}
|
|
|
|
if ($fontLight && $socialAccount->username) {
|
|
$this->drawTextAt($core, $handleText, $fontLight, $handleSize, '#536471', $nameX, $handleBaselineY);
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
return file_exists($path) ? $path : null;
|
|
}
|
|
}
|