feat: AI image generation pipeline and post creation overhaul
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.
|
|
@ -4,14 +4,21 @@
|
|||
|
||||
namespace App\DataTransferObjects;
|
||||
|
||||
use App\Enums\Media\Source;
|
||||
|
||||
class MediaItem
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $source_meta
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $path,
|
||||
public readonly string $url,
|
||||
public readonly ?string $mime_type = null,
|
||||
public readonly ?string $original_filename = null,
|
||||
public readonly ?Source $source = null,
|
||||
public readonly ?array $source_meta = null,
|
||||
) {}
|
||||
|
||||
public function isVideo(): bool
|
||||
|
|
@ -60,12 +67,19 @@ public static function fromArray(array $data): self
|
|||
};
|
||||
}
|
||||
|
||||
$sourceValue = data_get($data, 'source');
|
||||
$source = is_string($sourceValue) ? Source::tryFrom($sourceValue) : null;
|
||||
|
||||
$sourceMeta = data_get($data, 'source_meta');
|
||||
|
||||
return new self(
|
||||
id: data_get($data, 'id', ''),
|
||||
path: $path,
|
||||
url: data_get($data, 'url', ''),
|
||||
mime_type: $mimeType,
|
||||
original_filename: data_get($data, 'original_filename'),
|
||||
source: $source,
|
||||
source_meta: is_array($sourceMeta) ? $sourceMeta : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ enum UsageType: string
|
|||
{
|
||||
case Template = 'template';
|
||||
case Text = 'text';
|
||||
case Image = 'image';
|
||||
}
|
||||
|
|
|
|||
19
app/Enums/Media/Source.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Media;
|
||||
|
||||
/**
|
||||
* Origin of a media attachment on a post. `null`/absent means "uploaded by
|
||||
* the user" (legacy/unknown). The enum is forward-compatible so the regen
|
||||
* UI can decide on a per-source basis what actions are available (only `Ai`
|
||||
* exposes a "regenerate" button today, but `Unsplash`/`Giphy` may surface
|
||||
* "fetch alternate" or attribution UIs later).
|
||||
*/
|
||||
enum Source: string
|
||||
{
|
||||
case Ai = 'ai';
|
||||
case Unsplash = 'unsplash';
|
||||
case Giphy = 'giphy';
|
||||
}
|
||||
32
app/Enums/Workspace/ImageStyle.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Workspace;
|
||||
|
||||
/**
|
||||
* Visual style the AI image generator will lean into when rendering slides
|
||||
* and cover images for a workspace's posts. Each case maps to a prompt
|
||||
* template strategy in the image generation pipeline.
|
||||
*/
|
||||
enum ImageStyle: string
|
||||
{
|
||||
case Cinematic = 'cinematic';
|
||||
case Illustration = 'illustration';
|
||||
case Isometric3D = 'isometric_3d';
|
||||
case Cartoon = 'cartoon';
|
||||
case Typographic = 'typographic';
|
||||
case Infographic = 'infographic';
|
||||
case Minimalist = 'minimalist';
|
||||
case Mockup = 'mockup';
|
||||
|
||||
public const DEFAULT = self::Cinematic;
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(fn (self $s) => $s->value, self::cases());
|
||||
}
|
||||
}
|
||||
|
|
@ -17,10 +17,8 @@ class PostCreationReady implements ShouldBroadcast
|
|||
public function __construct(
|
||||
public string $userId,
|
||||
public string $creationId,
|
||||
public ?string $content,
|
||||
public ?string $postId = null,
|
||||
public ?string $error = null,
|
||||
public ?string $imageTitle = null,
|
||||
public ?string $imageBody = null,
|
||||
) {}
|
||||
|
||||
public function broadcastAs(): string
|
||||
|
|
@ -40,9 +38,7 @@ public function broadcastWith(): array
|
|||
{
|
||||
return [
|
||||
'creation_id' => $this->creationId,
|
||||
'content' => $this->content,
|
||||
'image_title' => $this->imageTitle,
|
||||
'image_body' => $this->imageBody,
|
||||
'post_id' => $this->postId,
|
||||
'error' => $this->error,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,11 @@
|
|||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Actions\Post\CreatePost;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Http\Requests\App\Ai\StartPostCreationRequest;
|
||||
use App\Jobs\Ai\StreamPostCreation;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
|
|
@ -62,162 +55,4 @@ public function start(StartPostCreationRequest $request): JsonResponse
|
|||
'channel' => "users.{$request->user()->id}.ai-creation.{$creationId}",
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
public function finalize(Request $request, string $creationId): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$state = Cache::get("ai-creation:{$creationId}");
|
||||
|
||||
if (! $state || data_get($state, 'user_id') !== $request->user()->id) {
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$format = data_get($state, 'format');
|
||||
$socialAccountId = data_get($state, 'social_account_id');
|
||||
$contentType = $format ? ContentType::tryFrom($format) : null;
|
||||
|
||||
// The wizard's preview is editable. Frontend sends whichever fields it
|
||||
// showed: caption for normal formats, image_title + image_body for
|
||||
// stories. We override the cached state with whatever was edited.
|
||||
if ($request->filled('content')) {
|
||||
$state['content'] = (string) $request->input('content');
|
||||
}
|
||||
if ($request->filled('image_title')) {
|
||||
$state['image_title'] = (string) $request->input('image_title');
|
||||
}
|
||||
if ($request->filled('image_body')) {
|
||||
$state['image_body'] = (string) $request->input('image_body');
|
||||
}
|
||||
|
||||
$media = $this->buildMediaArray($workspace, $state);
|
||||
|
||||
$caption = ($contentType && ! $contentType->supportsCaption())
|
||||
? ''
|
||||
: (string) data_get($state, 'content', '');
|
||||
|
||||
$post = CreatePost::execute($workspace, $request->user(), [
|
||||
'content' => $caption,
|
||||
'media' => $media,
|
||||
'date' => data_get($state, 'date'),
|
||||
]);
|
||||
|
||||
// Sync the post_platform with the wizard choice: set content_type to
|
||||
// match the format (so the editor surfaces "Story" instead of "Post"
|
||||
// for instance) and aspect_ratio so the preview matches the rendered
|
||||
// image exactly. Carousel collapses to feed since Instagram doesn't
|
||||
// expose carousel as a separate content_type at the API level.
|
||||
if ($contentType && $socialAccountId) {
|
||||
$aspectRatio = $this->aspectRatioFor($contentType);
|
||||
$platformContentType = $contentType === ContentType::InstagramCarousel
|
||||
? ContentType::InstagramFeed
|
||||
: $contentType;
|
||||
|
||||
$post->postPlatforms()
|
||||
->where('social_account_id', $socialAccountId)
|
||||
->each(function ($platform) use ($aspectRatio, $platformContentType): void {
|
||||
$meta = $platform->meta ?? [];
|
||||
if ($aspectRatio !== null) {
|
||||
$meta['aspect_ratio'] = $aspectRatio;
|
||||
}
|
||||
$platform->meta = $meta;
|
||||
$platform->content_type = $platformContentType->value;
|
||||
$platform->enabled = true;
|
||||
$platform->save();
|
||||
});
|
||||
}
|
||||
|
||||
Cache::forget("ai-creation:{$creationId}");
|
||||
|
||||
return response()->json([
|
||||
'post_id' => $post->id,
|
||||
'redirect_url' => route('app.posts.edit', $post),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the AI image dimensions to the aspect_ratio string the editor's
|
||||
* preview understands. Returns null when the size doesn't match a known
|
||||
* preview ratio (Instagram preview supports 1:1, 4:5, 16:9, original).
|
||||
*/
|
||||
private function aspectRatioFor(ContentType $type): ?string
|
||||
{
|
||||
$dims = $type->aiImageDimensions();
|
||||
$ratio = $dims['width'] / $dims['height'];
|
||||
|
||||
return match (true) {
|
||||
abs($ratio - 1.0) < 0.01 => '1:1',
|
||||
abs($ratio - 4 / 5) < 0.01 => '4:5',
|
||||
abs($ratio - 16 / 9) < 0.01 => '16:9',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the media array for post creation from the AI creation state.
|
||||
*
|
||||
* @param array<string, mixed> $state
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildMediaArray(Workspace $workspace, array $state): array
|
||||
{
|
||||
$media = [];
|
||||
|
||||
if (data_get($state, 'format') === 'instagram_carousel') {
|
||||
foreach (data_get($state, 'slides', []) as $slide) {
|
||||
$path = data_get($slide, 'image_path');
|
||||
if ($path) {
|
||||
$media[] = $this->createMediaItem($workspace, $path, [
|
||||
'slide_title' => data_get($slide, 'title'),
|
||||
'slide_body' => data_get($slide, 'body'),
|
||||
'slide_keywords' => data_get($slide, 'image_keywords', []),
|
||||
'is_closing' => (bool) data_get($slide, 'is_closing', false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$path = data_get($state, 'image_path');
|
||||
if ($path) {
|
||||
$media[] = $this->createMediaItem($workspace, $path, [
|
||||
'slide_title' => data_get($state, 'image_title'),
|
||||
'slide_body' => data_get($state, 'image_body'),
|
||||
'slide_keywords' => data_get($state, 'image_keywords', []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Media record for an AI-generated image and return it as an array.
|
||||
*
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function createMediaItem(Workspace $workspace, string $path, array $meta = []): array
|
||||
{
|
||||
// Use the relationship (not `mediable_type = Workspace::class`) so
|
||||
// the morph map alias `'workspace'` is persisted instead of the FQCN.
|
||||
$media = $workspace->media()->create([
|
||||
'collection' => 'ai-generated',
|
||||
'type' => MediaType::Image,
|
||||
'path' => $path,
|
||||
'original_filename' => basename($path),
|
||||
'mime_type' => 'image/webp',
|
||||
'size' => Storage::size($path),
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'meta' => $meta,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,11 +69,8 @@ public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateI
|
|||
$media = [];
|
||||
|
||||
if ($socialAccount && $template->slides) {
|
||||
foreach ($template->slides as $i => $slide) {
|
||||
// Even-indexed slides use Template A (full-bleed cover); odd-indexed slides use Template B.
|
||||
$tmpl = $i % 2 === 0 ? 'A' : 'B';
|
||||
$path = $generator->render(
|
||||
template: $tmpl,
|
||||
foreach ($template->slides as $slide) {
|
||||
$rendered = $generator->render(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $this->interpolate(data_get($slide, 'title', ''), $workspace),
|
||||
|
|
@ -81,8 +78,8 @@ public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateI
|
|||
imageKeywords: data_get($slide, 'image_keywords', []),
|
||||
);
|
||||
|
||||
if ($path) {
|
||||
$mediaItem = $this->createMediaItem($workspace, $path);
|
||||
if ($rendered) {
|
||||
$mediaItem = $this->createMediaItem($workspace, $rendered->path);
|
||||
$media[] = $mediaItem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Actions\Workspace\CreateWorkspace;
|
||||
use App\Actions\Workspace\DeleteWorkspace;
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
|
||||
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
|
||||
use App\Http\Resources\App\WorkspaceMemberResource;
|
||||
|
|
@ -77,7 +78,10 @@ public function create(Request $request): Response|RedirectResponse
|
|||
return back()->with('flash.error', __('workspaces.limit_reached'));
|
||||
}
|
||||
|
||||
return Inertia::render('workspaces/Create');
|
||||
return Inertia::render('workspaces/Create', [
|
||||
'availableFonts' => BrandFont::values(),
|
||||
'availableImageStyles' => ImageStyle::values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function autofillBrand(Request $request, AutofillBrand $autofill): JsonResponse
|
||||
|
|
@ -173,6 +177,7 @@ public function brandSettings(Request $request): Response|RedirectResponse
|
|||
return Inertia::render('settings/workspace/Brand', [
|
||||
'workspace' => $workspace,
|
||||
'availableFonts' => BrandFont::values(),
|
||||
'availableImageStyles' => ImageStyle::values(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ public function rules(): array
|
|||
],
|
||||
'social_account_id' => ['nullable', 'uuid'],
|
||||
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
|
||||
// Stories accept 1 image, no carousel — the wizard handles this client-side too.
|
||||
'prompt' => ['required', 'string', 'max:2000'],
|
||||
'date' => ['nullable', 'date_format:Y-m-d'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Http\Requests\App\Workspace;
|
||||
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ public function rules(): array
|
|||
'background_color' => $hex,
|
||||
'text_color' => $hex,
|
||||
'brand_font' => ['sometimes', 'string', Rule::in(BrandFont::values())],
|
||||
'image_style' => ['sometimes', 'string', Rule::in(ImageStyle::values())],
|
||||
'content_language' => ['nullable', 'string', 'in:en,pt-BR,es'],
|
||||
'logo_url' => ['nullable', 'url', 'max:1024'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Http\Requests\App\Workspace;
|
||||
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ public function rules(): array
|
|||
'background_color' => $hex,
|
||||
'text_color' => $hex,
|
||||
'brand_font' => ['required', 'string', Rule::in(BrandFont::values())],
|
||||
'image_style' => ['required', 'string', Rule::in(ImageStyle::values())],
|
||||
'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,23 +4,29 @@
|
|||
|
||||
namespace App\Jobs\Ai;
|
||||
|
||||
use App\Actions\Post\CreatePost;
|
||||
use App\Ai\Agents\PostContentGenerator;
|
||||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Events\Ai\PostCreationReady;
|
||||
use App\Models\Post;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\AiImageClient;
|
||||
use App\Services\Ai\RecordAiUsage;
|
||||
use App\Services\Image\BrandColorMapper;
|
||||
use App\Services\Image\RenderedSlide;
|
||||
use App\Services\Image\TemplateImageGenerator;
|
||||
use App\Services\Unsplash\UnsplashClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class StreamPostCreation implements ShouldQueue
|
||||
{
|
||||
|
|
@ -46,6 +52,7 @@ public function handle(): void
|
|||
|
||||
$isCarousel = $this->format === 'instagram_carousel';
|
||||
$agentFormat = $isCarousel ? 'carousel' : 'single';
|
||||
|
||||
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
|
||||
|
||||
$agent = new PostContentGenerator(
|
||||
|
|
@ -182,27 +189,19 @@ private function humanize(Workspace $workspace, array $structured, string $forma
|
|||
return $structured;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $structured
|
||||
*/
|
||||
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
|
||||
{
|
||||
$caption = data_get($structured, 'caption', '');
|
||||
$caption = (string) data_get($structured, 'caption', '');
|
||||
$slides = data_get($structured, 'slides', []);
|
||||
|
||||
$renderedSlides = [];
|
||||
$media = [];
|
||||
|
||||
if ($socialAccount) {
|
||||
$generator = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
|
||||
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
||||
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
|
||||
|
||||
foreach ($slides as $i => $slide) {
|
||||
// First slide is always Template A (full-bleed cover); subsequent slides
|
||||
// alternate so even-indexed are A and odd-indexed are B.
|
||||
$template = $i === 0 ? 'A' : ($i % 2 === 0 ? 'A' : 'B');
|
||||
|
||||
$path = $generator->render(
|
||||
template: $template,
|
||||
foreach ($slides as $slide) {
|
||||
$rendered = $generator->render(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: data_get($slide, 'title', ''),
|
||||
|
|
@ -212,51 +211,19 @@ private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAcco
|
|||
height: $height,
|
||||
);
|
||||
|
||||
$renderedSlides[] = [
|
||||
'title' => data_get($slide, 'title', ''),
|
||||
'body' => data_get($slide, 'body', ''),
|
||||
'image_path' => $path,
|
||||
];
|
||||
}
|
||||
|
||||
// Auto-append closing slide (Template C) at the end of the carousel.
|
||||
$closingPath = $generator->renderClosing(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
width: $width,
|
||||
height: $height,
|
||||
);
|
||||
|
||||
if ($closingPath) {
|
||||
$renderedSlides[] = [
|
||||
'title' => null,
|
||||
'body' => null,
|
||||
'image_path' => $closingPath,
|
||||
'is_closing' => true,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
foreach ($slides as $slide) {
|
||||
$renderedSlides[] = [
|
||||
'title' => data_get($slide, 'title', ''),
|
||||
'body' => data_get($slide, 'body', ''),
|
||||
'image_path' => null,
|
||||
];
|
||||
if ($rendered) {
|
||||
$media[] = $this->buildAiMediaItem($workspace, $rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cache::put("ai-creation:{$this->creationId}", [
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'user_id' => $this->userId,
|
||||
'format' => $this->format,
|
||||
'social_account_id' => $this->socialAccountId,
|
||||
'content' => $caption,
|
||||
'slides' => $renderedSlides,
|
||||
'date' => $this->date,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
|
||||
|
||||
PostCreationReady::dispatch($this->userId, $this->creationId, $caption);
|
||||
PostCreationReady::dispatch(
|
||||
userId: $this->userId,
|
||||
creationId: $this->creationId,
|
||||
postId: $post->id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -264,19 +231,21 @@ private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAcco
|
|||
*/
|
||||
private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
|
||||
{
|
||||
$content = data_get($structured, 'content', data_get($structured, 'text', ''));
|
||||
$imageTitle = data_get($structured, 'image_title', '');
|
||||
$imageBody = data_get($structured, 'image_body', '');
|
||||
$contentType = ContentType::tryFrom($this->format);
|
||||
$supportsCaption = $contentType?->supportsCaption() ?? true;
|
||||
|
||||
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
|
||||
$imageTitle = (string) data_get($structured, 'image_title', '');
|
||||
$imageBody = (string) data_get($structured, 'image_body', '');
|
||||
$keywords = data_get($structured, 'image_keywords', []);
|
||||
|
||||
$imagePath = null;
|
||||
$media = [];
|
||||
|
||||
if ($this->imageCount > 0 && $socialAccount) {
|
||||
$generator = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
|
||||
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
||||
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
|
||||
|
||||
$imagePath = $generator->render(
|
||||
template: 'A',
|
||||
$rendered = $generator->render(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $imageTitle,
|
||||
|
|
@ -285,32 +254,96 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun
|
|||
width: $width,
|
||||
height: $height,
|
||||
);
|
||||
|
||||
if ($rendered) {
|
||||
$media[] = $this->buildAiMediaItem($workspace, $rendered);
|
||||
}
|
||||
}
|
||||
|
||||
Cache::put("ai-creation:{$this->creationId}", [
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'user_id' => $this->userId,
|
||||
'format' => $this->format,
|
||||
'social_account_id' => $this->socialAccountId,
|
||||
'image_count' => $this->imageCount,
|
||||
'content' => $content,
|
||||
'image_title' => $imageTitle,
|
||||
'image_body' => $imageBody,
|
||||
'image_keywords' => $keywords,
|
||||
'image_path' => $imagePath,
|
||||
'date' => $this->date,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
$caption = $supportsCaption ? $rawContent : '';
|
||||
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
|
||||
|
||||
// Broadcast all three fields — the frontend renders caption (when the
|
||||
// format supports it) or title+body separately (for caption-less story
|
||||
// formats). No string parsing on the way back.
|
||||
PostCreationReady::dispatch(
|
||||
userId: $this->userId,
|
||||
creationId: $this->creationId,
|
||||
content: $content,
|
||||
imageTitle: $imageTitle,
|
||||
imageBody: $imageBody,
|
||||
postId: $post->id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $media
|
||||
*/
|
||||
private function createPost(Workspace $workspace, string $content, array $media, ?SocialAccount $socialAccount): Post
|
||||
{
|
||||
$user = User::findOrFail($this->userId);
|
||||
|
||||
$post = CreatePost::execute($workspace, $user, [
|
||||
'content' => $content,
|
||||
'media' => $media,
|
||||
'date' => $this->date,
|
||||
]);
|
||||
|
||||
$contentType = ContentType::tryFrom($this->format);
|
||||
|
||||
if ($contentType && $socialAccount) {
|
||||
$aspectRatio = $this->aspectRatioFor($contentType);
|
||||
$platformContentType = $contentType === ContentType::InstagramCarousel
|
||||
? ContentType::InstagramFeed
|
||||
: $contentType;
|
||||
|
||||
$post->postPlatforms()
|
||||
->where('social_account_id', $socialAccount->id)
|
||||
->each(function ($platform) use ($aspectRatio, $platformContentType): void {
|
||||
$meta = $platform->meta ?? [];
|
||||
if ($aspectRatio !== null) {
|
||||
$meta['aspect_ratio'] = $aspectRatio;
|
||||
}
|
||||
$platform->meta = $meta;
|
||||
$platform->content_type = $platformContentType->value;
|
||||
$platform->enabled = true;
|
||||
$platform->save();
|
||||
});
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
private function aspectRatioFor(ContentType $type): ?string
|
||||
{
|
||||
$dims = $type->aiImageDimensions();
|
||||
$ratio = $dims['width'] / $dims['height'];
|
||||
|
||||
return match (true) {
|
||||
abs($ratio - 1.0) < 0.01 => '1:1',
|
||||
abs($ratio - 4 / 5) < 0.01 => '4:5',
|
||||
abs($ratio - 16 / 9) < 0.01 => '16:9',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildAiMediaItem(Workspace $workspace, RenderedSlide $rendered): array
|
||||
{
|
||||
$media = $workspace->media()->create([
|
||||
'collection' => 'ai-generated',
|
||||
'type' => MediaType::Image,
|
||||
'path' => $rendered->path,
|
||||
'original_filename' => basename($rendered->path),
|
||||
'mime_type' => 'image/webp',
|
||||
'size' => Storage::size($rendered->path),
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => Source::Ai->value,
|
||||
'source_meta' => $rendered->sourceMeta,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use App\Models\Traits\HasMedia;
|
||||
use Database\Factories\WorkspaceFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
|
@ -31,9 +32,17 @@ class Workspace extends Model
|
|||
'background_color',
|
||||
'text_color',
|
||||
'brand_font',
|
||||
'image_style',
|
||||
'content_language',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'image_style' => ImageStyle::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected $appends = ['has_logo', 'logo_url'];
|
||||
|
||||
public function getHasLogoAttribute(): bool
|
||||
|
|
|
|||
95
app/Services/Ai/AiImageClient.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use App\Support\HexColorName;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Ai\Image;
|
||||
use Throwable;
|
||||
|
||||
class AiImageClient
|
||||
{
|
||||
public const MODEL = 'gpt-image-2';
|
||||
|
||||
private const BRAND_DESCRIPTION_MAX = 200;
|
||||
|
||||
/**
|
||||
* Generate raw image bytes via OpenAI gpt-image-2. Returns null on any
|
||||
* failure so the caller can fall back to a stock photo without throwing.
|
||||
*
|
||||
* @param array<int, string> $keywords
|
||||
*/
|
||||
public function generate(
|
||||
array $keywords,
|
||||
ImageStyle $style,
|
||||
string $orientation = 'portrait',
|
||||
string $language = 'en',
|
||||
?string $brandColor = null,
|
||||
?string $brandDescription = null,
|
||||
string $quality = 'low',
|
||||
int $timeout = 180,
|
||||
): ?string {
|
||||
$clean = array_values(array_filter(array_map('trim', $keywords)));
|
||||
if ($clean === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$brandColorName = $brandColor !== null
|
||||
? HexColorName::approximate($brandColor)
|
||||
: null;
|
||||
|
||||
$brandContext = null;
|
||||
if ($brandDescription !== null) {
|
||||
$trimmed = trim($brandDescription);
|
||||
if ($trimmed !== '') {
|
||||
$brandContext = mb_strlen($trimmed) > self::BRAND_DESCRIPTION_MAX
|
||||
? mb_substr($trimmed, 0, self::BRAND_DESCRIPTION_MAX).'…'
|
||||
: $trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
$prompt = view('prompts.post_image.generator', [
|
||||
'style' => $style->value,
|
||||
'scene' => implode(', ', $clean),
|
||||
'language_name' => $this->languageName($language),
|
||||
'brand_color_name' => $brandColorName,
|
||||
'brand_context' => $brandContext,
|
||||
])->render();
|
||||
|
||||
try {
|
||||
$builder = Image::of($prompt)->quality($quality)->timeout($timeout);
|
||||
|
||||
$builder = match ($orientation) {
|
||||
'portrait' => $builder->portrait(),
|
||||
'landscape' => $builder->landscape(),
|
||||
default => $builder->square(),
|
||||
};
|
||||
|
||||
$image = $builder->generate(model: self::MODEL);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('AiImageClient: generation failed', [
|
||||
'style' => $style->value,
|
||||
'orientation' => $orientation,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = (string) $image;
|
||||
|
||||
return $bytes !== '' ? $bytes : null;
|
||||
}
|
||||
|
||||
private function languageName(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
'pt-BR' => 'Brazilian Portuguese',
|
||||
'es' => 'Spanish',
|
||||
default => 'English',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,37 @@ public static function recordText(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a usage entry for an AI image generation (gpt-image-* etc.).
|
||||
* Credits are flat per call via CreditCost::forImage($model).
|
||||
*
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public static function recordImage(
|
||||
Workspace $workspace,
|
||||
string $provider,
|
||||
string $model,
|
||||
?string $userId = null,
|
||||
?string $postId = null,
|
||||
array $metadata = [],
|
||||
): void {
|
||||
$credits = CreditCost::forImage($model);
|
||||
|
||||
self::persist(
|
||||
workspace: $workspace,
|
||||
type: UsageType::Image,
|
||||
credits: $credits,
|
||||
provider: $provider,
|
||||
model: $model,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
userId: $userId,
|
||||
postId: $postId,
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a usage entry for an image-template generation. Templates do not
|
||||
* call an LLM (composed via Unsplash + branding) so we charge zero credits.
|
||||
|
|
|
|||
253
app/Services/Brand/CssColorFrequencyExtractor.php
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Brand;
|
||||
|
||||
/**
|
||||
* Extracts the most representative non-neutral colour from a CSS blob by
|
||||
* counting occurrences and clustering perceptually similar shades together
|
||||
* in CIE LAB space. Solves the Tailwind/utility-CSS case where the brand
|
||||
* colour appears hundreds of times as `bg-blue-700`/`border-blue-700`/etc
|
||||
* but no semantic --primary variable is exposed.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Regex-extract every #hex/rgb()/rgba()/hsl()/hsla() value from the CSS
|
||||
* 2. Normalise each to lowercase #rrggbb
|
||||
* 3. Convert to LAB and cluster colours within `delta E (CIE76) < 12`
|
||||
* 4. Drop clusters whose centre is neutral (low channel spread)
|
||||
* 5. Return the centre of the largest remaining cluster
|
||||
*
|
||||
* CIE76 (Euclidean ΔE in LAB) is intentional — DE2000 is the print-grade
|
||||
* gold standard but adds complexity for marginal accuracy at the "are
|
||||
* these the same brand colour?" task we're solving here.
|
||||
*/
|
||||
final class CssColorFrequencyExtractor
|
||||
{
|
||||
private const int CLUSTER_THRESHOLD = 12;
|
||||
|
||||
private const int NEUTRAL_CHANNEL_DELTA = 18;
|
||||
|
||||
public function extract(string $css): ?string
|
||||
{
|
||||
$occurrences = $this->countOccurrences($css);
|
||||
if ($occurrences === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clusters = $this->clusterPerceptually($occurrences);
|
||||
|
||||
// Drop clusters whose representative colour is neutral.
|
||||
$clusters = array_values(array_filter(
|
||||
$clusters,
|
||||
fn (array $cluster): bool => ! $this->isNeutral($cluster['hex']),
|
||||
));
|
||||
|
||||
if ($clusters === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($clusters, fn (array $a, array $b): int => $b['count'] <=> $a['count']);
|
||||
|
||||
return $clusters[0]['hex'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int> Hex colour => occurrence count.
|
||||
*/
|
||||
private function countOccurrences(string $css): array
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
$patterns = [
|
||||
'/#([0-9a-fA-F]{8})\b/' => fn (array $m): ?string => $this->normaliseHex(substr($m[1], 0, 6)),
|
||||
'/#([0-9a-fA-F]{6})\b/' => fn (array $m): ?string => $this->normaliseHex($m[1]),
|
||||
'/#([0-9a-fA-F]{3})\b/' => function (array $m): ?string {
|
||||
$h = $m[1];
|
||||
|
||||
return $this->normaliseHex($h[0].$h[0].$h[1].$h[1].$h[2].$h[2]);
|
||||
},
|
||||
'/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i' => fn (array $m): ?string => $this->rgbToHex((int) $m[1], (int) $m[2], (int) $m[3]),
|
||||
'/hsla?\(\s*(\d{1,3}(?:\.\d+)?)\s*,?\s*(\d{1,3}(?:\.\d+)?)%\s*,?\s*(\d{1,3}(?:\.\d+)?)%/i' => fn (array $m): ?string => $this->hslToHex((float) $m[1], (float) $m[2], (float) $m[3]),
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern => $convert) {
|
||||
if (preg_match_all($pattern, $css, $matches, PREG_SET_ORDER) === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($matches as $match) {
|
||||
$hex = $convert($match);
|
||||
if ($hex !== null) {
|
||||
$counts[$hex] = ($counts[$hex] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group colours by perceptual similarity (CIE76 ΔE) and sum their counts.
|
||||
*
|
||||
* @param array<string, int> $occurrences
|
||||
* @return list<array{hex: string, count: int}>
|
||||
*/
|
||||
private function clusterPerceptually(array $occurrences): array
|
||||
{
|
||||
// Sort entries so the most frequent colour anchors each cluster.
|
||||
arsort($occurrences);
|
||||
|
||||
$clusters = [];
|
||||
|
||||
foreach ($occurrences as $hex => $count) {
|
||||
$lab = $this->hexToLab($hex);
|
||||
$merged = false;
|
||||
|
||||
foreach ($clusters as &$cluster) {
|
||||
if ($this->deltaE76($lab, $cluster['lab']) < self::CLUSTER_THRESHOLD) {
|
||||
$cluster['count'] += $count;
|
||||
$merged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($cluster);
|
||||
|
||||
if (! $merged) {
|
||||
$clusters[] = ['hex' => $hex, 'lab' => $lab, 'count' => $count];
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (array $c): array => ['hex' => $c['hex'], 'count' => $c['count']],
|
||||
$clusters,
|
||||
);
|
||||
}
|
||||
|
||||
private function isNeutral(string $hex): bool
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
$r = (int) hexdec(substr($hex, 0, 2));
|
||||
$g = (int) hexdec(substr($hex, 2, 2));
|
||||
$b = (int) hexdec(substr($hex, 4, 2));
|
||||
|
||||
$maxDelta = max(abs($r - $g), abs($g - $b), abs($r - $b));
|
||||
|
||||
return $maxDelta < self::NEUTRAL_CHANNEL_DELTA;
|
||||
}
|
||||
|
||||
private function normaliseHex(string $hex6): ?string
|
||||
{
|
||||
if (! ctype_xdigit($hex6) || strlen($hex6) !== 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '#'.strtolower($hex6);
|
||||
}
|
||||
|
||||
private function rgbToHex(int $r, int $g, int $b): ?string
|
||||
{
|
||||
if ($r > 255 || $g > 255 || $b > 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('#%02x%02x%02x', $r, $g, $b);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null Hex colour.
|
||||
*/
|
||||
private function hslToHex(float $h, float $s, float $l): ?string
|
||||
{
|
||||
if ($h > 360 || $s > 100 || $l > 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$h /= 360;
|
||||
$s /= 100;
|
||||
$l /= 100;
|
||||
|
||||
if ($s === 0.0) {
|
||||
$r = $g = $b = $l;
|
||||
} else {
|
||||
$q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
|
||||
$p = 2 * $l - $q;
|
||||
$r = $this->hueToRgb($p, $q, $h + 1 / 3);
|
||||
$g = $this->hueToRgb($p, $q, $h);
|
||||
$b = $this->hueToRgb($p, $q, $h - 1 / 3);
|
||||
}
|
||||
|
||||
return $this->rgbToHex(
|
||||
(int) round($r * 255),
|
||||
(int) round($g * 255),
|
||||
(int) round($b * 255),
|
||||
);
|
||||
}
|
||||
|
||||
private function hueToRgb(float $p, float $q, float $t): float
|
||||
{
|
||||
if ($t < 0) {
|
||||
$t += 1;
|
||||
}
|
||||
if ($t > 1) {
|
||||
$t -= 1;
|
||||
}
|
||||
if ($t < 1 / 6) {
|
||||
return $p + ($q - $p) * 6 * $t;
|
||||
}
|
||||
if ($t < 1 / 2) {
|
||||
return $q;
|
||||
}
|
||||
if ($t < 2 / 3) {
|
||||
return $p + ($q - $p) * (2 / 3 - $t) * 6;
|
||||
}
|
||||
|
||||
return $p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex to CIE LAB. Goes via sRGB → XYZ (D65) → LAB.
|
||||
*
|
||||
* @return array{0: float, 1: float, 2: float}
|
||||
*/
|
||||
private function hexToLab(string $hex): array
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
$r = hexdec(substr($hex, 0, 2)) / 255;
|
||||
$g = hexdec(substr($hex, 2, 2)) / 255;
|
||||
$b = hexdec(substr($hex, 4, 2)) / 255;
|
||||
|
||||
// sRGB → linear RGB
|
||||
$r = $r > 0.04045 ? (($r + 0.055) / 1.055) ** 2.4 : $r / 12.92;
|
||||
$g = $g > 0.04045 ? (($g + 0.055) / 1.055) ** 2.4 : $g / 12.92;
|
||||
$b = $b > 0.04045 ? (($b + 0.055) / 1.055) ** 2.4 : $b / 12.92;
|
||||
|
||||
// Linear RGB → XYZ (D65)
|
||||
$x = ($r * 0.4124564 + $g * 0.3575761 + $b * 0.1804375) / 0.95047;
|
||||
$y = ($r * 0.2126729 + $g * 0.7151522 + $b * 0.0721750) / 1.00000;
|
||||
$z = ($r * 0.0193339 + $g * 0.1191920 + $b * 0.9503041) / 1.08883;
|
||||
|
||||
$f = fn (float $t): float => $t > 0.008856 ? $t ** (1 / 3) : (7.787 * $t) + 16 / 116;
|
||||
$fx = $f($x);
|
||||
$fy = $f($y);
|
||||
$fz = $f($z);
|
||||
|
||||
return [
|
||||
116 * $fy - 16,
|
||||
500 * ($fx - $fy),
|
||||
200 * ($fy - $fz),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $lab1
|
||||
* @param array{0: float, 1: float, 2: float} $lab2
|
||||
*/
|
||||
private function deltaE76(array $lab1, array $lab2): float
|
||||
{
|
||||
return sqrt(
|
||||
($lab1[0] - $lab2[0]) ** 2 +
|
||||
($lab1[1] - $lab2[1]) ** 2 +
|
||||
($lab1[2] - $lab2[2]) ** 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,10 @@ final class HomepageMetaExtractor
|
|||
{
|
||||
private const array TITLE_SEPARATORS = [' | ', ' - ', ' — ', ' – '];
|
||||
|
||||
public function __construct(
|
||||
private readonly CssColorFrequencyExtractor $cssColorFrequency = new CssColorFrequencyExtractor,
|
||||
) {}
|
||||
|
||||
public function extract(string $html, string $baseUrl, string $extraCss = ''): BrandMetadata
|
||||
{
|
||||
$crawler = new Crawler($html, $baseUrl);
|
||||
|
|
@ -77,6 +81,14 @@ private function extractColors(Crawler $crawler, string $html, string $extraCss
|
|||
$brand = $this->matchCssVar($css, ['primary', 'brand', 'brand-primary', 'accent', 'color-primary', 'main', 'theme']);
|
||||
}
|
||||
|
||||
// Final fallback: scan all colour values in the collected CSS, cluster
|
||||
// perceptually similar shades and pick the most frequent non-neutral
|
||||
// cluster. Catches Tailwind/utility-CSS sites where no semantic var
|
||||
// exposes the brand colour.
|
||||
if ($brand === null || $this->normalizeHex($brand) === null) {
|
||||
$brand = $this->cssColorFrequency->extract($css);
|
||||
}
|
||||
|
||||
$background = $this->matchCssVar($css, ['background', 'bg', 'background-color', 'surface', 'color-bg', 'body-bg']);
|
||||
$text = $this->matchCssVar($css, ['foreground', 'text', 'color-text', 'on-background', 'body-color']);
|
||||
|
||||
|
|
|
|||
16
app/Services/Image/RenderedSlide.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Image;
|
||||
|
||||
class RenderedSlide
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $sourceMeta
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $path,
|
||||
public readonly array $sourceMeta,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -4,10 +4,11 @@
|
|||
|
||||
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 App\Services\Unsplash\UnsplashClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
|
|
@ -29,100 +30,17 @@ class TemplateImageGenerator
|
|||
private int $height = self::DEFAULT_HEIGHT;
|
||||
|
||||
public function __construct(
|
||||
private UnsplashClient $unsplash,
|
||||
private BrandColorMapper $colorMapper,
|
||||
private AiImageClient $aiImage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Render a slide using Template A (full bleed) or Template B (photo card).
|
||||
*
|
||||
* @param array<int, string> $imageKeywords
|
||||
* @return string|null The storage path, or null on failure.
|
||||
* @return RenderedSlide|null The storage path + source meta, or null on failure.
|
||||
*/
|
||||
/**
|
||||
* Render a closing/CTA slide (Template C) for the end of a carousel.
|
||||
* Solid brand-colored background with centered avatar + display name +
|
||||
* short divider + @handle.
|
||||
*/
|
||||
public function renderClosing(
|
||||
Workspace $workspace,
|
||||
SocialAccount $socialAccount,
|
||||
int $width = self::DEFAULT_WIDTH,
|
||||
int $height = self::DEFAULT_HEIGHT,
|
||||
): ?string {
|
||||
$this->width = $width;
|
||||
$this->height = $height;
|
||||
|
||||
$bgColor = $workspace->background_color ?? '#0F172A';
|
||||
$brandColor = $workspace->brand_color ?? '#D4AF37';
|
||||
$textColor = $workspace->text_color ?? '#FFFFFF';
|
||||
|
||||
$manager = new ImageManager(Driver::class);
|
||||
$canvas = $manager->createImage($this->width, $this->height)->fill($bgColor);
|
||||
$core = $canvas->core()->native();
|
||||
|
||||
$fontBold = $this->fontPath('Inter-Bold.ttf');
|
||||
$fontMedium = $this->fontPath('Inter-Medium.ttf');
|
||||
|
||||
// Centered avatar — size scales with the smaller canvas dimension.
|
||||
$avatarSize = (int) round(min($this->width, $this->height) * 0.14);
|
||||
$avatarX = (int) (($this->width - $avatarSize) / 2);
|
||||
$avatarY = (int) ($this->height / 2 - $avatarSize - 10);
|
||||
|
||||
$avatarBinary = $this->fetchAvatarBinary($socialAccount);
|
||||
if ($avatarBinary) {
|
||||
$this->drawCircularAvatar($canvas, $avatarBinary, $avatarX, $avatarY, $avatarSize);
|
||||
}
|
||||
|
||||
// Display name (bold uppercase, letter-spaced, centered).
|
||||
$name = strtoupper((string) ($socialAccount->display_name ?? ''));
|
||||
$nameSize = (int) round(min($this->width, $this->height) * 0.037);
|
||||
$nameBaselineY = $avatarY + $avatarSize + 60 + (int) round($nameSize * 0.82);
|
||||
|
||||
if ($fontBold && $name !== '') {
|
||||
$nameWidth = $this->measureLetterSpacedWidth($name, $fontBold, $nameSize, 6);
|
||||
$nameX = (int) (($this->width - $nameWidth) / 2);
|
||||
$this->drawTextAt($core, $name, $fontBold, $nameSize, $textColor, $nameX, $nameBaselineY, letterSpacing: 6);
|
||||
}
|
||||
|
||||
// Short divider line under the name.
|
||||
$smallDividerY = $nameBaselineY + 30;
|
||||
$smallDividerWidth = 80;
|
||||
$smallDividerX = (int) (($this->width - $smallDividerWidth) / 2);
|
||||
$this->drawHorizontalLine($core, $smallDividerX, $smallDividerX + $smallDividerWidth, $smallDividerY, $brandColor, 1.0);
|
||||
|
||||
// @handle (lighter weight, lighter color, centered).
|
||||
$handle = $socialAccount->username ? '@'.$socialAccount->username : '';
|
||||
$handleSize = (int) round($nameSize * 0.65);
|
||||
$handleBaselineY = $smallDividerY + 30 + (int) round($handleSize * 0.82);
|
||||
|
||||
if ($fontMedium && $handle !== '') {
|
||||
// @handle uses text_color blended with the bg at 0.8 opacity —
|
||||
// legible on any brand palette, slightly muted vs the display name.
|
||||
$handleColor = $this->blendHex($textColor, $bgColor, 0.8);
|
||||
$handleWidth = $this->measureLetterSpacedWidth($handle, $fontMedium, $handleSize, 1);
|
||||
$handleX = (int) (($this->width - $handleWidth) / 2);
|
||||
$this->drawTextAt($core, $handle, $fontMedium, $handleSize, $handleColor, $handleX, $handleBaselineY, letterSpacing: 1);
|
||||
}
|
||||
|
||||
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
|
||||
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
|
||||
|
||||
RecordAiUsage::recordTemplate(
|
||||
workspace: $workspace,
|
||||
provider: 'internal',
|
||||
metadata: [
|
||||
'template' => 'C',
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
],
|
||||
);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function render(
|
||||
string $template,
|
||||
Workspace $workspace,
|
||||
SocialAccount $socialAccount,
|
||||
string $title,
|
||||
|
|
@ -130,54 +48,79 @@ public function render(
|
|||
array $imageKeywords,
|
||||
int $width = self::DEFAULT_WIDTH,
|
||||
int $height = self::DEFAULT_HEIGHT,
|
||||
): ?string {
|
||||
): ?RenderedSlide {
|
||||
$this->width = $width;
|
||||
$this->height = $height;
|
||||
|
||||
$colorBucket = $this->colorMapper->fromWorkspace($workspace);
|
||||
$orientation = $this->unsplashOrientation();
|
||||
$photo = $this->unsplash->searchPhoto($imageKeywords, $orientation, $colorBucket);
|
||||
|
||||
if (! $photo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$imageData = @file_get_contents($photo['url']);
|
||||
if (! $imageData) {
|
||||
Log::warning('TemplateImageGenerator: failed to download Unsplash photo', ['url' => $photo['url']]);
|
||||
$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 ?? 'en';
|
||||
|
||||
$imageData = $this->aiImage->generate(
|
||||
keywords: $imageKeywords,
|
||||
style: $imageStyle,
|
||||
orientation: $orientation,
|
||||
language: $language,
|
||||
brandColor: $workspace->brand_color,
|
||||
brandDescription: $workspace->brand_description,
|
||||
);
|
||||
if ($imageData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$manager = new ImageManager(Driver::class);
|
||||
|
||||
$canvas = $template === 'A'
|
||||
? $this->renderTemplateA($manager, $imageData, $title, $body)
|
||||
: $this->renderTemplateB($manager, $imageData, $title, $body, $workspace);
|
||||
|
||||
$canvas = $this->renderFooter($canvas, $socialAccount, $template, $workspace);
|
||||
$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)));
|
||||
|
||||
RecordAiUsage::recordTemplate(
|
||||
RecordAiUsage::recordImage(
|
||||
workspace: $workspace,
|
||||
provider: 'internal',
|
||||
provider: 'openai',
|
||||
model: AiImageClient::MODEL,
|
||||
metadata: [
|
||||
'template' => $template,
|
||||
'image_style' => $imageStyle->value,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
],
|
||||
);
|
||||
|
||||
return $filename;
|
||||
RecordAiUsage::recordTemplate(
|
||||
workspace: $workspace,
|
||||
provider: 'internal',
|
||||
metadata: [
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
],
|
||||
);
|
||||
|
||||
return new RenderedSlide(
|
||||
path: $filename,
|
||||
sourceMeta: [
|
||||
'keywords' => array_values($imageKeywords),
|
||||
'style' => $imageStyle->value,
|
||||
'language' => $language,
|
||||
'model' => AiImageClient::MODEL,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the closest Unsplash orientation for the active canvas. Avoids
|
||||
* stretching/cropping a portrait photo into a landscape canvas.
|
||||
* 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 unsplashOrientation(): string
|
||||
private function orientationForCanvas(): string
|
||||
{
|
||||
$ratio = $this->width / $this->height;
|
||||
if ($ratio > 1.1) {
|
||||
|
|
@ -309,26 +252,6 @@ private function renderTextLines($core, array $lines, string $fontPath, int $fon
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as renderTextLines but each line is horizontally centered within the
|
||||
* canvas based on its measured glyph width.
|
||||
*/
|
||||
private function renderTextLinesCentered($core, array $lines, string $fontPath, int $fontSize, float $lineHeight, string $hexColor, int $topY): void
|
||||
{
|
||||
$color = $this->allocateColor($core, $hexColor);
|
||||
$lineSpacing = (int) round($fontSize * $lineHeight);
|
||||
$ascent = (int) round($fontSize * 0.82);
|
||||
$baselineY = $topY + $ascent;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$bbox = imagettfbbox($fontSize, 0, $fontPath, $line);
|
||||
$lineWidth = abs($bbox[2] - $bbox[0]);
|
||||
$x = (int) round(($this->width - $lineWidth) / 2);
|
||||
imagettftext($core, $fontSize, 0, $x, $baselineY, $color, $fontPath, $line);
|
||||
$baselineY += $lineSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a GD color from a hex string (#rrggbb).
|
||||
*
|
||||
|
|
@ -343,23 +266,6 @@ private function allocateColor($core, string $hex): int
|
|||
return $color === false ? imagecolorallocate($core, 255, 255, 255) : $color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearly blend two hex colors. $weight is the share of $foreground in the
|
||||
* mix (0.0 = pure background, 1.0 = pure foreground).
|
||||
*/
|
||||
private function blendHex(string $foreground, string $background, float $weight): string
|
||||
{
|
||||
[$fr, $fg, $fb] = $this->hexToRgb($foreground);
|
||||
[$br, $bg, $bb] = $this->hexToRgb($background);
|
||||
$w = max(0.0, min(1.0, $weight));
|
||||
|
||||
$r = (int) round($fr * $w + $br * (1 - $w));
|
||||
$g = (int) round($fg * $w + $bg * (1 - $w));
|
||||
$b = (int) round($fb * $w + $bb * (1 - $w));
|
||||
|
||||
return sprintf('#%02x%02x%02x', $r, $g, $b);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: int, 2: int}
|
||||
*/
|
||||
|
|
@ -403,103 +309,11 @@ private function applyBottomGradient(ImageInterface $image, float $heightFractio
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Punch transparent corners into the image's alpha channel so it renders as
|
||||
* a rounded rectangle when copied onto another canvas.
|
||||
*/
|
||||
private function roundCorners(ImageInterface $image, int $radius): void
|
||||
private function renderFooter(ImageInterface $canvas, SocialAccount $socialAccount): ImageInterface
|
||||
{
|
||||
$core = $image->core()->native();
|
||||
$w = imagesx($core);
|
||||
$h = imagesy($core);
|
||||
|
||||
imagealphablending($core, false);
|
||||
imagesavealpha($core, true);
|
||||
$transparent = imagecolorallocatealpha($core, 0, 0, 0, 127);
|
||||
|
||||
$corners = [
|
||||
['x0' => 0, 'y0' => 0, 'cx' => $radius, 'cy' => $radius],
|
||||
['x0' => $w - $radius, 'y0' => 0, 'cx' => $w - $radius - 1, 'cy' => $radius],
|
||||
['x0' => 0, 'y0' => $h - $radius, 'cx' => $radius, 'cy' => $h - $radius - 1],
|
||||
['x0' => $w - $radius, 'y0' => $h - $radius, 'cx' => $w - $radius - 1, 'cy' => $h - $radius - 1],
|
||||
];
|
||||
|
||||
foreach ($corners as $c) {
|
||||
for ($y = $c['y0']; $y < $c['y0'] + $radius; $y++) {
|
||||
for ($x = $c['x0']; $x < $c['x0'] + $radius; $x++) {
|
||||
$dx = $x - $c['cx'];
|
||||
$dy = $y - $c['cy'];
|
||||
if ($dx * $dx + $dy * $dy > $radius * $radius) {
|
||||
imagesetpixel($core, $x, $y, $transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
imagealphablending($core, true);
|
||||
}
|
||||
|
||||
private function renderTemplateB(ImageManager $manager, string $imageData, string $title, string $body, Workspace $workspace): ImageInterface
|
||||
{
|
||||
$bgColor = $workspace->background_color ?? '#F0F4F0';
|
||||
$brandColor = $workspace->brand_color ?? '#0F4C2A';
|
||||
$textColor = $workspace->text_color ?? '#0F172A';
|
||||
|
||||
// Solid background canvas at active dimensions.
|
||||
$canvas = $manager->createImage($this->width, $this->height)->fill($bgColor);
|
||||
|
||||
$fontBold = $this->fontPath('Inter-Bold.ttf');
|
||||
$fontMedium = $this->fontPath('Inter-Medium.ttf');
|
||||
|
||||
$titleSize = 56;
|
||||
$bodySize = 28;
|
||||
$titleLineHeight = 1.25;
|
||||
$bodyLineHeight = 1.55;
|
||||
$padding = 60;
|
||||
$maxWidth = $this->width - 2 * $padding;
|
||||
$photoWidth = $this->width - 2 * $padding;
|
||||
// Photo card height scales with the canvas: ~37% of total height. This
|
||||
// keeps a comfortable text/photo balance across 1:1, 4:5 and 9:16 sizes.
|
||||
$photoHeight = (int) round($this->height * 0.37);
|
||||
$titleTopY = (int) round($this->height * 0.09);
|
||||
$gapTitleToPhoto = 60;
|
||||
$gapPhotoToBody = 60;
|
||||
|
||||
$core = $canvas->core()->native();
|
||||
|
||||
$titleLines = ($fontBold && file_exists($fontBold)) ? $this->wrapText($title, $fontBold, $titleSize, $maxWidth) : [];
|
||||
$bodyLines = ($fontMedium && file_exists($fontMedium)) ? $this->wrapText($body, $fontMedium, $bodySize, $maxWidth) : [];
|
||||
|
||||
$titleHeight = $this->measureBlockHeight($titleLines, $titleSize, $titleLineHeight);
|
||||
$photoY = $titleTopY + $titleHeight + $gapTitleToPhoto;
|
||||
$bodyTopY = $photoY + $photoHeight + $gapPhotoToBody;
|
||||
$photoX = (int) (($this->width - $photoWidth) / 2);
|
||||
|
||||
if ($titleLines) {
|
||||
$this->renderTextLines($core, $titleLines, $fontBold, $titleSize, $titleLineHeight, $brandColor, $padding, $titleTopY);
|
||||
}
|
||||
|
||||
// Photo card with rounded corners, horizontally centered.
|
||||
$photo = $manager->decodeBinary($imageData)->cover($photoWidth, $photoHeight);
|
||||
$this->roundCorners($photo, 20);
|
||||
$canvas->insert($photo, $photoX, $photoY);
|
||||
|
||||
if ($bodyLines) {
|
||||
$this->renderTextLines($core, $bodyLines, $fontMedium, $bodySize, $bodyLineHeight, $textColor, $padding, $bodyTopY);
|
||||
}
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
private function renderFooter(ImageInterface $canvas, SocialAccount $socialAccount, string $template, Workspace $workspace): ImageInterface
|
||||
{
|
||||
// Footer uses Inter Light (300) in a muted color for both @handle and display_name.
|
||||
// Template A: hardcoded slate (always on dark gradient).
|
||||
// Template B: blend the brand's text color with its background so the footer is
|
||||
// always legibly muted regardless of which palette the workspace picked.
|
||||
$footerColor = $template === 'A'
|
||||
? '#9ca3af'
|
||||
: $this->blendHex($workspace->text_color ?? '#0F172A', $workspace->background_color ?? '#F0F4F0', 0.45);
|
||||
// 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_name ?? '';
|
||||
|
|
@ -688,18 +502,6 @@ private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hex
|
|||
imagecolordeallocate($core, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized "Follow me" CTA based on the workspace's content_language.
|
||||
*/
|
||||
private function followCta(Workspace $workspace): string
|
||||
{
|
||||
return match ($workspace->content_language) {
|
||||
'pt-BR' => 'ME SIGA',
|
||||
'es' => 'SÍGUEME',
|
||||
default => 'FOLLOW ME',
|
||||
};
|
||||
}
|
||||
|
||||
private function fontPath(string $filename): ?string
|
||||
{
|
||||
$path = base_path('resources/fonts/'.$filename);
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Unsplash;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UnsplashClient
|
||||
{
|
||||
/**
|
||||
* Generic, always-available fallback keywords. We try these last so a slide
|
||||
* never ends up without a photo.
|
||||
*/
|
||||
private const array FALLBACK_KEYWORDS = ['business', 'workspace', 'abstract', 'background'];
|
||||
|
||||
/**
|
||||
* Search a single photo with progressive fallbacks so a slide never returns
|
||||
* without a photo:
|
||||
* 1. all keywords + color filter
|
||||
* 2. all keywords, no color
|
||||
* 3. only the first keyword, no color
|
||||
* 4. each generic fallback keyword in turn
|
||||
*
|
||||
* @param array<int, string> $keywords
|
||||
* @return array{id: string, url: string, alt_description: ?string}|null
|
||||
*/
|
||||
public function searchPhoto(array $keywords, string $orientation = 'portrait', ?string $colorBucket = null): ?array
|
||||
{
|
||||
$key = config('services.unsplash.access_key');
|
||||
if (! $key) {
|
||||
Log::warning('Unsplash access key not configured');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleanKeywords = array_values(array_filter(array_map('trim', $keywords)));
|
||||
|
||||
if (empty($cleanKeywords)) {
|
||||
return $this->tryFallbacks([], $orientation, $key);
|
||||
}
|
||||
|
||||
$query = implode(' ', $cleanKeywords);
|
||||
|
||||
// 1. all keywords + color
|
||||
$photo = $this->fetchOne($key, $query, $orientation, $colorBucket);
|
||||
if ($photo) {
|
||||
return $photo;
|
||||
}
|
||||
|
||||
// 2. all keywords, no color
|
||||
if ($colorBucket !== null) {
|
||||
$photo = $this->fetchOne($key, $query, $orientation, null);
|
||||
if ($photo) {
|
||||
return $photo;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. only the first keyword
|
||||
if (count($cleanKeywords) > 1) {
|
||||
$photo = $this->fetchOne($key, $cleanKeywords[0], $orientation, null);
|
||||
if ($photo) {
|
||||
return $photo;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. generic fallbacks
|
||||
return $this->tryFallbacks($cleanKeywords, $orientation, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, url: string, alt_description: ?string}|null
|
||||
*/
|
||||
private function fetchOne(string $key, string $query, string $orientation, ?string $colorBucket): ?array
|
||||
{
|
||||
if ($query === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$params = [
|
||||
'query' => $query,
|
||||
'orientation' => $orientation,
|
||||
'per_page' => 1,
|
||||
];
|
||||
if ($colorBucket) {
|
||||
$params['color'] = $colorBucket;
|
||||
}
|
||||
|
||||
$cacheKey = 'unsplash:'.md5(json_encode($params));
|
||||
|
||||
$result = Cache::get($cacheKey);
|
||||
|
||||
if ($result === null) {
|
||||
$response = Http::withHeaders(['Authorization' => 'Client-ID '.$key])
|
||||
->timeout(10)
|
||||
->get('https://api.unsplash.com/search/photos', $params);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('Unsplash search failed', ['status' => $response->status(), 'query' => $query]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
|
||||
// Only cache successful responses that actually returned results, so
|
||||
// a transient empty hit doesn't get pinned for an hour.
|
||||
if (! empty(data_get($result, 'results'))) {
|
||||
Cache::put($cacheKey, $result, now()->addHour());
|
||||
}
|
||||
}
|
||||
|
||||
$first = data_get($result, 'results.0');
|
||||
if (! $first) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => data_get($first, 'id'),
|
||||
'url' => data_get($first, 'urls.regular'),
|
||||
'alt_description' => data_get($first, 'alt_description'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each generic fallback keyword in turn.
|
||||
*
|
||||
* @param array<int, string> $skip fallback keywords to skip (already tried)
|
||||
* @return array{id: string, url: string, alt_description: ?string}|null
|
||||
*/
|
||||
private function tryFallbacks(array $skip, string $orientation, string $key): ?array
|
||||
{
|
||||
foreach (self::FALLBACK_KEYWORDS as $keyword) {
|
||||
if (in_array($keyword, $skip, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$photo = $this->fetchOne($key, $keyword, $orientation, null);
|
||||
if ($photo) {
|
||||
Log::info('Unsplash fell back to generic keyword', ['keyword' => $keyword]);
|
||||
|
||||
return $photo;
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('Unsplash exhausted all fallbacks');
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
102
app/Support/HexColorName.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Maps a hex colour to a human-readable approximate name. Image generation
|
||||
* models follow colour names ("warm orange", "deep teal") far more reliably
|
||||
* than raw hex codes, so we translate the brand colour into the closest
|
||||
* named bucket before injecting it into the prompt.
|
||||
*
|
||||
* Returns null when the hex is malformed.
|
||||
*/
|
||||
class HexColorName
|
||||
{
|
||||
public static function approximate(string $hex): ?string
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
|
||||
}
|
||||
|
||||
if (strlen($hex) === 8) {
|
||||
$hex = substr($hex, 0, 6);
|
||||
}
|
||||
|
||||
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$r = hexdec(substr($hex, 0, 2)) / 255;
|
||||
$g = hexdec(substr($hex, 2, 2)) / 255;
|
||||
$b = hexdec(substr($hex, 4, 2)) / 255;
|
||||
|
||||
[$h, $s, $l] = self::rgbToHsl($r, $g, $b);
|
||||
|
||||
// Low-saturation neutrals.
|
||||
if ($s < 0.10) {
|
||||
return match (true) {
|
||||
$l < 0.10 => 'near-black',
|
||||
$l < 0.30 => 'dark gray',
|
||||
$l < 0.70 => 'medium gray',
|
||||
$l < 0.90 => 'light gray',
|
||||
default => 'off-white',
|
||||
};
|
||||
}
|
||||
|
||||
$hue = $h * 360;
|
||||
|
||||
$base = match (true) {
|
||||
$hue < 10 || $hue >= 345 => 'red',
|
||||
$hue < 25 => 'red-orange',
|
||||
$hue < 45 => 'warm orange',
|
||||
$hue < 65 => 'golden yellow',
|
||||
$hue < 90 => 'yellow-green',
|
||||
$hue < 150 => 'green',
|
||||
$hue < 180 => 'teal',
|
||||
$hue < 210 => 'cyan',
|
||||
$hue < 240 => 'blue',
|
||||
$hue < 270 => 'indigo',
|
||||
$hue < 300 => 'purple',
|
||||
$hue < 345 => 'magenta',
|
||||
default => 'red',
|
||||
};
|
||||
|
||||
$modifier = match (true) {
|
||||
$l < 0.25 => 'deep ',
|
||||
$l > 0.75 => 'light ',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return $modifier.$base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: float, 1: float, 2: float} HSL components on a 0..1 scale.
|
||||
*/
|
||||
private static function rgbToHsl(float $r, float $g, float $b): array
|
||||
{
|
||||
$max = max($r, $g, $b);
|
||||
$min = min($r, $g, $b);
|
||||
$l = ($max + $min) / 2;
|
||||
|
||||
if ($max === $min) {
|
||||
return [0.0, 0.0, $l];
|
||||
}
|
||||
|
||||
$d = $max - $min;
|
||||
$s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min);
|
||||
|
||||
$h = match (true) {
|
||||
$max === $r => ($g - $b) / $d + ($g < $b ? 6 : 0),
|
||||
$max === $g => ($b - $r) / $d + 2,
|
||||
default => ($r - $g) / $d + 4,
|
||||
};
|
||||
$h /= 6;
|
||||
|
||||
return [$h, $s, $l];
|
||||
}
|
||||
}
|
||||
2
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "3d3e5acee3f0e8b5df1dcfab91a2c6c0",
|
||||
"content-hash": "4d71722b650e74b41e1943d3ae9c814f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@
|
|||
'default' => 50,
|
||||
'gpt-image-1.5' => 50,
|
||||
'gpt-image-1.5-hd' => 100,
|
||||
// gpt-image-2 is currently called at quality=low (~$0.01/image at 1024².
|
||||
// If we ever expose medium/high to users, add gpt-image-2-medium / -hd.
|
||||
'gpt-image-2' => 15,
|
||||
'flux-pro' => 30,
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('workspaces', function (Blueprint $table): void {
|
||||
$table->string('image_style')
|
||||
->default(ImageStyle::DEFAULT->value)
|
||||
->after('brand_font');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('workspaces', function (Blueprint $table): void {
|
||||
$table->dropColumn('image_style');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -501,8 +501,8 @@
|
|||
'prompt_title' => 'Describe your post',
|
||||
'prompt_label' => 'What is this post about?',
|
||||
'prompt_placeholder' => 'e.g. Announce our new carousel feature for Instagram',
|
||||
'preview_title' => 'Preview',
|
||||
'preview_loading' => 'Generating your content…',
|
||||
'generating_title' => 'Generating',
|
||||
'generation_loading' => 'Generating your post. This can take up to a minute.',
|
||||
'preview_error' => 'Something went wrong. Please try again.',
|
||||
'create' => 'Create post',
|
||||
'back' => 'Back',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@
|
|||
'brand' => [
|
||||
'title' => 'Brand',
|
||||
'description' => 'Configure your brand identity for AI-generated content.',
|
||||
'name' => 'Workspace name',
|
||||
'name_placeholder' => 'My brand',
|
||||
'website' => 'Website',
|
||||
'website_placeholder' => 'https://yourbrand.com',
|
||||
'brand_description' => 'Description',
|
||||
|
|
@ -163,6 +165,16 @@
|
|||
'background_color' => 'Background color',
|
||||
'text_color' => 'Text color',
|
||||
'font' => 'Font',
|
||||
'image_style' => 'Image style',
|
||||
'image_style_description' => 'Visual style applied when generating slide and cover images for AI posts.',
|
||||
'image_style_cinematic' => 'Cinematic',
|
||||
'image_style_illustration' => 'Illustration',
|
||||
'image_style_isometric_3d' => 'Isometric',
|
||||
'image_style_cartoon' => 'Cartoon',
|
||||
'image_style_typographic' => 'Typographic',
|
||||
'image_style_infographic' => 'Infographic',
|
||||
'image_style_minimalist' => 'Minimalist',
|
||||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Content language',
|
||||
'content_language_description' => 'Language used for AI-generated captions, hashtags, and any text inside generated images or videos.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -502,8 +502,8 @@
|
|||
'prompt_title' => 'Describe tu post',
|
||||
'prompt_label' => '¿De qué trata este post?',
|
||||
'prompt_placeholder' => 'Ej. Anuncia nuestra nueva función de carrusel para Instagram',
|
||||
'preview_title' => 'Vista previa',
|
||||
'preview_loading' => 'Generando tu contenido…',
|
||||
'generating_title' => 'Generando',
|
||||
'generation_loading' => 'Generando tu publicación. Esto puede tardar hasta un minuto.',
|
||||
'preview_error' => 'Algo salió mal. Por favor, inténtalo de nuevo.',
|
||||
'create' => 'Crear post',
|
||||
'back' => 'Atrás',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@
|
|||
'brand' => [
|
||||
'title' => 'Marca',
|
||||
'description' => 'Configura la identidad de tu marca para el contenido generado por IA.',
|
||||
'name' => 'Nombre del workspace',
|
||||
'name_placeholder' => 'Mi marca',
|
||||
'website' => 'Sitio web',
|
||||
'website_placeholder' => 'https://tumarca.com',
|
||||
'brand_description' => 'Descripción',
|
||||
|
|
@ -163,6 +165,16 @@
|
|||
'background_color' => 'Color de fondo',
|
||||
'text_color' => 'Color de texto',
|
||||
'font' => 'Fuente',
|
||||
'image_style' => 'Estilo de imágenes',
|
||||
'image_style_description' => 'Estilo visual aplicado al generar imágenes de diapositivas y portadas para publicaciones con IA.',
|
||||
'image_style_cinematic' => 'Cinematográfico',
|
||||
'image_style_illustration' => 'Ilustración',
|
||||
'image_style_isometric_3d' => 'Isométrico',
|
||||
'image_style_cartoon' => 'Cartoon',
|
||||
'image_style_typographic' => 'Tipográfico',
|
||||
'image_style_infographic' => 'Infográfico',
|
||||
'image_style_minimalist' => 'Minimalista',
|
||||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Idioma del contenido',
|
||||
'content_language_description' => 'Idioma usado en los subtítulos, hashtags y cualquier texto dentro de imágenes o videos generados por IA.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -501,8 +501,8 @@
|
|||
'prompt_title' => 'Descreva seu post',
|
||||
'prompt_label' => 'Sobre o que é este post?',
|
||||
'prompt_placeholder' => 'Ex. Anunciar nossa nova função de carrossel para o Instagram',
|
||||
'preview_title' => 'Prévia',
|
||||
'preview_loading' => 'Gerando seu conteúdo…',
|
||||
'generating_title' => 'Gerando',
|
||||
'generation_loading' => 'Gerando seu post. Isso pode levar até um minuto.',
|
||||
'preview_error' => 'Algo deu errado. Por favor, tente novamente.',
|
||||
'create' => 'Criar post',
|
||||
'back' => 'Voltar',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@
|
|||
'brand' => [
|
||||
'title' => 'Marca',
|
||||
'description' => 'Configure a identidade da sua marca para os conteúdos gerados por AI.',
|
||||
'name' => 'Nome do workspace',
|
||||
'name_placeholder' => 'Minha marca',
|
||||
'website' => 'Site',
|
||||
'website_placeholder' => 'https://suamarca.com',
|
||||
'brand_description' => 'Descrição',
|
||||
|
|
@ -163,6 +165,16 @@
|
|||
'background_color' => 'Cor de fundo',
|
||||
'text_color' => 'Cor do texto',
|
||||
'font' => 'Fonte',
|
||||
'image_style' => 'Estilo das imagens',
|
||||
'image_style_description' => 'Estilo visual aplicado ao gerar imagens de slides e capas para posts com AI.',
|
||||
'image_style_cinematic' => 'Cinematográfico',
|
||||
'image_style_illustration' => 'Ilustração',
|
||||
'image_style_isometric_3d' => 'Isométrico',
|
||||
'image_style_cartoon' => 'Cartoon',
|
||||
'image_style_typographic' => 'Tipográfico',
|
||||
'image_style_infographic' => 'Infográfico',
|
||||
'image_style_minimalist' => 'Minimalista',
|
||||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Idioma do conteúdo',
|
||||
'content_language_description' => 'Idioma usado nas legendas, hashtags e em qualquer texto dentro de imagens ou vídeos gerados por AI.',
|
||||
],
|
||||
|
|
|
|||
6
package-lock.json
generated
|
|
@ -8603,9 +8603,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
|
|
|||
BIN
public/images/branding/image-styles/cartoon.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/images/branding/image-styles/cinematic.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/images/branding/image-styles/illustration.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/images/branding/image-styles/infographic.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/images/branding/image-styles/isometric_3d.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/images/branding/image-styles/minimalist.webp
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
public/images/branding/image-styles/mockup.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/images/branding/image-styles/typographic.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
291
resources/js/components/BrandForm.vue
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
<script setup lang="ts">
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { IconCheck, IconLoader2, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import FontPicker from '@/components/FontPicker.vue';
|
||||
import HexColorInput from '@/components/HexColorInput.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { autofill as autofillBrand } from '@/routes/app/workspaces';
|
||||
|
||||
interface BrandFields {
|
||||
name?: string;
|
||||
brand_website: string;
|
||||
brand_description: string;
|
||||
brand_tone: string;
|
||||
brand_voice_notes: string;
|
||||
brand_color: string | null;
|
||||
background_color: string | null;
|
||||
text_color: string | null;
|
||||
brand_font: string;
|
||||
image_style: string;
|
||||
content_language: string;
|
||||
logo_url?: string | null;
|
||||
}
|
||||
|
||||
interface AutofillResponse {
|
||||
name: string | null;
|
||||
brand_description: string | null;
|
||||
content_language: string | null;
|
||||
brand_tone: string | null;
|
||||
brand_voice_notes: string | null;
|
||||
brand_color: string | null;
|
||||
background_color: string | null;
|
||||
text_color: string | null;
|
||||
logo_url: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
fields: BrandFields;
|
||||
errors: Partial<Record<keyof BrandFields, string>>;
|
||||
availableFonts: string[];
|
||||
availableImageStyles: string[];
|
||||
autofill?: boolean;
|
||||
showName?: boolean;
|
||||
}>(),
|
||||
{
|
||||
autofill: false,
|
||||
showName: false,
|
||||
},
|
||||
);
|
||||
|
||||
const autofillHttp = useHttp<{ url: string }, AutofillResponse>({ url: '' });
|
||||
const isAutofilling = ref(false);
|
||||
const logoPreview = ref<string | null>(null);
|
||||
|
||||
const toneLabel = computed(() =>
|
||||
props.fields.brand_tone ? trans(`settings.brand.tone_${props.fields.brand_tone}`) : '',
|
||||
);
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
en: 'English',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
es: 'Español',
|
||||
};
|
||||
return map[props.fields.content_language] ?? '';
|
||||
});
|
||||
|
||||
const runAutofill = async () => {
|
||||
const url = props.fields.brand_website?.trim() ?? '';
|
||||
if (!url) {
|
||||
toast.error(trans('workspaces.create.autofill_missing_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
isAutofilling.value = true;
|
||||
try {
|
||||
autofillHttp.url = url;
|
||||
const data = await autofillHttp.post(autofillBrand.url());
|
||||
|
||||
if (data?.name && props.showName && !props.fields.name) props.fields.name = data.name;
|
||||
if (data?.brand_description) props.fields.brand_description = data.brand_description;
|
||||
if (data?.content_language) props.fields.content_language = data.content_language;
|
||||
if (data?.brand_tone) props.fields.brand_tone = data.brand_tone;
|
||||
if (data?.brand_voice_notes) props.fields.brand_voice_notes = data.brand_voice_notes;
|
||||
if (data?.brand_color) props.fields.brand_color = data.brand_color;
|
||||
if (data?.background_color) props.fields.background_color = data.background_color;
|
||||
if (data?.text_color) props.fields.text_color = data.text_color;
|
||||
if (data?.logo_url) {
|
||||
logoPreview.value = data.logo_url;
|
||||
if ('logo_url' in props.fields) {
|
||||
props.fields.logo_url = data.logo_url;
|
||||
}
|
||||
}
|
||||
toast.success(trans('workspaces.create.autofill_success'));
|
||||
} catch {
|
||||
toast.error(trans('workspaces.create.autofill_error'));
|
||||
} finally {
|
||||
isAutofilling.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
|
||||
<div :class="autofill ? 'flex gap-2' : ''">
|
||||
<Input
|
||||
id="brand_website"
|
||||
v-model="fields.brand_website"
|
||||
type="url"
|
||||
:placeholder="$t('settings.brand.website_placeholder')"
|
||||
:class="autofill ? 'flex-1' : ''"
|
||||
/>
|
||||
<Button
|
||||
v-if="autofill"
|
||||
type="button"
|
||||
variant="default"
|
||||
:disabled="isAutofilling || !fields.brand_website"
|
||||
@click="runAutofill"
|
||||
>
|
||||
<IconLoader2 v-if="isAutofilling" class="size-4 animate-spin" />
|
||||
<IconSparkles v-else class="size-4" />
|
||||
{{ $t('workspaces.create.autofill') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="autofill && logoPreview" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<img :src="logoPreview" alt="" class="h-6 w-6 rounded object-cover" />
|
||||
{{ $t('workspaces.create.logo_captured') }}
|
||||
</p>
|
||||
<InputError :message="errors.brand_website" />
|
||||
</div>
|
||||
|
||||
<div v-if="showName" class="grid gap-2">
|
||||
<Label for="name">{{ $t('settings.brand.name') }}</Label>
|
||||
<Input id="name" v-model="fields.name" :placeholder="$t('settings.brand.name_placeholder')" />
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_description">{{ $t('settings.brand.brand_description') }}</Label>
|
||||
<Textarea
|
||||
id="brand_description"
|
||||
v-model="fields.brand_description"
|
||||
:placeholder="$t('settings.brand.brand_description_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="errors.brand_description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
|
||||
<Select v-model="fields.brand_tone">
|
||||
<SelectTrigger id="brand_tone" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.tone')">
|
||||
{{ toneLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
|
||||
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
|
||||
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
|
||||
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
|
||||
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
|
||||
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
|
||||
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="errors.brand_tone" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
|
||||
<Select v-model="fields.content_language">
|
||||
<SelectTrigger id="content_language" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.content_language')">
|
||||
{{ languageLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="errors.content_language" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="-mt-4 text-xs font-medium text-foreground/60">
|
||||
{{ $t('settings.brand.content_language_description') }}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
|
||||
<Textarea
|
||||
id="brand_voice_notes"
|
||||
v-model="fields.brand_voice_notes"
|
||||
:placeholder="$t('settings.brand.voice_notes_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="errors.brand_voice_notes" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_color">{{ $t('settings.brand.brand_color') }}</Label>
|
||||
<HexColorInput v-model="fields.brand_color" />
|
||||
<InputError :message="errors.brand_color" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="background_color">{{ $t('settings.brand.background_color') }}</Label>
|
||||
<HexColorInput v-model="fields.background_color" />
|
||||
<InputError :message="errors.background_color" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="text_color">{{ $t('settings.brand.text_color') }}</Label>
|
||||
<HexColorInput v-model="fields.text_color" />
|
||||
<InputError :message="errors.text_color" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_font">{{ $t('settings.brand.font') }}</Label>
|
||||
<FontPicker v-model="fields.brand_font" :fonts="availableFonts" />
|
||||
<InputError :message="errors.brand_font" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label>{{ $t('settings.brand.image_style') }}</Label>
|
||||
<p class="text-xs font-medium text-foreground/60">
|
||||
{{ $t('settings.brand.image_style_description') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="style in availableImageStyles"
|
||||
:key="style"
|
||||
type="button"
|
||||
:aria-pressed="fields.image_style === style"
|
||||
:class="[
|
||||
'group relative flex flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card text-left shadow-sm transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
fields.image_style === style
|
||||
? '-translate-y-0.5 shadow-md'
|
||||
: 'hover:-translate-y-0.5 hover:shadow-md',
|
||||
]"
|
||||
@click="fields.image_style = style"
|
||||
>
|
||||
<div class="relative aspect-square w-full overflow-hidden border-b-2 border-foreground bg-muted">
|
||||
<img
|
||||
:src="`/images/branding/image-styles/${style}.webp`"
|
||||
:alt="$t(`settings.brand.image_style_${style}`)"
|
||||
class="size-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-if="fields.image_style === style"
|
||||
class="absolute right-2 top-2 flex size-7 items-center justify-center rounded-full border-2 border-foreground bg-primary text-primary-foreground shadow"
|
||||
>
|
||||
<IconCheck class="size-4" stroke-width="3" />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'block truncate px-3 py-2 text-center text-sm font-semibold',
|
||||
fields.image_style === style ? 'bg-foreground text-background' : 'bg-card text-foreground',
|
||||
]"
|
||||
>
|
||||
{{ $t(`settings.brand.image_style_${style}`) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<InputError :message="errors.image_style" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -71,6 +71,8 @@ interface PickedMedia {
|
|||
original_filename?: string;
|
||||
size?: number;
|
||||
meta?: { width?: number; height?: number; duration?: number };
|
||||
source?: 'ai' | 'unsplash' | 'giphy';
|
||||
source_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -357,7 +359,7 @@ const saveAndPickUnsplash = async (photo: UnsplashPhoto) => {
|
|||
if (!media) return;
|
||||
|
||||
if (isPicker.value) {
|
||||
toggleSelect(media);
|
||||
toggleSelect(media, { source: 'unsplash', source_meta: { photo_id: photo.id } });
|
||||
} else {
|
||||
toast.success(trans('assets.saved'));
|
||||
await loadUploadsFirstPage();
|
||||
|
|
@ -376,7 +378,15 @@ const createPostFromUnsplash = async (photo: UnsplashPhoto) => {
|
|||
return;
|
||||
}
|
||||
router.post(storePost.url(), {
|
||||
media: [{ id: media.id, path: media.path, url: media.url, type: media.type, mime_type: media.mime_type }],
|
||||
media: [{
|
||||
id: media.id,
|
||||
path: media.path,
|
||||
url: media.url,
|
||||
type: media.type,
|
||||
mime_type: media.mime_type,
|
||||
source: 'unsplash',
|
||||
source_meta: { photo_id: photo.id },
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -485,7 +495,7 @@ const saveAndPickGiphy = async (gif: GiphyGif) => {
|
|||
if (!media) return;
|
||||
|
||||
if (isPicker.value) {
|
||||
toggleSelect(media);
|
||||
toggleSelect(media, { source: 'giphy', source_meta: { gif_id: gif.id } });
|
||||
} else {
|
||||
toast.success(trans('assets.saved'));
|
||||
await loadUploadsFirstPage();
|
||||
|
|
@ -503,7 +513,15 @@ const createPostFromGiphy = async (gif: GiphyGif) => {
|
|||
return;
|
||||
}
|
||||
router.post(storePost.url(), {
|
||||
media: [{ id: media.id, path: media.path, url: media.url, type: media.type, mime_type: media.mime_type }],
|
||||
media: [{
|
||||
id: media.id,
|
||||
path: media.path,
|
||||
url: media.url,
|
||||
type: media.type,
|
||||
mime_type: media.mime_type,
|
||||
source: 'giphy',
|
||||
source_meta: { gif_id: gif.id },
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ import {
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { finalize as finalizeRoute, start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCreateController';
|
||||
import { start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCreateController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { ContentType, type ContentTypeValue } from '@/enums/content-type';
|
||||
import { edit as editPostRoute } from '@/routes/app/posts';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -36,7 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
date: null,
|
||||
});
|
||||
|
||||
type WizardStep = 'configure' | 'preview';
|
||||
type WizardStep = 'configure' | 'generating';
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Parent mirrors this in the PageHeader for context. */
|
||||
|
|
@ -54,15 +54,10 @@ const includeImages = ref(true);
|
|||
const imageCount = ref(2);
|
||||
const promptText = ref('');
|
||||
|
||||
// Preview state
|
||||
// Generation state
|
||||
const submitting = ref(false);
|
||||
const finalizing = ref(false);
|
||||
const previewStatus = ref<'loading' | 'done' | 'error'>('loading');
|
||||
const previewContent = ref('');
|
||||
const previewImageTitle = ref('');
|
||||
const previewImageBody = ref('');
|
||||
const previewError = ref('');
|
||||
const previewCreationId = ref<string | null>(null);
|
||||
const generationStatus = ref<'loading' | 'error'>('loading');
|
||||
const generationError = ref('');
|
||||
let echoChannel: any = null;
|
||||
let subscribedChannelName: string | null = null;
|
||||
|
||||
|
|
@ -74,12 +69,6 @@ const httpStart = useHttp<{
|
|||
date: string | null;
|
||||
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null });
|
||||
|
||||
const httpFinalize = useHttp<{ content: string; image_title: string; image_body: string }>({
|
||||
content: '',
|
||||
image_title: '',
|
||||
image_body: '',
|
||||
});
|
||||
|
||||
const AI_FORMATS: Array<{ value: ContentTypeValue; platforms: string[] }> = [
|
||||
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
|
||||
{ value: ContentType.InstagramCarousel, platforms: ['instagram', 'instagram-facebook'] },
|
||||
|
|
@ -191,9 +180,9 @@ const stepHeaderFor = (s: WizardStep) => {
|
|||
title: trans('posts.create.ai_title'),
|
||||
description: trans('posts.create.ai_configure_description'),
|
||||
};
|
||||
case 'preview':
|
||||
case 'generating':
|
||||
return {
|
||||
title: trans('posts.create.steps.preview_title'),
|
||||
title: trans('posts.create.steps.generating_title'),
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
|
|
@ -209,12 +198,12 @@ emit('update:stepHeader', stepHeaderFor(step.value));
|
|||
const goBack = () => {
|
||||
if (step.value === 'configure') {
|
||||
emit('cancel');
|
||||
} else if (step.value === 'preview') {
|
||||
} else if (step.value === 'generating') {
|
||||
unsubscribeEcho();
|
||||
goToStep('configure');
|
||||
}
|
||||
};
|
||||
|
||||
// Echo subscription for AI streaming
|
||||
const unsubscribeEcho = () => {
|
||||
if (echoChannel && subscribedChannelName) {
|
||||
echo().leave(`private-${subscribedChannelName}`);
|
||||
|
|
@ -225,21 +214,17 @@ const unsubscribeEcho = () => {
|
|||
|
||||
const subscribeToCreation = (userId: string, creationId: string) => {
|
||||
unsubscribeEcho();
|
||||
previewCreationId.value = creationId;
|
||||
const channelName = `users.${userId}.ai-creation.${creationId}`;
|
||||
subscribedChannelName = channelName;
|
||||
|
||||
echoChannel = echo().private(channelName).listen('.ai.creation.completed', (e: any) => {
|
||||
if (e.error) {
|
||||
previewStatus.value = 'error';
|
||||
previewError.value = e.error;
|
||||
} else {
|
||||
previewContent.value = e.content ?? '';
|
||||
previewImageTitle.value = e.image_title ?? '';
|
||||
previewImageBody.value = e.image_body ?? '';
|
||||
previewStatus.value = 'done';
|
||||
}
|
||||
unsubscribeEcho();
|
||||
if (e.error || !e.post_id) {
|
||||
generationStatus.value = 'error';
|
||||
generationError.value = e.error ?? trans('posts.create.steps.preview_error');
|
||||
return;
|
||||
}
|
||||
router.visit(editPostRoute(e.post_id).url);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -247,12 +232,9 @@ const startGeneration = async () => {
|
|||
if (!canSubmit.value || submitting.value) return;
|
||||
|
||||
submitting.value = true;
|
||||
previewStatus.value = 'loading';
|
||||
previewContent.value = '';
|
||||
previewImageTitle.value = '';
|
||||
previewImageBody.value = '';
|
||||
previewError.value = '';
|
||||
goToStep('preview');
|
||||
generationStatus.value = 'loading';
|
||||
generationError.value = '';
|
||||
goToStep('generating');
|
||||
|
||||
httpStart.format = selectedFormat.value;
|
||||
httpStart.social_account_id = selectedAccountId.value;
|
||||
|
|
@ -265,8 +247,8 @@ const startGeneration = async () => {
|
|||
const userId = data.channel.split('.')[1] ?? '';
|
||||
subscribeToCreation(userId, data.creation_id);
|
||||
} catch (err: any) {
|
||||
previewStatus.value = 'error';
|
||||
previewError.value = err?.response?.data?.message ?? trans('posts.create.steps.preview_error');
|
||||
generationStatus.value = 'error';
|
||||
generationError.value = err?.response?.data?.message ?? trans('posts.create.steps.preview_error');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
|
@ -274,25 +256,6 @@ const startGeneration = async () => {
|
|||
|
||||
const retryGeneration = () => startGeneration();
|
||||
|
||||
const createPost = async () => {
|
||||
if (!previewCreationId.value || finalizing.value) return;
|
||||
finalizing.value = true;
|
||||
|
||||
httpFinalize.content = previewContent.value;
|
||||
httpFinalize.image_title = previewImageTitle.value;
|
||||
httpFinalize.image_body = previewImageBody.value;
|
||||
|
||||
try {
|
||||
const data = await httpFinalize.post(finalizeRoute.url(previewCreationId.value)) as { redirect_url: string };
|
||||
router.visit(data.redirect_url);
|
||||
} catch {
|
||||
previewStatus.value = 'error';
|
||||
previewError.value = trans('posts.create.steps.preview_error');
|
||||
} finally {
|
||||
finalizing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => unsubscribeEcho());
|
||||
</script>
|
||||
|
||||
|
|
@ -300,6 +263,7 @@ onUnmounted(() => unsubscribeEcho());
|
|||
<div class="space-y-6">
|
||||
<!-- Back button — sticker arrow + ink label, mirrors the marketing site -->
|
||||
<button
|
||||
v-if="step !== 'generating' || generationStatus === 'error'"
|
||||
type="button"
|
||||
class="group inline-flex cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground/70 transition-colors hover:text-foreground"
|
||||
@click="goBack"
|
||||
|
|
@ -428,18 +392,18 @@ onUnmounted(() => unsubscribeEcho());
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ====== Step 2: Preview ====== -->
|
||||
<template v-else-if="step === 'preview'">
|
||||
<div v-if="previewStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-2xl border-2 border-foreground bg-card py-16 text-center shadow-2xs">
|
||||
<!-- ====== Step 2: Generating ====== -->
|
||||
<template v-else-if="step === 'generating'">
|
||||
<div v-if="generationStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-2xl border-2 border-foreground bg-card py-16 text-center shadow-2xs">
|
||||
<div class="inline-flex size-12 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
|
||||
<IconLoader2 class="size-6 animate-spin text-foreground" stroke-width="2" />
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-foreground/70">{{ $t('posts.create.steps.preview_loading') }}</p>
|
||||
<p class="text-sm font-semibold text-foreground/70">{{ $t('posts.create.steps.generation_loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewStatus === 'error'" class="space-y-4">
|
||||
<div v-else class="space-y-4">
|
||||
<div class="rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
|
||||
<p class="text-sm font-semibold text-rose-700">{{ previewError || $t('posts.create.steps.preview_error') }}</p>
|
||||
<p class="text-sm font-semibold text-rose-700">{{ generationError || $t('posts.create.steps.preview_error') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
|
|
@ -449,38 +413,6 @@ onUnmounted(() => unsubscribeEcho());
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewStatus === 'done'" class="space-y-4">
|
||||
<!-- Caption-less formats (Stories): edit title + body separately. -->
|
||||
<div v-if="!supportsCaption" class="space-y-3 rounded-2xl border-2 border-foreground bg-card p-5 shadow-2xs">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_title') }}</Label>
|
||||
<Input v-model="previewImageTitle" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_body') }}</Label>
|
||||
<Textarea v-model="previewImageBody" class="min-h-[120px] resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default: edit caption text. -->
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="previewContent"
|
||||
class="min-h-[200px] resize-none p-5 text-sm leading-relaxed"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="retryGeneration">
|
||||
<IconRefresh class="size-4" />
|
||||
{{ $t('posts.create.steps.retry') }}
|
||||
</Button>
|
||||
<Button :disabled="finalizing" @click="createPost">
|
||||
<IconLoader2 v-if="finalizing" class="size-4 animate-spin" />
|
||||
{{ $t('posts.create.steps.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
|
||||
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
|
||||
import FontPicker from '@/components/FontPicker.vue';
|
||||
import BrandForm from '@/components/BrandForm.vue';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import HexColorInput from '@/components/HexColorInput.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
|
|
@ -31,156 +17,49 @@ interface Workspace {
|
|||
background_color: string | null;
|
||||
text_color: string | null;
|
||||
brand_font: string;
|
||||
image_style: string;
|
||||
content_language: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
workspace: Workspace;
|
||||
availableFonts: string[];
|
||||
availableImageStyles: string[];
|
||||
}>();
|
||||
|
||||
const brandTone = ref(props.workspace.brand_tone ?? 'professional');
|
||||
const contentLanguage = ref(props.workspace.content_language ?? 'en');
|
||||
const brandColor = ref<string | null>(props.workspace.brand_color);
|
||||
const backgroundColor = ref<string | null>(props.workspace.background_color);
|
||||
const textColor = ref<string | null>(props.workspace.text_color);
|
||||
const brandFont = ref<string>(props.workspace.brand_font ?? 'Inter');
|
||||
|
||||
const toneLabel = computed(() =>
|
||||
brandTone.value ? trans(`settings.brand.tone_${brandTone.value}`) : '',
|
||||
);
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
en: 'English',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
es: 'Español',
|
||||
};
|
||||
return map[contentLanguage.value] ?? '';
|
||||
const form = useForm({
|
||||
name: props.workspace.name,
|
||||
brand_website: props.workspace.brand_website ?? '',
|
||||
brand_description: props.workspace.brand_description ?? '',
|
||||
brand_tone: props.workspace.brand_tone ?? 'professional',
|
||||
brand_voice_notes: props.workspace.brand_voice_notes ?? '',
|
||||
brand_color: props.workspace.brand_color,
|
||||
background_color: props.workspace.background_color,
|
||||
text_color: props.workspace.text_color,
|
||||
brand_font: props.workspace.brand_font ?? 'Inter',
|
||||
image_style: props.workspace.image_style ?? 'cinematic',
|
||||
content_language: props.workspace.content_language ?? 'en',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.put(WorkspaceController.updateSettings.url());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<form class="flex flex-col space-y-6" @submit.prevent="submit">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.brand.title')"
|
||||
:description="$t('settings.brand.description')"
|
||||
/>
|
||||
|
||||
<Form
|
||||
v-bind="WorkspaceController.updateSettings.form()"
|
||||
v-slot="{ errors, processing }"
|
||||
class="space-y-6"
|
||||
>
|
||||
<input type="hidden" name="name" :value="workspace.name" />
|
||||
<BrandForm
|
||||
:fields="form"
|
||||
:errors="form.errors"
|
||||
:available-fonts="availableFonts"
|
||||
:available-image-styles="availableImageStyles"
|
||||
/>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
|
||||
<Input
|
||||
id="brand_website"
|
||||
name="brand_website"
|
||||
type="url"
|
||||
:default-value="workspace.brand_website ?? ''"
|
||||
:placeholder="$t('settings.brand.website_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.brand_website" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_description">{{ $t('settings.brand.brand_description') }}</Label>
|
||||
<Textarea
|
||||
id="brand_description"
|
||||
name="brand_description"
|
||||
:default-value="workspace.brand_description ?? ''"
|
||||
:placeholder="$t('settings.brand.brand_description_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="errors.brand_description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
|
||||
<Select v-model="brandTone" name="brand_tone">
|
||||
<SelectTrigger id="brand_tone" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.tone')">
|
||||
{{ toneLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
|
||||
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
|
||||
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
|
||||
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
|
||||
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
|
||||
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
|
||||
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="brand_tone" :value="brandTone" />
|
||||
<InputError :message="errors.brand_tone" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
|
||||
<Select v-model="contentLanguage" name="content_language">
|
||||
<SelectTrigger id="content_language" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.content_language')">
|
||||
{{ languageLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="content_language" :value="contentLanguage" />
|
||||
<InputError :message="errors.content_language" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="-mt-4 text-xs font-medium text-foreground/60">
|
||||
{{ $t('settings.brand.content_language_description') }}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_color">{{ $t('settings.brand.brand_color') }}</Label>
|
||||
<HexColorInput v-model="brandColor" name="brand_color" />
|
||||
<InputError :message="errors.brand_color" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="background_color">{{ $t('settings.brand.background_color') }}</Label>
|
||||
<HexColorInput v-model="backgroundColor" name="background_color" />
|
||||
<InputError :message="errors.background_color" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="text_color">{{ $t('settings.brand.text_color') }}</Label>
|
||||
<HexColorInput v-model="textColor" name="text_color" />
|
||||
<InputError :message="errors.text_color" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_font">{{ $t('settings.brand.font') }}</Label>
|
||||
<FontPicker v-model="brandFont" name="brand_font" :fonts="availableFonts" />
|
||||
<InputError :message="errors.brand_font" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
|
||||
<Textarea
|
||||
id="brand_voice_notes"
|
||||
name="brand_voice_notes"
|
||||
:default-value="workspace.brand_voice_notes ?? ''"
|
||||
:placeholder="$t('settings.brand.voice_notes_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="errors.brand_voice_notes" />
|
||||
</div>
|
||||
|
||||
<Button :disabled="processing">{{ $t('settings.workspace.save') }}</Button>
|
||||
</Form>
|
||||
</div>
|
||||
<Button :disabled="form.processing">{{ $t('settings.workspace.save') }}</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const platforms = [
|
|||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="w-full max-w-lg">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<h1 v-if="title" class="text-2xl font-bold">{{ title }}</h1>
|
||||
|
|
@ -112,7 +112,7 @@ const platforms = [
|
|||
</div>
|
||||
|
||||
<div
|
||||
class="relative hidden overflow-hidden border-l-2 border-foreground bg-accent lg:block"
|
||||
class="relative hidden overflow-hidden border-l-2 border-foreground bg-accent lg:sticky lg:top-0 lg:block lg:h-svh lg:self-start"
|
||||
@mouseenter="isPaused = true"
|
||||
@mouseleave="isPaused = false"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ interface Workspace {
|
|||
background_color: string | null;
|
||||
text_color: string | null;
|
||||
brand_font: string;
|
||||
image_style: string;
|
||||
content_language: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
workspace: Workspace;
|
||||
availableFonts: string[];
|
||||
availableImageStyles: string[];
|
||||
}>();
|
||||
|
||||
const tabs = computed(() => [
|
||||
|
|
@ -50,7 +52,11 @@ const tabs = computed(() => [
|
|||
|
||||
<SettingsTabsNav :tabs="tabs" active="brand" />
|
||||
|
||||
<BrandTab :workspace="workspace" :available-fonts="availableFonts" />
|
||||
<BrandTab
|
||||
:workspace="workspace"
|
||||
:available-fonts="availableFonts"
|
||||
:available-image-styles="availableImageStyles"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, useForm, useHttp } from '@inertiajs/vue3';
|
||||
import { IconLoader2, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
|
||||
import HexColorInput from '@/components/HexColorInput.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import BrandForm from '@/components/BrandForm.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { autofill as autofillBrand, store as storeWorkspace } from '@/routes/app/workspaces';
|
||||
import { store as storeWorkspace } from '@/routes/app/workspaces';
|
||||
|
||||
defineProps<{
|
||||
availableFonts: string[];
|
||||
availableImageStyles: string[];
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
|
|
@ -30,81 +20,15 @@ const form = useForm({
|
|||
brand_color: null as string | null,
|
||||
background_color: null as string | null,
|
||||
text_color: null as string | null,
|
||||
brand_font: 'Inter',
|
||||
image_style: 'cinematic',
|
||||
content_language: 'en',
|
||||
logo_url: '' as string | null,
|
||||
});
|
||||
|
||||
const isAutofilling = ref(false);
|
||||
const logoPreview = ref<string | null>(null);
|
||||
|
||||
interface AutofillResponse {
|
||||
name: string | null;
|
||||
brand_description: string | null;
|
||||
content_language: string | null;
|
||||
brand_tone: string | null;
|
||||
brand_voice_notes: string | null;
|
||||
brand_color: string | null;
|
||||
background_color: string | null;
|
||||
text_color: string | null;
|
||||
logo_url: string | null;
|
||||
}
|
||||
|
||||
const autofillHttp = useHttp<{ url: string }, AutofillResponse>({ url: '' });
|
||||
|
||||
const toneLabel = computed(() =>
|
||||
form.brand_tone ? trans(`workspaces.create.tone_${form.brand_tone}`) : '',
|
||||
);
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
en: 'English',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
es: 'Español',
|
||||
};
|
||||
return map[form.content_language] ?? '';
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(storeWorkspace.url());
|
||||
};
|
||||
|
||||
const runAutofill = async () => {
|
||||
const url = form.brand_website.trim();
|
||||
|
||||
if (! url) {
|
||||
toast.error(trans('workspaces.create.autofill_missing_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
isAutofilling.value = true;
|
||||
|
||||
try {
|
||||
autofillHttp.url = url;
|
||||
|
||||
const data = await autofillHttp.post(autofillBrand.url());
|
||||
|
||||
if (data?.name) form.name = data.name;
|
||||
if (data?.brand_description) form.brand_description = data.brand_description;
|
||||
if (data?.content_language) form.content_language = data.content_language;
|
||||
if (data?.brand_tone) form.brand_tone = data.brand_tone;
|
||||
if (data?.brand_voice_notes) form.brand_voice_notes = data.brand_voice_notes;
|
||||
if (data?.brand_color) form.brand_color = data.brand_color;
|
||||
if (data?.background_color) form.background_color = data.background_color;
|
||||
if (data?.text_color) form.text_color = data.text_color;
|
||||
|
||||
if (data?.logo_url) {
|
||||
form.logo_url = data.logo_url;
|
||||
logoPreview.value = data.logo_url;
|
||||
}
|
||||
|
||||
toast.success(trans('workspaces.create.autofill_success'));
|
||||
} catch (error) {
|
||||
const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(message ?? trans('workspaces.create.autofill_error'));
|
||||
} finally {
|
||||
isAutofilling.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -114,128 +38,15 @@ const runAutofill = async () => {
|
|||
:title="$t('workspaces.create.title')"
|
||||
:description="$t('workspaces.create.description')"
|
||||
>
|
||||
<form class="flex flex-col gap-5" @submit.prevent="submit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_website">{{ $t('workspaces.create.website') }}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="brand_website"
|
||||
v-model="form.brand_website"
|
||||
type="url"
|
||||
:placeholder="$t('workspaces.create.website_placeholder')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
:disabled="isAutofilling || !form.brand_website"
|
||||
@click="runAutofill"
|
||||
>
|
||||
<IconLoader2 v-if="isAutofilling" class="h-4 w-4 animate-spin" />
|
||||
<IconSparkles v-else class="h-4 w-4" />
|
||||
{{ $t('workspaces.create.autofill') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="logoPreview" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<img :src="logoPreview" alt="" class="h-6 w-6 rounded object-cover" />
|
||||
{{ $t('workspaces.create.logo_captured') }}
|
||||
</p>
|
||||
<InputError :message="form.errors.brand_website" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">{{ $t('workspaces.create.name') }}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
:placeholder="$t('workspaces.create.name_placeholder')"
|
||||
/>
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_description">{{ $t('workspaces.create.brand_description') }}</Label>
|
||||
<Textarea
|
||||
id="brand_description"
|
||||
v-model="form.brand_description"
|
||||
:placeholder="$t('workspaces.create.brand_description_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="form.errors.brand_description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_tone">{{ $t('workspaces.create.tone') }}</Label>
|
||||
<Select v-model="form.brand_tone">
|
||||
<SelectTrigger id="brand_tone" class="w-full">
|
||||
<SelectValue :placeholder="$t('workspaces.create.tone')">
|
||||
{{ toneLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="professional">{{ $t('workspaces.create.tone_professional') }}</SelectItem>
|
||||
<SelectItem value="casual">{{ $t('workspaces.create.tone_casual') }}</SelectItem>
|
||||
<SelectItem value="friendly">{{ $t('workspaces.create.tone_friendly') }}</SelectItem>
|
||||
<SelectItem value="bold">{{ $t('workspaces.create.tone_bold') }}</SelectItem>
|
||||
<SelectItem value="inspirational">{{ $t('workspaces.create.tone_inspirational') }}</SelectItem>
|
||||
<SelectItem value="humorous">{{ $t('workspaces.create.tone_humorous') }}</SelectItem>
|
||||
<SelectItem value="educational">{{ $t('workspaces.create.tone_educational') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="form.errors.brand_tone" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="content_language">{{ $t('workspaces.create.content_language') }}</Label>
|
||||
<Select v-model="form.content_language">
|
||||
<SelectTrigger id="content_language" class="w-full">
|
||||
<SelectValue :placeholder="$t('workspaces.create.content_language')">
|
||||
{{ languageLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="form.errors.content_language" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="-mt-2 text-xs text-muted-foreground">
|
||||
{{ $t('workspaces.create.content_language_description') }}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_color">{{ $t('workspaces.create.brand_color') }}</Label>
|
||||
<HexColorInput v-model="form.brand_color" />
|
||||
<InputError :message="form.errors.brand_color" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="background_color">{{ $t('workspaces.create.background_color') }}</Label>
|
||||
<HexColorInput v-model="form.background_color" />
|
||||
<InputError :message="form.errors.background_color" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="text_color">{{ $t('workspaces.create.text_color') }}</Label>
|
||||
<HexColorInput v-model="form.text_color" />
|
||||
<InputError :message="form.errors.text_color" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_voice_notes">{{ $t('workspaces.create.voice_notes') }}</Label>
|
||||
<Textarea
|
||||
id="brand_voice_notes"
|
||||
v-model="form.brand_voice_notes"
|
||||
:placeholder="$t('workspaces.create.voice_notes_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="form.errors.brand_voice_notes" />
|
||||
</div>
|
||||
<form class="flex flex-col space-y-6" @submit.prevent="submit">
|
||||
<BrandForm
|
||||
:fields="form"
|
||||
:errors="form.errors"
|
||||
:available-fonts="availableFonts"
|
||||
:available-image-styles="availableImageStyles"
|
||||
:autofill="true"
|
||||
:show-name="true"
|
||||
/>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="form.processing">
|
||||
{{ $t('workspaces.create.submit') }}
|
||||
|
|
|
|||
|
|
@ -61,9 +61,24 @@
|
|||
CRITICAL: The `slides` array MUST contain exactly {{ $slide_count ?? 1 }} items — no fewer, no more. Count carefully before responding. Each slide object must have:
|
||||
- `title`: a short, impactful headline for that slide (in {{ $content_language ?? 'en' }})
|
||||
- `body`: 1-3 sentences of supporting text (in {{ $content_language ?? 'en' }})
|
||||
- `image_keywords`: 2-4 keywords for an Unsplash image search.
|
||||
- `image_keywords`: 2-4 words describing a CONCRETE VISUAL SCENE for an Unsplash image search.
|
||||
Think like an art director, not a copywriter. Describe what should literally be IN the photo: physical objects, specific settings, people doing specific things, lighting, mood. Avoid abstract concepts (Unsplash is a photo library — it can't return "growth" or "innovation", only photos of things).
|
||||
|
||||
GOOD (concrete scenes a photographer could shoot):
|
||||
- `["person typing laptop", "coffee shop morning"]`
|
||||
- `["modern minimal desk", "plant natural light"]`
|
||||
- `["whiteboard team meeting", "collaboration"]`
|
||||
- `["sunrise mountain hiker", "silhouette"]`
|
||||
- `["empty notebook", "pen wooden table"]`
|
||||
|
||||
BAD (abstract concepts → returns generic stock):
|
||||
- `["growth strategy", "business success"]`
|
||||
- `["productivity mindset", "innovation"]`
|
||||
- `["leadership", "vision"]`
|
||||
- `["happiness", "motivation"]`
|
||||
|
||||
ALWAYS write these in English, even when content_language is not 'en'. Unsplash's search index is English-only — Portuguese/Spanish queries return poor results.
|
||||
Example for a pt-BR post: `["calendar", "team meeting"]`, NOT `["calendário", "reunião de equipe"]`.
|
||||
Example for a pt-BR post about productivity: `["person typing laptop", "coffee shop morning"]`, NOT `["produtividade", "trabalho"]`.
|
||||
|
||||
Plan the {{ $slide_count ?? 1 }}-slide narrative arc first (intro → development → conclusion or hook → points → CTA), then write each slide. The caption should tease the carousel content and encourage swiping.
|
||||
@else
|
||||
|
|
@ -71,7 +86,22 @@
|
|||
- `content`: the full post caption in {{ $content_language ?? 'en' }} (no preamble, no quotation marks). This is what gets published.
|
||||
- `image_title`: a short headline (5-12 words) in {{ $content_language ?? 'en' }} that will be overlaid on the image. Make it a hook that stops the scroll. Do NOT just copy the first sentence of content — write something punchier.
|
||||
- `image_body`: 1-2 short sentences (max 25 words) in {{ $content_language ?? 'en' }} that go below image_title on the image. Tease the rest so the reader opens the caption.
|
||||
- `image_keywords`: 2-4 keywords for an Unsplash image search.
|
||||
- `image_keywords`: 2-4 words describing a CONCRETE VISUAL SCENE for an Unsplash image search.
|
||||
Think like an art director, not a copywriter. Describe what should literally be IN the photo: physical objects, specific settings, people doing specific things, lighting, mood. Avoid abstract concepts (Unsplash is a photo library — it can't return "growth" or "innovation", only photos of things).
|
||||
|
||||
GOOD (concrete scenes a photographer could shoot):
|
||||
- `["person typing laptop", "coffee shop morning"]`
|
||||
- `["modern minimal desk", "plant natural light"]`
|
||||
- `["whiteboard team meeting", "collaboration"]`
|
||||
- `["sunrise mountain hiker", "silhouette"]`
|
||||
- `["empty notebook", "pen wooden table"]`
|
||||
|
||||
BAD (abstract concepts → returns generic stock):
|
||||
- `["growth strategy", "business success"]`
|
||||
- `["productivity mindset", "innovation"]`
|
||||
- `["leadership", "vision"]`
|
||||
- `["happiness", "motivation"]`
|
||||
|
||||
ALWAYS write these in English, even when content_language is not 'en'. Unsplash's search index is English-only — Portuguese/Spanish queries return poor results.
|
||||
Example for a pt-BR post: `["calendar", "team meeting"]`, NOT `["calendário", "reunião de equipe"]`.
|
||||
Example for a pt-BR post about productivity: `["person typing laptop", "coffee shop morning"]`, NOT `["produtividade", "trabalho"]`.
|
||||
@endif
|
||||
|
|
|
|||
48
resources/views/prompts/post_image/generator.blade.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
@switch($style)
|
||||
@case('cinematic')
|
||||
Cinematic photograph of {{ $scene }}, soft natural lighting, shallow depth of field, color graded with cinematic tones, professional cinematography, magazine editorial quality, 35mm film aesthetic.
|
||||
@break
|
||||
@case('illustration')
|
||||
Modern flat vector illustration of {{ $scene }}, soft pastel color palette of muted tones, clean geometric shapes, no gradients, contemporary editorial illustration style, calm and uncluttered composition.
|
||||
@break
|
||||
@case('isometric_3d')
|
||||
3D isometric illustration of {{ $scene }}, isometric viewing angle, vibrant but tasteful colors with soft ambient occlusion shadows, clean octane-style rendering, modern SaaS landing page aesthetic, polished and inviting.
|
||||
@break
|
||||
@case('cartoon')
|
||||
Friendly cartoon illustration of {{ $scene }}, hand-drawn line art style, bright cheerful palette of soft tones, slightly chunky line weight, Notion or Linear-style illustration, warm and approachable mood.
|
||||
@break
|
||||
@case('typographic')
|
||||
Abstract typographic editorial composition inspired by {{ $scene }}, modern Swiss design aesthetic, large decorative letterform shapes used purely as visual elements, gallery-quality print poster, deep contrasting background. The letterforms must be ABSTRACT and DECORATIVE — do NOT spell out any actual word, name, headline, caption, or readable phrase. Do NOT include any sentence, label, watermark, or numerical text.
|
||||
@break
|
||||
@case('infographic')
|
||||
Modern flat infographic design illustrating {{ $scene }}, clean dashboard tile style, soft pastel palette, sleek minimal data visualization aesthetic, no people, focus on shapes and graphics.
|
||||
@break
|
||||
@case('minimalist')
|
||||
Minimalist still-life photograph of {{ $scene }}, lots of empty negative space, soft diffused window light, monochromatic muted palette, calm zen aesthetic, fine art editorial photography style.
|
||||
@break
|
||||
@case('mockup')
|
||||
Clean product mockup photograph of {{ $scene }}, polished commercial product photography lighting, soft long shadow, minimal styling, editorial Apple-style aesthetic, neutral pastel background.
|
||||
@break
|
||||
@endswitch
|
||||
|
||||
@if($style !== 'typographic')
|
||||
Pure visual composition. Do NOT render any headline, caption, label, watermark, sticker, badge, slogan, or floating written text anywhere on the image. Diegetic text that is part of the scene (UI text on a screen, signage seen in the environment) is acceptable; standalone words overlaid on the image are not.
|
||||
@endif
|
||||
|
||||
@if($style === 'infographic')
|
||||
Charts and bars are fine but do not include axis labels, numbers, percentages, or any written legend.
|
||||
@endif
|
||||
|
||||
@if($style === 'mockup')
|
||||
If a screen is shown, it should display generic UI shapes and icons only — no readable copy, no headings, no body text.
|
||||
@endif
|
||||
|
||||
Any diegetic text that appears within the scene (text on screens, packaging, signage, speech bubbles, magazine covers, captions inside a comic frame, decorative letterforms) MUST be written in {{ $language_name }}.
|
||||
|
||||
@isset($brand_color_name)
|
||||
Include a small accent of {{ $brand_color_name }} (the brand colour) on a single physical object or graphic element in the scene — a mug, plant pot, jacket, accessory, neon highlight, or UI bar. Keep this accent to roughly 5% of the image; do not flood the scene with this colour or use it as the dominant palette.
|
||||
@endisset
|
||||
|
||||
@isset($brand_context)
|
||||
Brand context (use only to inform tasteful detail choices in the scene, not to spell anything out): {{ $brand_context }}
|
||||
@endisset
|
||||
|
|
@ -164,7 +164,6 @@
|
|||
Route::post('posts/{post}/ai/generate', [PostAiGenerateController::class, 'generate'])->name('app.posts.ai.generate');
|
||||
Route::post('posts/{post}/ai/review', [PostAiReviewController::class, 'review'])->name('app.posts.ai.review');
|
||||
Route::post('posts/ai/create', [PostAiCreateController::class, 'start'])->name('app.posts.ai.create');
|
||||
Route::post('posts/ai/create/{creationId}/finalize', [PostAiCreateController::class, 'finalize'])->name('app.posts.ai.create.finalize');
|
||||
|
||||
// Post Comments
|
||||
Route::get('posts/{post}/comments', [PostCommentController::class, 'index'])->name('app.posts.comments.index');
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@
|
|||
expect($result->brandColor)->toBe('#1e6fff');
|
||||
});
|
||||
|
||||
test('rejects malformed color values', function () {
|
||||
test('returns null when CSS contains no extractable colour values', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response(<<<'HTML'
|
||||
<!DOCTYPE html>
|
||||
|
|
@ -288,7 +288,7 @@
|
|||
<head>
|
||||
<title>Acme</title>
|
||||
<meta name="theme-color" content="rgb(255, 0, 0)">
|
||||
<style>:root { --primary: hsl(200 50% 50%); --background: red; }</style>
|
||||
<style>:root { --primary: notacolor; --background: red; }</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
|
@ -297,10 +297,34 @@
|
|||
|
||||
$result = ($this->autofill)('https://example.com');
|
||||
|
||||
// theme-color tier-1 and CSS-var tier-2 reject the rgb()/named values;
|
||||
// the frequency tier-3 finds nothing extractable in this CSS.
|
||||
expect($result->brandColor)->toBeNull();
|
||||
expect($result->backgroundColor)->toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to CSS colour frequency when no semantic var is exposed', function () {
|
||||
// Tailwind/utility-style CSS with no --primary var: the brand colour just
|
||||
// appears many times across utility classes. Frequency tier picks it up.
|
||||
$css = str_repeat('.btn-primary { background-color: #2563eb; } ', 30);
|
||||
Http::fake([
|
||||
'example.com' => Http::response(<<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Acme</title>
|
||||
<style>{$css}</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
HTML, 200),
|
||||
]);
|
||||
|
||||
$result = ($this->autofill)('https://example.com');
|
||||
|
||||
expect($result->brandColor)->toBe('#2563eb');
|
||||
});
|
||||
|
||||
test('throws when upstream site returns an error', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response('', 500),
|
||||
|
|
|
|||
|
|
@ -5,12 +5,10 @@
|
|||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Jobs\Ai\StreamPostCreation;
|
||||
use App\Models\Post;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
beforeEach(function () {
|
||||
|
|
@ -20,8 +18,6 @@
|
|||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
// --- POST /posts/ai/create (start) ---
|
||||
|
||||
test('start requires authentication', function () {
|
||||
$this->postJson(route('app.posts.ai.create'), ['prompt' => 'hello', 'format' => 'x_post'])
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED);
|
||||
|
|
@ -118,115 +114,6 @@
|
|||
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => is_null($job->socialAccountId));
|
||||
});
|
||||
|
||||
// --- POST /posts/ai/create/{creationId}/finalize ---
|
||||
|
||||
test('finalize requires authentication', function () {
|
||||
$this->postJson(route('app.posts.ai.create.finalize', 'fake-id'))
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('finalize returns 404 if creation not found in cache', function () {
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create.finalize', 'nonexistent-id'))
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('finalize returns 404 if creation belongs to another user', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
$creationId = 'test-creation-id';
|
||||
|
||||
Cache::put("ai-creation:{$creationId}", [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $otherUser->id,
|
||||
'format' => 'x_post',
|
||||
'social_account_id' => null,
|
||||
'image_count' => 0,
|
||||
'content' => 'Some generated content',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create.finalize', $creationId))
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('finalize creates a post and returns post_id and redirect_url', function () {
|
||||
$creationId = 'test-creation-id';
|
||||
$content = 'AI-generated content for the post';
|
||||
|
||||
Cache::put("ai-creation:{$creationId}", [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'format' => 'x_post',
|
||||
'social_account_id' => null,
|
||||
'image_count' => 0,
|
||||
'content' => $content,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create.finalize', $creationId))
|
||||
->assertStatus(Response::HTTP_OK)
|
||||
->assertJsonStructure(['post_id', 'redirect_url']);
|
||||
|
||||
$postId = $response->json('post_id');
|
||||
$post = Post::find($postId);
|
||||
|
||||
expect($post)->not->toBeNull();
|
||||
expect($post->content)->toBe($content);
|
||||
expect($post->workspace_id)->toBe($this->workspace->id);
|
||||
expect($post->user_id)->toBe($this->user->id);
|
||||
|
||||
// Cache entry should be cleared
|
||||
expect(Cache::get("ai-creation:{$creationId}"))->toBeNull();
|
||||
|
||||
// Redirect URL should point to the edit page
|
||||
expect($response->json('redirect_url'))->toContain("/posts/{$postId}/edit");
|
||||
});
|
||||
|
||||
test('finalize defaults scheduled_at to today when no date is in cache state', function () {
|
||||
$creationId = 'no-date-creation';
|
||||
|
||||
Cache::put("ai-creation:{$creationId}", [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'format' => 'x_post',
|
||||
'social_account_id' => null,
|
||||
'image_count' => 0,
|
||||
'content' => 'hello',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create.finalize', $creationId))
|
||||
->assertOk();
|
||||
|
||||
$post = Post::find($response->json('post_id'));
|
||||
expect($post->scheduled_at->format('Y-m-d'))->toBe(now('UTC')->format('Y-m-d'));
|
||||
});
|
||||
|
||||
test('finalize schedules the post on the date stored in cache state', function () {
|
||||
$creationId = 'with-date-creation';
|
||||
|
||||
Cache::put("ai-creation:{$creationId}", [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'format' => 'x_post',
|
||||
'social_account_id' => null,
|
||||
'image_count' => 0,
|
||||
'content' => 'hello',
|
||||
'date' => '2026-06-15',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create.finalize', $creationId))
|
||||
->assertOk();
|
||||
|
||||
$post = Post::find($response->json('post_id'));
|
||||
expect($post->scheduled_at->format('Y-m-d'))->toBe('2026-06-15');
|
||||
});
|
||||
|
||||
test('start dispatches the job carrying the date param when provided', function () {
|
||||
Bus::fake();
|
||||
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@
|
|||
->put(route('app.workspace.settings.update'), [
|
||||
'name' => 'Updated Name',
|
||||
'brand_font' => 'Inter',
|
||||
'image_style' => 'cinematic',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('app.workspace.brand'));
|
||||
|
|
@ -219,6 +220,27 @@
|
|||
expect($this->workspace->name)->toBe('Updated Name');
|
||||
});
|
||||
|
||||
test('update workspace settings persists the image_style choice', function () {
|
||||
$this->actingAs($this->user)
|
||||
->from(route('app.workspace.brand'))
|
||||
->put(route('app.workspace.settings.update'), [
|
||||
'name' => $this->workspace->name,
|
||||
'brand_font' => 'Inter',
|
||||
'image_style' => 'minimalist',
|
||||
])->assertRedirect(route('app.workspace.brand'));
|
||||
|
||||
expect($this->workspace->refresh()->image_style->value)->toBe('minimalist');
|
||||
});
|
||||
|
||||
test('update workspace settings rejects unknown image_style values', function () {
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.workspace.settings.update'), [
|
||||
'name' => $this->workspace->name,
|
||||
'brand_font' => 'Inter',
|
||||
'image_style' => 'pixel-art',
|
||||
])->assertSessionHasErrors(['image_style']);
|
||||
});
|
||||
|
||||
test('update workspace settings validates required fields', function () {
|
||||
$response = $this->actingAs($this->user)->put(route('app.workspace.settings.update'), [
|
||||
'name' => '',
|
||||
|
|
|
|||
23
tests/Unit/Enums/Workspace/ImageStyleTest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
|
||||
test('enum exposes the curated set of image styles', function () {
|
||||
expect(ImageStyle::values())->toBe([
|
||||
'cinematic',
|
||||
'illustration',
|
||||
'isometric_3d',
|
||||
'cartoon',
|
||||
'typographic',
|
||||
'infographic',
|
||||
'minimalist',
|
||||
'mockup',
|
||||
]);
|
||||
});
|
||||
|
||||
test('default style is cinematic', function () {
|
||||
expect(ImageStyle::DEFAULT)->toBe(ImageStyle::Cinematic);
|
||||
expect(ImageStyle::DEFAULT->value)->toBe('cinematic');
|
||||
});
|
||||
157
tests/Unit/Services/Ai/AiImageClientTest.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?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();
|
||||
});
|
||||
75
tests/Unit/Services/Brand/CssColorFrequencyExtractorTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Brand\CssColorFrequencyExtractor;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->extractor = new CssColorFrequencyExtractor;
|
||||
});
|
||||
|
||||
test('returns null when CSS has no colour values', function () {
|
||||
expect($this->extractor->extract('body { padding: 20px; }'))->toBeNull();
|
||||
});
|
||||
|
||||
test('extracts the most frequent hex colour', function () {
|
||||
$css = str_repeat('color: #1e40af; ', 50)
|
||||
.str_repeat('color: #dc2626; ', 5);
|
||||
|
||||
expect($this->extractor->extract($css))->toBe('#1e40af');
|
||||
});
|
||||
|
||||
test('clusters perceptually similar shades and sums their counts', function () {
|
||||
// 30× a brand blue PLUS 20× a slightly different shade of the same blue
|
||||
// should still resolve to a blue cluster, not lose to an unrelated colour
|
||||
// that happens to appear 40 times standalone.
|
||||
$css = str_repeat('color: #1e40af; ', 30)
|
||||
.str_repeat('color: #1d3fae; ', 20) // ΔE76 ~1 — clusters with above
|
||||
.str_repeat('color: #16a34a; ', 40);
|
||||
|
||||
expect($this->extractor->extract($css))->toBe('#1e40af');
|
||||
});
|
||||
|
||||
test('filters neutral greys/blacks/whites out of the result', function () {
|
||||
$css = str_repeat('color: #000000; ', 200)
|
||||
.str_repeat('color: #ffffff; ', 200)
|
||||
.str_repeat('color: #888888; ', 200)
|
||||
.str_repeat('color: #f47b20; ', 5);
|
||||
|
||||
expect($this->extractor->extract($css))->toBe('#f47b20');
|
||||
});
|
||||
|
||||
test('parses rgb() and rgba() values', function () {
|
||||
$css = str_repeat('color: rgb(30, 64, 175); ', 50)
|
||||
.str_repeat('color: rgba(220, 38, 38, 0.5); ', 5);
|
||||
|
||||
expect($this->extractor->extract($css))->toBe('#1e40af');
|
||||
});
|
||||
|
||||
test('parses hsl() values', function () {
|
||||
// hsl(220, 70%, 40%) ≈ #1f4ec7 (a clear blue)
|
||||
$css = str_repeat('color: hsl(220, 70%, 40%); ', 50)
|
||||
.str_repeat('color: hsl(0, 70%, 40%); ', 5);
|
||||
|
||||
expect($this->extractor->extract($css))->toStartWith('#');
|
||||
});
|
||||
|
||||
test('parses 3-char hex shorthand', function () {
|
||||
$css = str_repeat('color: #f80; ', 50);
|
||||
|
||||
expect($this->extractor->extract($css))->toBe('#ff8800');
|
||||
});
|
||||
|
||||
test('strips alpha from 8-char hex', function () {
|
||||
$css = str_repeat('color: #f47b20ff; ', 50);
|
||||
|
||||
expect($this->extractor->extract($css))->toBe('#f47b20');
|
||||
});
|
||||
|
||||
test('returns null when only neutrals are present', function () {
|
||||
$css = str_repeat('color: #000000; ', 50)
|
||||
.str_repeat('color: #ffffff; ', 50)
|
||||
.str_repeat('color: #888888; ', 50);
|
||||
|
||||
expect($this->extractor->extract($css))->toBeNull();
|
||||
});
|
||||
|
|
@ -4,42 +4,40 @@
|
|||
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\AiImageClient;
|
||||
use App\Services\Image\BrandColorMapper;
|
||||
use App\Services\Image\TemplateImageGenerator;
|
||||
use App\Services\Unsplash\UnsplashClient;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Ai\Image;
|
||||
|
||||
$minimalPng = fn () => base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake();
|
||||
Cache::flush();
|
||||
config()->set('services.unsplash.access_key', 'test-key');
|
||||
Image::fake();
|
||||
});
|
||||
|
||||
test('returns null when Unsplash returns no photos', function () {
|
||||
Http::fake(['api.unsplash.com/*' => Http::response(['results' => []])]);
|
||||
|
||||
$service = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
|
||||
test('returns null when AI client cannot generate (no keywords)', function () {
|
||||
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
||||
$result = $service->render(
|
||||
template: 'A',
|
||||
workspace: Workspace::factory()->make(),
|
||||
socialAccount: SocialAccount::factory()->make(['username' => 'testuser', 'display_name' => 'Test User']),
|
||||
title: 'Hello',
|
||||
body: 'World',
|
||||
imageKeywords: ['kitchen'],
|
||||
imageKeywords: [],
|
||||
);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
Image::assertNothingGenerated();
|
||||
});
|
||||
|
||||
test('returns null when Unsplash access key is not configured', function () {
|
||||
config()->set('services.unsplash.access_key', null);
|
||||
Http::fake();
|
||||
test('returns null when AI generation throws', function () {
|
||||
Image::fake(fn () => throw new RuntimeException('upstream outage'));
|
||||
|
||||
$service = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
|
||||
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
||||
$result = $service->render(
|
||||
template: 'A',
|
||||
workspace: Workspace::factory()->make(),
|
||||
socialAccount: SocialAccount::factory()->make(),
|
||||
title: 'Hello',
|
||||
|
|
@ -50,31 +48,17 @@
|
|||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('render returns a storage path when given a valid Unsplash photo', function () {
|
||||
// Serve a tiny 1x1 transparent PNG as the "photo" to avoid real HTTP calls
|
||||
$minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
|
||||
test('renders a slide and stores webp when AI returns bytes', function () use ($minimalPng) {
|
||||
Image::fake([base64_encode($minimalPng())]);
|
||||
|
||||
Http::fake([
|
||||
'api.unsplash.com/*' => Http::response([
|
||||
'results' => [[
|
||||
'id' => 'test123',
|
||||
'urls' => ['regular' => 'https://images.unsplash.com/test123'],
|
||||
'alt_description' => 'test image',
|
||||
]],
|
||||
]),
|
||||
'images.unsplash.com/*' => Http::response($minimalPng, 200, ['Content-Type' => 'image/png']),
|
||||
]);
|
||||
|
||||
// We need to use a real HTTP response for the image download since file_get_contents
|
||||
// is used internally. Skip if fonts are missing — architecture is tested, rendering is best-effort.
|
||||
if (! file_exists(base_path('resources/fonts/Inter-Bold.ttf'))) {
|
||||
$this->markTestSkipped('Inter fonts not available — skipping full render test.');
|
||||
$this->markTestSkipped('Inter fonts not available — skipping render-dependent test.');
|
||||
}
|
||||
|
||||
$service = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
|
||||
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
||||
$result = $service->render(
|
||||
template: 'A',
|
||||
workspace: Workspace::factory()->make([
|
||||
'image_style' => 'illustration',
|
||||
'brand_color' => '#0000ff',
|
||||
'background_color' => '#ffffff',
|
||||
'text_color' => '#000000',
|
||||
|
|
@ -85,30 +69,17 @@
|
|||
]),
|
||||
title: 'Hello World',
|
||||
body: 'This is a test slide body.',
|
||||
imageKeywords: ['technology', 'office'],
|
||||
imageKeywords: ['kitchen', 'morning'],
|
||||
);
|
||||
|
||||
// Result may be null if GD font rendering fails in test env — that's acceptable
|
||||
if ($result !== null) {
|
||||
expect($result)->toStartWith('ai-images/')
|
||||
->toEndWith('.webp');
|
||||
} else {
|
||||
expect($result)->toBeNull(); // graceful failure is acceptable
|
||||
expect($result->path)->toStartWith('ai-images/')->toEndWith('.webp');
|
||||
expect($result->sourceMeta)
|
||||
->toHaveKey('keywords')
|
||||
->toHaveKey('style', 'illustration')
|
||||
->toHaveKey('model', 'gpt-image-2')
|
||||
->toHaveKey('title', 'Hello World');
|
||||
}
|
||||
|
||||
Image::assertGenerated(fn ($prompt) => $prompt->contains('kitchen'));
|
||||
})->skip(fn () => ! extension_loaded('gd'), 'GD extension required');
|
||||
|
||||
test('uses brand color to filter Unsplash search', function () {
|
||||
Http::fake(['api.unsplash.com/*' => Http::response(['results' => []])]);
|
||||
|
||||
$service = new TemplateImageGenerator(new UnsplashClient, new BrandColorMapper);
|
||||
$service->render(
|
||||
template: 'B',
|
||||
workspace: Workspace::factory()->make(['brand_color' => '#ff0000']),
|
||||
socialAccount: SocialAccount::factory()->make(),
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
imageKeywords: ['food'],
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'color=red'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Unsplash\UnsplashClient;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.unsplash.access_key', 'test-key');
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
test('searchPhoto returns null when access key missing', function () {
|
||||
config()->set('services.unsplash.access_key', null);
|
||||
|
||||
expect((new UnsplashClient)->searchPhoto(['kitchen']))->toBeNull();
|
||||
});
|
||||
|
||||
test('searchPhoto returns null when keywords are empty and no fallback found', function () {
|
||||
Http::fake(['api.unsplash.com/*' => Http::response(['results' => []])]);
|
||||
|
||||
expect((new UnsplashClient)->searchPhoto([]))->toBeNull();
|
||||
});
|
||||
|
||||
test('searchPhoto returns formatted photo on success', function () {
|
||||
Http::fake(['api.unsplash.com/*' => Http::response([
|
||||
'results' => [[
|
||||
'id' => 'abc',
|
||||
'urls' => ['regular' => 'https://images.unsplash.com/abc'],
|
||||
'alt_description' => 'a kitchen',
|
||||
]],
|
||||
])]);
|
||||
|
||||
$result = (new UnsplashClient)->searchPhoto(['kitchen']);
|
||||
|
||||
expect($result)->toMatchArray([
|
||||
'id' => 'abc',
|
||||
'url' => 'https://images.unsplash.com/abc',
|
||||
'alt_description' => 'a kitchen',
|
||||
]);
|
||||
});
|
||||
|
||||
test('searchPhoto returns null when Unsplash API returns failure status', function () {
|
||||
Http::fake(['api.unsplash.com/*' => Http::response([], 500)]);
|
||||
|
||||
$result = (new UnsplashClient)->searchPhoto(['kitchen']);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('searchPhoto falls back when color search returns empty results', function () {
|
||||
Http::fake([
|
||||
'api.unsplash.com/search/photos*' => Http::sequence()
|
||||
->push(['results' => []])
|
||||
->push([
|
||||
'results' => [[
|
||||
'id' => 'fallback',
|
||||
'urls' => ['regular' => 'https://images.unsplash.com/fallback'],
|
||||
'alt_description' => null,
|
||||
]],
|
||||
]),
|
||||
]);
|
||||
|
||||
$result = (new UnsplashClient)->searchPhoto(['kitchen'], 'portrait', 'blue');
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect($result['id'])->toBe('fallback');
|
||||
});
|
||||
|
||||
test('searchPhoto caches results to avoid duplicate requests', function () {
|
||||
Http::fake(['api.unsplash.com/*' => Http::response([
|
||||
'results' => [[
|
||||
'id' => 'cached',
|
||||
'urls' => ['regular' => 'https://images.unsplash.com/cached'],
|
||||
'alt_description' => null,
|
||||
]],
|
||||
])]);
|
||||
|
||||
$client = new UnsplashClient;
|
||||
$first = $client->searchPhoto(['coffee']);
|
||||
$second = $client->searchPhoto(['coffee']);
|
||||
|
||||
expect($first)->toMatchArray($second);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('searchPhoto sends color parameter when colorBucket is provided', function () {
|
||||
Http::fake(['api.unsplash.com/*' => Http::response([
|
||||
'results' => [[
|
||||
'id' => 'colored',
|
||||
'urls' => ['regular' => 'https://images.unsplash.com/colored'],
|
||||
'alt_description' => null,
|
||||
]],
|
||||
])]);
|
||||
|
||||
(new UnsplashClient)->searchPhoto(['office'], 'portrait', 'blue');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'color=blue'));
|
||||
});
|
||||
48
tests/Unit/Support/HexColorNameTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\HexColorName;
|
||||
|
||||
test('returns null for malformed hex', function () {
|
||||
expect(HexColorName::approximate(''))->toBeNull();
|
||||
expect(HexColorName::approximate('#xyz'))->toBeNull();
|
||||
expect(HexColorName::approximate('#12345'))->toBeNull();
|
||||
expect(HexColorName::approximate('not-a-hex'))->toBeNull();
|
||||
});
|
||||
|
||||
test('handles 3-char shorthand and alpha hex', function () {
|
||||
expect(HexColorName::approximate('#fff'))->toBe('off-white');
|
||||
expect(HexColorName::approximate('#000000FF'))->toBe('near-black');
|
||||
});
|
||||
|
||||
test('classifies neutrals by lightness', function () {
|
||||
expect(HexColorName::approximate('#000000'))->toBe('near-black');
|
||||
expect(HexColorName::approximate('#333333'))->toBe('dark gray');
|
||||
expect(HexColorName::approximate('#888888'))->toBe('medium gray');
|
||||
expect(HexColorName::approximate('#cccccc'))->toBe('light gray');
|
||||
expect(HexColorName::approximate('#ffffff'))->toBe('off-white');
|
||||
});
|
||||
|
||||
test('maps warm hues to expected names', function () {
|
||||
expect(HexColorName::approximate('#ff0000'))->toBe('red');
|
||||
expect(HexColorName::approximate('#f47b20'))->toBe('warm orange');
|
||||
expect(HexColorName::approximate('#ffd100'))->toBe('golden yellow');
|
||||
});
|
||||
|
||||
test('maps cool hues to expected names', function () {
|
||||
expect(HexColorName::approximate('#00ff00'))->toBe('green');
|
||||
expect(HexColorName::approximate('#00bcd4'))->toBe('cyan');
|
||||
expect(HexColorName::approximate('#0066ff'))->toBe('blue');
|
||||
expect(HexColorName::approximate('#7a3cff'))->toBe('indigo');
|
||||
});
|
||||
|
||||
test('applies deep modifier for dark variants', function () {
|
||||
expect(HexColorName::approximate('#330000'))->toBe('deep red');
|
||||
expect(HexColorName::approximate('#001f3f'))->toBe('deep blue');
|
||||
});
|
||||
|
||||
test('applies light modifier for pale variants', function () {
|
||||
expect(HexColorName::approximate('#ffd99e'))->toBe('light warm orange');
|
||||
expect(HexColorName::approximate('#ffd6c2'))->toBe('light red-orange');
|
||||
});
|
||||