Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
New AiImageClient builds prompts from a Blade template seeded by the
workspace's ImageStyle enum, content language, brand color (mapped to a
human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
Template A (full-bleed photo + bottom gradient + white/grey overlay).
Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
PostCreationReady with post_id; the wizard kills its preview step and
redirects straight to the post editor on completion. Finalize endpoint
removed.
- New Workspace.image_style enum field with an 8-option visual picker
shared by /workspaces/create and /settings/workspace/brand via a single
BrandForm component (autofill is a prop). 8 sample webp thumbs ship
under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
source_meta (recipe needed to regenerate AI images later); the gallery
picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
most frequent cluster. Solves Tailwind/utility-CSS sites where no
semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
157 lines
5.1 KiB
PHP
157 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Enums\Workspace\ImageStyle;
|
|
use App\Services\Ai\AiImageClient;
|
|
use Laravel\Ai\Image;
|
|
use Laravel\Ai\Prompts\ImagePrompt;
|
|
|
|
test('generate returns null when keywords are empty', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
|
|
expect($client->generate([], ImageStyle::Cinematic))->toBeNull();
|
|
Image::assertNothingGenerated();
|
|
});
|
|
|
|
test('generate returns raw bytes when AI succeeds', function () {
|
|
$bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
|
|
Image::fake([base64_encode($bytes)]);
|
|
|
|
$client = new AiImageClient;
|
|
|
|
expect($client->generate(['kitchen', 'morning'], ImageStyle::Illustration))
|
|
->toBe($bytes);
|
|
});
|
|
|
|
test('generate uses style-specific prompt prefix', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['mountain hiker'], ImageStyle::Cinematic);
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Cinematic photograph')
|
|
&& $prompt->contains('mountain hiker'));
|
|
});
|
|
|
|
test('generate maps orientation to portrait', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, orientation: 'portrait');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->isPortrait());
|
|
});
|
|
|
|
test('generate maps orientation to landscape', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, orientation: 'landscape');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->isLandscape());
|
|
});
|
|
|
|
test('generate falls back to square for unknown orientation', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, orientation: 'whatever');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->isSquare());
|
|
});
|
|
|
|
test('generate appends Brazilian Portuguese instruction when language is pt-BR', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, language: 'pt-BR');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Brazilian Portuguese'));
|
|
});
|
|
|
|
test('generate appends Spanish instruction when language is es', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, language: 'es');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Spanish'));
|
|
});
|
|
|
|
test('generate defaults to English instruction when language is unknown', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, language: 'fr');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('English'));
|
|
});
|
|
|
|
test('generate appends brand color accent when brandColor is provided', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, brandColor: '#f47b20');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('warm orange')
|
|
&& $prompt->contains('small accent'));
|
|
});
|
|
|
|
test('generate omits brand color accent when brandColor is null', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic);
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('small accent'));
|
|
});
|
|
|
|
test('generate skips accent when brandColor hex is malformed', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, brandColor: 'not-a-hex');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('small accent'));
|
|
});
|
|
|
|
test('generate appends brand context when brandDescription is provided', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, brandDescription: 'a fitness coaching brand for busy professionals');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Brand context')
|
|
&& $prompt->contains('fitness coaching'));
|
|
});
|
|
|
|
test('generate truncates brand description longer than 200 chars', function () {
|
|
Image::fake();
|
|
|
|
$longDescription = str_repeat('lorem ipsum ', 50);
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, brandDescription: $longDescription);
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Brand context')
|
|
&& $prompt->contains('…'));
|
|
});
|
|
|
|
test('generate omits brand context when brandDescription is empty or whitespace', function () {
|
|
Image::fake();
|
|
|
|
$client = new AiImageClient;
|
|
$client->generate(['x'], ImageStyle::Cinematic, brandDescription: ' ');
|
|
|
|
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('Brand context'));
|
|
});
|
|
|
|
test('generate returns null when SDK throws', function () {
|
|
Image::fake(fn () => throw new RuntimeException('boom'));
|
|
|
|
$client = new AiImageClient;
|
|
|
|
expect($client->generate(['x'], ImageStyle::Cinematic))->toBeNull();
|
|
});
|