feat: regenerate AI post images with brand palette in editor
Let users adjust AI-generated slides in place via async job and Echo, while applying workspace brand, background, and text colors to image prompts. Autofill swaps site text/background colors for the image palette, and regeneration is blocked on finalized posts with safer job cleanup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a4e1757535
commit
560393db2a
31 changed files with 1335 additions and 33 deletions
|
|
@ -28,6 +28,7 @@ This project has domain-specific skills in `.claude/skills/` (e.g. `pest-testing
|
|||
## Conventions
|
||||
|
||||
- Follow existing code conventions. Check sibling files for structure, approach and naming before creating or editing.
|
||||
- In Vue `<DialogFooter>`, primary action button first in markup, then cancel/secondary (see `vue-typescript.mdc` and `CLAUDE.md`).
|
||||
- Use descriptive names (`isRegisteredForDiscounts`, not `discount()`).
|
||||
- Reuse existing components before writing new ones.
|
||||
- Stick to existing directory structure. Do not create new base folders without approval.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,21 @@ This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`
|
|||
- Import routes from `@/routes/...` — e.g. `import { store } from '@/routes/login'`.
|
||||
- Import controller actions from `@/actions/...`.
|
||||
|
||||
## Dialogs
|
||||
|
||||
In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel.
|
||||
|
||||
`DialogFooter` (`resources/js/components/ui/dialog/DialogFooter.vue`) uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop — the first child is the leftmost action on larger screens.
|
||||
|
||||
```vue
|
||||
<DialogFooter>
|
||||
<Button @click="submit">{{ $t('...submit') }}</Button>
|
||||
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
||||
</DialogFooter>
|
||||
```
|
||||
|
||||
Check sibling dialogs in the same feature before inventing a new footer layout.
|
||||
|
||||
## Form validation
|
||||
|
||||
NEVER use HTML5 validation attributes (`required`, `minlength`, `pattern`, etc.) on form inputs. Always rely solely on backend validation.
|
||||
|
|
|
|||
|
|
@ -230,6 +230,11 @@ ## Frontend (Vue/TypeScript)
|
|||
|
||||
- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations.
|
||||
|
||||
## Dialogs
|
||||
|
||||
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
|
||||
- Match sibling dialogs in the same feature area before inventing a new footer layout.
|
||||
|
||||
## Icons (@tabler/icons-vue)
|
||||
|
||||
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.
|
||||
|
|
|
|||
72
app/Ai/Agents/PostImageRegenerator.php
Normal file
72
app/Ai/Agents/PostImageRegenerator.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Attributes\Temperature;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
#[Temperature(0.25)]
|
||||
class PostImageRegenerator implements Agent, HasStructuredOutput
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
$language = $this->workspace->content_language ?: 'en';
|
||||
|
||||
return <<<PROMPT
|
||||
You are editing text that will be printed inside a social media image.
|
||||
|
||||
Your job:
|
||||
- Apply the user's instruction to the current title/body/keywords.
|
||||
- Keep the same language as the input unless instruction explicitly asks to change it.
|
||||
- Fix spelling/grammar when needed.
|
||||
- Keep output concise and suitable for image overlays.
|
||||
- Preserve intent and topic; only change what's needed.
|
||||
|
||||
Return JSON only following the schema.
|
||||
Language preference: {$language}
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'title' => $schema->string()
|
||||
->description('Updated short title for the image (max ~120 chars).')
|
||||
->required(),
|
||||
'body' => $schema->string()
|
||||
->description('Updated supporting text for the image (max ~240 chars).')
|
||||
->required(),
|
||||
'keywords' => $schema->array()
|
||||
->items($schema->string())
|
||||
->description('3-10 short keywords for image generation context.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
'anthropic' => Lab::Anthropic,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
}
|
||||
|
||||
public function model(): string
|
||||
{
|
||||
return config('ai.default_text_model');
|
||||
}
|
||||
}
|
||||
15
app/Broadcasting/UserAiMediaRegenerationChannel.php
Normal file
15
app/Broadcasting/UserAiMediaRegenerationChannel.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Broadcasting;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserAiMediaRegenerationChannel
|
||||
{
|
||||
public function join(User $user, User $owner, string $regenerationId): bool
|
||||
{
|
||||
return $user->is($owner);
|
||||
}
|
||||
}
|
||||
55
app/Events/Ai/PostMediaRegenerated.php
Normal file
55
app/Events/Ai/PostMediaRegenerated.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events\Ai;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostMediaRegenerated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $media
|
||||
*/
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $regenerationId,
|
||||
public string $postId,
|
||||
public ?array $media = null,
|
||||
public ?string $error = null,
|
||||
) {}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'ai.media.regenerated';
|
||||
}
|
||||
|
||||
public function broadcastOn(): PrivateChannel
|
||||
{
|
||||
return new PrivateChannel("user.{$this->userId}.ai-media.{$this->regenerationId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'regeneration_id' => $this->regenerationId,
|
||||
'post_id' => $this->postId,
|
||||
'media' => $this->media,
|
||||
'error' => $this->error,
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastQueue(): string
|
||||
{
|
||||
return 'broadcasts';
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/App/PostAiRegenerateMediaController.php
Normal file
74
app/Http/Controllers/App/PostAiRegenerateMediaController.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Http\Requests\App\Ai\RegeneratePostMediaImageRequest;
|
||||
use App\Jobs\Ai\RegeneratePostMediaImage;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostAiRegenerateMediaController extends Controller
|
||||
{
|
||||
public function regenerate(RegeneratePostMediaImageRequest $request, Post $post, string $mediaId): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $post);
|
||||
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$terminalStatuses = [
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
PostStatus::Failed,
|
||||
PostStatus::Publishing,
|
||||
];
|
||||
|
||||
if (in_array($post->status, $terminalStatuses, true)) {
|
||||
return response()->json([
|
||||
'message' => __('posts.cannot_edit_finalized'),
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$gate = Gate::inspect('useAi', $workspace->account);
|
||||
if ($gate->denied()) {
|
||||
return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
$mediaItem = collect($post->media ?? [])
|
||||
->first(fn ($item) => data_get($item, 'id') === $mediaId);
|
||||
|
||||
if (! is_array($mediaItem)) {
|
||||
return response()->json([
|
||||
'message' => __('posts.ai.image_regenerate.errors.media_not_found'),
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (data_get($mediaItem, 'source') !== Source::Ai->value) {
|
||||
return response()->json([
|
||||
'message' => __('posts.ai.image_regenerate.errors.not_ai_media'),
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$regenerationId = (string) Str::uuid();
|
||||
|
||||
RegeneratePostMediaImage::dispatch(
|
||||
workspaceId: $workspace->id,
|
||||
postId: $post->id,
|
||||
userId: $request->user()->id,
|
||||
mediaId: $mediaId,
|
||||
regenerationId: $regenerationId,
|
||||
instruction: $request->string('instruction')->toString(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'regeneration_id' => $regenerationId,
|
||||
'channel' => "user.{$request->user()->id}.ai-media.{$regenerationId}",
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php
Normal file
34
app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Ai;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RegeneratePostMediaImageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'instruction' => ['required', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('instruction')) {
|
||||
$this->merge([
|
||||
'instruction' => trim((string) $this->input('instruction')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Http\Requests\App\Post;
|
||||
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
|
@ -49,6 +50,8 @@ public function rules(): array
|
|||
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'media.*.size' => ['sometimes', 'nullable', 'integer'],
|
||||
'media.*.meta' => ['sometimes', 'nullable', 'array'],
|
||||
'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))],
|
||||
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
|
||||
'scheduled_at' => [
|
||||
'sometimes',
|
||||
'nullable',
|
||||
|
|
|
|||
288
app/Jobs/Ai/RegeneratePostMediaImage.php
Normal file
288
app/Jobs/Ai/RegeneratePostMediaImage.php
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Ai;
|
||||
|
||||
use App\Ai\Agents\PostImageRegenerator;
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Events\Ai\PostMediaRegenerated;
|
||||
use App\Models\Media;
|
||||
use App\Models\Post;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\RecordAiUsage;
|
||||
use App\Services\Image\TemplateImageGenerator;
|
||||
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\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
class RegeneratePostMediaImage implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $workspaceId,
|
||||
public string $postId,
|
||||
public string $userId,
|
||||
public string $mediaId,
|
||||
public string $regenerationId,
|
||||
public string $instruction,
|
||||
) {
|
||||
$this->onQueue('ai');
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::warning('RegeneratePostMediaImage failed', [
|
||||
'post_id' => $this->postId,
|
||||
'media_id' => $this->mediaId,
|
||||
'regeneration_id' => $this->regenerationId,
|
||||
'error' => $exception?->getMessage(),
|
||||
]);
|
||||
|
||||
PostMediaRegenerated::dispatch(
|
||||
userId: $this->userId,
|
||||
regenerationId: $this->regenerationId,
|
||||
postId: $this->postId,
|
||||
media: null,
|
||||
error: __('posts.ai.image_regenerate.errors.unavailable'),
|
||||
);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$workspace = Workspace::query()->findOrFail($this->workspaceId);
|
||||
$post = Post::query()
|
||||
->where('workspace_id', $workspace->id)
|
||||
->with(['postPlatforms.socialAccount', 'workspace'])
|
||||
->findOrFail($this->postId);
|
||||
|
||||
$mediaItems = collect($post->media ?? []);
|
||||
$targetIndex = $mediaItems->search(fn ($item) => data_get($item, 'id') === $this->mediaId);
|
||||
if ($targetIndex === false) {
|
||||
throw new \RuntimeException('Media item no longer exists in post.');
|
||||
}
|
||||
|
||||
$target = $mediaItems->get($targetIndex);
|
||||
if (data_get($target, 'source') !== Source::Ai->value) {
|
||||
throw new \RuntimeException('Only AI media can be regenerated.');
|
||||
}
|
||||
|
||||
$sourceMeta = data_get($target, 'source_meta');
|
||||
$baseContext = $this->buildSourceContext(
|
||||
sourceMeta: is_array($sourceMeta) ? $sourceMeta : [],
|
||||
post: $post,
|
||||
workspace: $workspace,
|
||||
);
|
||||
|
||||
/** @var PostImageRegenerator $agent */
|
||||
$agent = app(PostImageRegenerator::class, ['workspace' => $workspace]);
|
||||
|
||||
$response = $agent->prompt(json_encode([
|
||||
'instruction' => $this->instruction,
|
||||
'title' => $baseContext['title'],
|
||||
'body' => $baseContext['body'],
|
||||
'keywords' => $baseContext['keywords'],
|
||||
'language' => $baseContext['language'],
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
RecordAiUsage::recordText(
|
||||
workspace: $workspace,
|
||||
promptTokens: $response->usage?->promptTokens ?? 0,
|
||||
completionTokens: $response->usage?->completionTokens ?? 0,
|
||||
provider: (string) config('ai.default'),
|
||||
model: (string) config('ai.default_text_model'),
|
||||
userId: $this->userId,
|
||||
postId: $post->id,
|
||||
metadata: ['agent' => 'post_image_regenerator'],
|
||||
);
|
||||
|
||||
$structured = $response->structured ?? [];
|
||||
|
||||
$title = trim((string) data_get($structured, 'title', $baseContext['title']));
|
||||
$body = trim((string) data_get($structured, 'body', $baseContext['body']));
|
||||
$keywords = collect(data_get($structured, 'keywords', $baseContext['keywords']))
|
||||
->filter(fn ($keyword) => is_string($keyword) && trim($keyword) !== '')
|
||||
->map(fn (string $keyword) => trim($keyword))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($keywords === []) {
|
||||
$keywords = $baseContext['keywords'];
|
||||
}
|
||||
|
||||
$socialAccount = $this->resolveSocialAccount($post, $workspace);
|
||||
if (! $socialAccount) {
|
||||
throw new \RuntimeException('No social account available for image footer rendering.');
|
||||
}
|
||||
|
||||
$generator = app(TemplateImageGenerator::class);
|
||||
$rendered = $generator->render(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $title,
|
||||
body: $body,
|
||||
imageKeywords: $keywords,
|
||||
width: $baseContext['width'],
|
||||
height: $baseContext['height'],
|
||||
);
|
||||
|
||||
if (! $rendered) {
|
||||
throw new \RuntimeException('Image generator failed to produce media.');
|
||||
}
|
||||
|
||||
$renderedPath = (string) data_get($rendered, 'path');
|
||||
|
||||
try {
|
||||
$newMediaItem = DB::transaction(function () use ($post, $rendered, $target, $workspace) {
|
||||
$newMediaItem = $this->buildAiMediaItem($workspace, $rendered);
|
||||
|
||||
$fresh = Post::query()->whereKey($post->id)->lockForUpdate()->firstOrFail();
|
||||
$items = collect($fresh->media ?? []);
|
||||
|
||||
$currentIndex = $items->search(fn ($item) => data_get($item, 'id') === $this->mediaId);
|
||||
if ($currentIndex === false) {
|
||||
throw new \RuntimeException('Media item changed before regeneration completed.');
|
||||
}
|
||||
|
||||
$items->put($currentIndex, $newMediaItem);
|
||||
|
||||
$fresh->update(['media' => $items->values()->all()]);
|
||||
|
||||
$oldMediaId = data_get($target, 'id');
|
||||
Media::query()->where('id', $oldMediaId)->first()?->delete();
|
||||
|
||||
return $newMediaItem;
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
$this->discardRenderedFile($renderedPath);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
PostMediaRegenerated::dispatch(
|
||||
userId: $this->userId,
|
||||
regenerationId: $this->regenerationId,
|
||||
postId: $post->id,
|
||||
media: $newMediaItem,
|
||||
error: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function discardRenderedFile(string $path): void
|
||||
{
|
||||
if ($path !== '' && Storage::exists($path)) {
|
||||
Storage::delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sourceMeta
|
||||
* @return array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* keywords: array<int, string>,
|
||||
* language: string,
|
||||
* width: int,
|
||||
* height: int
|
||||
* }
|
||||
*/
|
||||
private function buildSourceContext(array $sourceMeta, Post $post, Workspace $workspace): array
|
||||
{
|
||||
$title = trim((string) data_get($sourceMeta, 'title', ''));
|
||||
$body = trim((string) data_get($sourceMeta, 'body', ''));
|
||||
$keywords = collect(data_get($sourceMeta, 'keywords', []))
|
||||
->filter(fn ($keyword) => is_string($keyword) && trim($keyword) !== '')
|
||||
->map(fn (string $keyword) => trim($keyword))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// Fallback path for older AI media without source metadata.
|
||||
if ($title === '' && $body === '') {
|
||||
$derived = trim((string) $post->content);
|
||||
if ($derived !== '') {
|
||||
$lines = preg_split('/\R+/', $derived) ?: [];
|
||||
$title = trim((string) data_get($lines, 0, ''));
|
||||
$body = trim(collect($lines)->slice(1)->implode(' '));
|
||||
}
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
$title = __('posts.ai.image_regenerate.fallback_title');
|
||||
}
|
||||
|
||||
if ($keywords === []) {
|
||||
$keywords = collect(preg_split('/\s+/', "{$title} {$body}") ?: [])
|
||||
->filter(fn ($word) => is_string($word) && mb_strlen(trim($word)) >= 4)
|
||||
->map(fn (string $word) => trim($word, ".,!?;:\"'()[]{}"))
|
||||
->filter()
|
||||
->take(8)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
if ($keywords === []) {
|
||||
$keywords = ['social media', 'marketing'];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'keywords' => $keywords,
|
||||
'language' => (string) data_get($sourceMeta, 'language', $workspace->content_language),
|
||||
'width' => (int) data_get($sourceMeta, 'width', TemplateImageGenerator::DEFAULT_WIDTH),
|
||||
'height' => (int) data_get($sourceMeta, 'height', TemplateImageGenerator::DEFAULT_HEIGHT),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveSocialAccount(Post $post, Workspace $workspace): ?SocialAccount
|
||||
{
|
||||
$enabledAccount = $post->postPlatforms
|
||||
->first(fn ($platform) => $platform->enabled && $platform->socialAccount);
|
||||
|
||||
if ($enabledAccount?->socialAccount) {
|
||||
return $enabledAccount->socialAccount;
|
||||
}
|
||||
|
||||
$anyAccount = $post->postPlatforms
|
||||
->first(fn ($platform) => $platform->socialAccount);
|
||||
|
||||
return $anyAccount?->socialAccount
|
||||
?? $workspace->socialAccounts()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{path: string, source_meta: array<string, mixed>} $rendered
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildAiMediaItem(Workspace $workspace, array $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['source_meta'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
namespace App\Services\Ai;
|
||||
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use App\Support\HexColorName;
|
||||
use App\Support\BrandImagePalette;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Ai\Image;
|
||||
use Throwable;
|
||||
|
|
@ -28,6 +28,8 @@ public function generate(
|
|||
string $orientation = 'portrait',
|
||||
string $language = 'en',
|
||||
?string $brandColor = null,
|
||||
?string $backgroundColor = null,
|
||||
?string $textColor = null,
|
||||
?string $brandDescription = null,
|
||||
string $quality = 'low',
|
||||
int $timeout = 180,
|
||||
|
|
@ -37,9 +39,11 @@ public function generate(
|
|||
return null;
|
||||
}
|
||||
|
||||
$brandColorName = $brandColor !== null
|
||||
? HexColorName::approximate($brandColor)
|
||||
: null;
|
||||
$palette = new BrandImagePalette(
|
||||
brandColor: $brandColor,
|
||||
backgroundColor: $backgroundColor,
|
||||
textColor: $textColor,
|
||||
);
|
||||
|
||||
$brandContext = null;
|
||||
if ($brandDescription !== null) {
|
||||
|
|
@ -55,7 +59,10 @@ public function generate(
|
|||
'style' => $style->value,
|
||||
'scene' => implode(', ', $clean),
|
||||
'language_name' => $this->languageName($language),
|
||||
'brand_color_name' => $brandColorName,
|
||||
'has_brand_palette' => $palette->isDefined(),
|
||||
'brand_color_name' => $palette->brandColorName,
|
||||
'background_color_name' => $palette->backgroundColorName,
|
||||
'text_color_name' => $palette->textColorName,
|
||||
'brand_context' => $brandContext,
|
||||
])->render();
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ public function withBrandColor(?string $brandColor): self
|
|||
}
|
||||
|
||||
/**
|
||||
* Shape returned to the workspace / brand forms after autofill.
|
||||
*
|
||||
* Site extraction keeps the literal page background and body text colours.
|
||||
* For AI image generation we swap them: page text (usually dark) becomes
|
||||
* image background, and page background (usually light) becomes in-image text.
|
||||
*
|
||||
* @return array{name: ?string, brand_description: ?string, content_language: ?string, brand_tone: ?string, brand_voice_notes: ?string, brand_color: ?string, background_color: ?string, text_color: ?string, logo_url: ?string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
|
|
@ -78,8 +84,8 @@ public function toArray(): array
|
|||
'brand_tone' => $this->tone,
|
||||
'brand_voice_notes' => $this->voiceNotes,
|
||||
'brand_color' => $this->brandColor,
|
||||
'background_color' => $this->backgroundColor,
|
||||
'text_color' => $this->textColor,
|
||||
'background_color' => $this->textColor,
|
||||
'text_color' => $this->backgroundColor,
|
||||
'logo_url' => $this->logoUrl,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ public function render(
|
|||
orientation: $orientation,
|
||||
language: $language,
|
||||
brandColor: $workspace->brand_color,
|
||||
backgroundColor: $workspace->background_color,
|
||||
textColor: $workspace->text_color,
|
||||
brandDescription: $workspace->brand_description,
|
||||
);
|
||||
if ($imageData === null) {
|
||||
|
|
@ -114,6 +116,9 @@ public function render(
|
|||
'body' => $body,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'brand_color' => $workspace->brand_color,
|
||||
'background_color' => $workspace->background_color,
|
||||
'text_color' => $workspace->text_color,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
45
app/Support/BrandImagePalette.php
Normal file
45
app/Support/BrandImagePalette.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Resolves workspace brand hex colours into human-readable names for
|
||||
* image-generation prompts. Models follow named colours more reliably
|
||||
* than raw hex codes.
|
||||
*/
|
||||
final readonly class BrandImagePalette
|
||||
{
|
||||
public ?string $brandColorName;
|
||||
|
||||
public ?string $backgroundColorName;
|
||||
|
||||
public ?string $textColorName;
|
||||
|
||||
public function __construct(
|
||||
?string $brandColor = null,
|
||||
?string $backgroundColor = null,
|
||||
?string $textColor = null,
|
||||
) {
|
||||
$this->brandColorName = self::resolveName($brandColor);
|
||||
$this->backgroundColorName = self::resolveName($backgroundColor);
|
||||
$this->textColorName = self::resolveName($textColor);
|
||||
}
|
||||
|
||||
public function isDefined(): bool
|
||||
{
|
||||
return $this->brandColorName !== null
|
||||
|| $this->backgroundColorName !== null
|
||||
|| $this->textColorName !== null;
|
||||
}
|
||||
|
||||
private static function resolveName(?string $hex): ?string
|
||||
{
|
||||
if ($hex === null || trim($hex) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return HexColorName::approximate($hex);
|
||||
}
|
||||
}
|
||||
|
|
@ -213,6 +213,26 @@
|
|||
'applied' => 'Applied',
|
||||
'cancel' => 'Cancel',
|
||||
],
|
||||
'image_regenerate' => [
|
||||
'button' => 'Adjust',
|
||||
'title' => 'Adjust AI image',
|
||||
'description' => 'Describe the correction. The new image replaces the current one and keeps its position in the carousel.',
|
||||
'instruction_label' => 'Instruction',
|
||||
'instruction_placeholder' => 'e.g. Fix the typo in the headline and make the background lighter.',
|
||||
'processing' => 'Regenerating image... this can take a few seconds.',
|
||||
'submit' => 'Regenerate image',
|
||||
'cancel' => 'Cancel',
|
||||
'success' => 'Image updated. The new version replaced the previous one in your post.',
|
||||
'fallback_title' => 'Improve this image copy',
|
||||
'errors' => [
|
||||
'required' => 'Instruction is required.',
|
||||
'start_failed' => 'Failed to start regeneration.',
|
||||
'unavailable' => 'Unable to regenerate this image right now.',
|
||||
'timeout' => 'Regeneration is taking longer than expected. Try again in a moment.',
|
||||
'media_not_found' => 'Media item not found.',
|
||||
'not_ai_media' => 'Only AI-generated media can be regenerated.',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'show' => [
|
||||
|
|
|
|||
|
|
@ -213,6 +213,26 @@
|
|||
'applied' => 'Aplicada',
|
||||
'cancel' => 'Cancelar',
|
||||
],
|
||||
'image_regenerate' => [
|
||||
'button' => 'Ajustar',
|
||||
'title' => 'Ajustar imagen con IA',
|
||||
'description' => 'Describe la corrección. La nueva imagen reemplaza la actual y mantiene su posición en el carrusel.',
|
||||
'instruction_label' => 'Instrucción',
|
||||
'instruction_placeholder' => 'ej: Corregir el error en el titular y aclarar el fondo.',
|
||||
'processing' => 'Regenerando imagen... esto puede tardar unos segundos.',
|
||||
'submit' => 'Regenerar imagen',
|
||||
'cancel' => 'Cancelar',
|
||||
'success' => 'Imagen actualizada. La nueva versión reemplazó la anterior en la publicación.',
|
||||
'fallback_title' => 'Mejorar este texto de imagen',
|
||||
'errors' => [
|
||||
'required' => 'La instrucción es obligatoria.',
|
||||
'start_failed' => 'No se pudo iniciar la regeneración.',
|
||||
'unavailable' => 'No se pudo regenerar esta imagen en este momento.',
|
||||
'timeout' => 'La regeneración está tardando más de lo esperado. Inténtalo de nuevo en un momento.',
|
||||
'media_not_found' => 'No se encontró el archivo multimedia.',
|
||||
'not_ai_media' => 'Solo se puede regenerar contenido generado por IA.',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'show' => [
|
||||
|
|
|
|||
|
|
@ -213,6 +213,26 @@
|
|||
'applied' => 'Aplicada',
|
||||
'cancel' => 'Cancelar',
|
||||
],
|
||||
'image_regenerate' => [
|
||||
'button' => 'Ajustar',
|
||||
'title' => 'Ajustar imagem com IA',
|
||||
'description' => 'Descreva a correção. A nova imagem substitui a atual e mantém a posição no carrossel.',
|
||||
'instruction_label' => 'Instrução',
|
||||
'instruction_placeholder' => 'ex: Corrigir o erro no título e deixar o fundo mais claro.',
|
||||
'processing' => 'Regenerando imagem... isso pode levar alguns segundos.',
|
||||
'submit' => 'Regenerar imagem',
|
||||
'cancel' => 'Cancelar',
|
||||
'success' => 'Imagem atualizada. A nova versão substituiu a anterior no post.',
|
||||
'fallback_title' => 'Melhore esta copy da imagem',
|
||||
'errors' => [
|
||||
'required' => 'A instrução é obrigatória.',
|
||||
'start_failed' => 'Não foi possível iniciar a regeneração.',
|
||||
'unavailable' => 'Não foi possível regenerar esta imagem agora.',
|
||||
'timeout' => 'A regeneração está demorando mais que o esperado. Tente de novo em instantes.',
|
||||
'media_not_found' => 'Mídia não encontrada.',
|
||||
'not_ai_media' => 'Só é possível regenerar mídia gerada por IA.',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'show' => [
|
||||
|
|
|
|||
211
resources/js/components/posts/ai/AiRegenerateImageDialog.vue
Normal file
211
resources/js/components/posts/ai/AiRegenerateImageDialog.vue
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<script setup lang="ts">
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { echo } from '@laravel/echo-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { regenerateMedia as regeneratePostAiMedia } from '@/routes/app/posts/ai';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
path: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
source?: 'ai' | 'unsplash' | 'giphy';
|
||||
source_meta?: Record<string, unknown>;
|
||||
meta?: { width?: number; height?: number; duration?: number };
|
||||
}
|
||||
|
||||
interface RegenerationPayload {
|
||||
media: MediaItem;
|
||||
targetMediaId: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
postId: string;
|
||||
mediaItem: MediaItem | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'regenerated', payload: RegenerationPayload): void;
|
||||
}>();
|
||||
|
||||
const instruction = ref('');
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const status = ref<'idle' | 'starting' | 'processing'>('idle');
|
||||
|
||||
let subscribedChannel: string | null = null;
|
||||
let regenerationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const REGENERATION_TIMEOUT_MS = 180_000;
|
||||
|
||||
const httpRegenerate = useHttp<{ instruction: string }>({
|
||||
instruction: '',
|
||||
});
|
||||
|
||||
const isBusy = computed(() => status.value !== 'idle');
|
||||
|
||||
const unsubscribe = () => {
|
||||
if (subscribedChannel) {
|
||||
echo().leave(`private-${subscribedChannel}`);
|
||||
subscribedChannel = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearRegenerationTimeout = () => {
|
||||
if (regenerationTimeout !== null) {
|
||||
clearTimeout(regenerationTimeout);
|
||||
regenerationTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
instruction.value = '';
|
||||
errorMessage.value = null;
|
||||
status.value = 'idle';
|
||||
clearRegenerationTimeout();
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
const blockDismissWhileBusy = (event: Event) => {
|
||||
if (isBusy.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = (channel: string) => {
|
||||
subscribedChannel = channel;
|
||||
status.value = 'processing';
|
||||
|
||||
clearRegenerationTimeout();
|
||||
regenerationTimeout = setTimeout(() => {
|
||||
errorMessage.value = trans('posts.ai.image_regenerate.errors.timeout');
|
||||
status.value = 'idle';
|
||||
unsubscribe();
|
||||
}, REGENERATION_TIMEOUT_MS);
|
||||
|
||||
echo()
|
||||
.private(channel)
|
||||
.listen('.ai.media.regenerated', (event: {
|
||||
media: MediaItem | null;
|
||||
error?: string | null;
|
||||
}) => {
|
||||
clearRegenerationTimeout();
|
||||
|
||||
if (event.error || !event.media || !props.mediaItem) {
|
||||
errorMessage.value = event.error ?? trans('posts.ai.image_regenerate.errors.unavailable');
|
||||
status.value = 'idle';
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(trans('posts.ai.image_regenerate.success'));
|
||||
|
||||
emit('regenerated', {
|
||||
media: event.media,
|
||||
targetMediaId: props.mediaItem.id,
|
||||
});
|
||||
|
||||
resetState();
|
||||
open.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!props.mediaItem) return;
|
||||
if (!instruction.value.trim()) {
|
||||
errorMessage.value = trans('posts.ai.image_regenerate.errors.required');
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = null;
|
||||
status.value = 'starting';
|
||||
httpRegenerate.instruction = instruction.value.trim();
|
||||
|
||||
try {
|
||||
const response = await httpRegenerate.post(
|
||||
regeneratePostAiMedia.url({ post: props.postId, mediaId: props.mediaItem.id }),
|
||||
) as { channel?: string };
|
||||
const channel = String(response.channel ?? '');
|
||||
if (!channel) {
|
||||
throw new Error('Missing channel in regeneration response.');
|
||||
}
|
||||
subscribe(channel);
|
||||
} catch (error: unknown) {
|
||||
status.value = 'idle';
|
||||
const responseMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
errorMessage.value = responseMessage ?? trans('posts.ai.image_regenerate.errors.start_failed');
|
||||
}
|
||||
};
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
if (status.value === 'processing') {
|
||||
open.value = true;
|
||||
return;
|
||||
}
|
||||
resetState();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent
|
||||
class="sm:max-w-xl"
|
||||
:show-close-button="!isBusy"
|
||||
@pointer-down-outside="blockDismissWhileBusy"
|
||||
@escape-key-down="blockDismissWhileBusy"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('posts.ai.image_regenerate.title') }}</DialogTitle>
|
||||
<DialogDescription>{{ $t('posts.ai.image_regenerate.description') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="ai-image-instruction">{{ $t('posts.ai.image_regenerate.instruction_label') }}</Label>
|
||||
<Textarea
|
||||
id="ai-image-instruction"
|
||||
v-model="instruction"
|
||||
:disabled="isBusy"
|
||||
:placeholder="$t('posts.ai.image_regenerate.instruction_placeholder')"
|
||||
rows="4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="status === 'processing'" class="text-sm text-foreground/70">
|
||||
{{ $t('posts.ai.image_regenerate.processing') }}
|
||||
</p>
|
||||
<p v-if="errorMessage" class="text-sm font-semibold text-rose-700">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
:loading="isBusy"
|
||||
:disabled="!instruction.trim()"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('posts.ai.image_regenerate.submit') }}
|
||||
</Button>
|
||||
<Button variant="outline" :disabled="isBusy" @click="open = false">
|
||||
{{ $t('posts.ai.image_regenerate.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
IconHash,
|
||||
IconLibraryPhoto,
|
||||
IconMoodSmile,
|
||||
IconRefresh,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconVideo,
|
||||
|
|
@ -30,6 +31,8 @@ interface MediaItem {
|
|||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
source?: 'ai' | 'unsplash' | 'giphy';
|
||||
source_meta?: Record<string, unknown>;
|
||||
meta?: { width?: number; height?: number; duration?: number };
|
||||
}
|
||||
|
||||
|
|
@ -49,11 +52,17 @@ interface MediaIssue {
|
|||
reason: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
signatures: Signature[];
|
||||
platformLimits: PlatformLimit[];
|
||||
mediaIssues: Record<string, MediaIssue[]>;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
signatures: Signature[];
|
||||
platformLimits: PlatformLimit[];
|
||||
mediaIssues: Record<string, MediaIssue[]>;
|
||||
allowAiRegenerate?: boolean;
|
||||
}>(),
|
||||
{
|
||||
allowAiRegenerate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const content = defineModel<string>('content', { required: true });
|
||||
const media = defineModel<MediaItem[]>('media', { required: true });
|
||||
|
|
@ -61,6 +70,7 @@ const media = defineModel<MediaItem[]>('media', { required: true });
|
|||
const emit = defineEmits<{
|
||||
(e: 'open-ai-generate'): void;
|
||||
(e: 'open-ai-review'): void;
|
||||
(e: 'open-ai-regenerate-image', mediaId: string): void;
|
||||
}>();
|
||||
|
||||
const emojiOpen = ref(false);
|
||||
|
|
@ -210,6 +220,7 @@ const onMediaKeydown = async (event: KeyboardEvent, index: number) => {
|
|||
};
|
||||
|
||||
const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reason}`);
|
||||
const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerate && item.source === 'ai';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -296,6 +307,16 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
<IconGripVertical class="size-3.5" />
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="canRegenerateWithAi(item)"
|
||||
type="button"
|
||||
class="absolute bottom-1.5 left-1.5 inline-flex h-6 cursor-pointer items-center gap-1 rounded-md border-2 border-foreground bg-card px-1.5 text-[10px] font-semibold text-foreground opacity-0 shadow-2xs transition-all hover:bg-violet-100 group-hover:opacity-100 group-focus:opacity-100"
|
||||
@click.stop="emit('open-ai-regenerate-image', item.id)"
|
||||
>
|
||||
<IconRefresh class="size-3" />
|
||||
{{ $t('posts.ai.image_regenerate.button') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:bg-rose-100 hover:text-rose-700 group-hover:opacity-100 group-focus:opacity-100"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ import { getMediaRulesForContentType } from '@/composables/useMediaRules';
|
|||
export interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
source?: 'ai' | 'unsplash' | 'giphy';
|
||||
source_meta?: Record<string, unknown>;
|
||||
meta?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { computed, onUnmounted, ref, watch } from 'vue';
|
|||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import AiGenerateDialog from '@/components/posts/ai/AiGenerateDialog.vue';
|
||||
import AiRegenerateImageDialog from '@/components/posts/ai/AiRegenerateImageDialog.vue';
|
||||
import AiReviewDialog from '@/components/posts/ai/AiReviewDialog.vue';
|
||||
import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue';
|
||||
import PostEditorHeader from '@/components/posts/editor/PostEditorHeader.vue';
|
||||
|
|
@ -32,6 +33,8 @@ interface MediaItem {
|
|||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
source?: 'ai' | 'unsplash' | 'giphy';
|
||||
source_meta?: Record<string, unknown>;
|
||||
meta?: { width?: number; height?: number; duration?: number };
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +179,8 @@ const isSaving = ref(false);
|
|||
const showSaved = ref(false);
|
||||
const isAiGenerateOpen = ref(false);
|
||||
const isAiReviewOpen = ref(false);
|
||||
const isAiRegenerateImageOpen = ref(false);
|
||||
const selectedAiMediaId = ref<string | null>(null);
|
||||
|
||||
const onAiGenerateApply = (newContent: string) => {
|
||||
content.value = newContent;
|
||||
|
|
@ -185,6 +190,23 @@ const onAiReviewApply = (original: string, suggestion: string) => {
|
|||
content.value = content.value.replace(original, suggestion);
|
||||
};
|
||||
|
||||
const onOpenAiRegenerateImage = (mediaId: string) => {
|
||||
selectedAiMediaId.value = mediaId;
|
||||
isAiRegenerateImageOpen.value = true;
|
||||
};
|
||||
|
||||
const selectedAiMediaItem = computed(() => (
|
||||
selectedAiMediaId.value
|
||||
? (media.value.find((item) => item.id === selectedAiMediaId.value) ?? null)
|
||||
: null
|
||||
));
|
||||
|
||||
const onAiMediaRegenerated = (payload: { media: MediaItem; targetMediaId: string }) => {
|
||||
media.value = media.value.map((item) => (
|
||||
item.id === payload.targetMediaId ? payload.media : item
|
||||
));
|
||||
};
|
||||
|
||||
const isPostActionDisabled = computed(
|
||||
() => isSubmitting.value || selectedPlatformIds.value.length === 0 || !canSchedule.value,
|
||||
);
|
||||
|
|
@ -384,8 +406,10 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
|
|||
:signatures="signatures"
|
||||
:platform-limits="platformLimits"
|
||||
:media-issues="mediaIssues"
|
||||
:allow-ai-regenerate="!isLocked"
|
||||
@open-ai-generate="isAiGenerateOpen = true"
|
||||
@open-ai-review="isAiReviewOpen = true"
|
||||
@open-ai-regenerate-image="onOpenAiRegenerateImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -441,4 +465,11 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
|
|||
:content="content"
|
||||
@apply="onAiReviewApply"
|
||||
/>
|
||||
|
||||
<AiRegenerateImageDialog
|
||||
v-model:open="isAiRegenerateImageOpen"
|
||||
:post-id="post.id"
|
||||
:media-item="selectedAiMediaItem"
|
||||
@regenerated="onAiMediaRegenerated"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
@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.
|
||||
Cinematic photograph of {{ $scene }}, soft natural lighting, shallow depth of field, 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.
|
||||
Modern flat vector illustration of {{ $scene }}, 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.
|
||||
3D isometric illustration of {{ $scene }}, isometric viewing angle, 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.
|
||||
Friendly cartoon illustration of {{ $scene }}, hand-drawn line art style, 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.
|
||||
Abstract typographic editorial composition inspired by {{ $scene }}, modern Swiss design aesthetic, large decorative letterform shapes used purely as visual elements, gallery-quality print poster. 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.
|
||||
Modern flat infographic design illustrating {{ $scene }}, clean dashboard tile style, 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.
|
||||
Minimalist still-life photograph of {{ $scene }}, lots of empty negative space, soft diffused window light, 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.
|
||||
Clean product mockup photograph of {{ $scene }}, polished commercial product photography lighting, soft long shadow, minimal styling, editorial Apple-style aesthetic.
|
||||
@break
|
||||
@endswitch
|
||||
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
@endif
|
||||
|
||||
@if($style === 'infographic')
|
||||
Charts and bars are fine but do not include axis labels, numbers, percentages, or any written legend.
|
||||
Charts, bars, lines, and data highlights are encouraged; do not include axis labels, numbers, percentages, or any written legend.
|
||||
@endif
|
||||
|
||||
@if($style === 'mockup')
|
||||
|
|
@ -39,9 +39,19 @@
|
|||
|
||||
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 }}.
|
||||
|
||||
@if($has_brand_palette)
|
||||
BRAND COLOR PALETTE (mandatory — applies to every style above; overrides generic stock palettes such as default blue dashboards):
|
||||
@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.
|
||||
- Brand / primary accent ({{ $brand_color_name }}): charts, bars, graph lines, highlights, CTAs, icons, key shapes, accents, and primary UI elements.
|
||||
@endisset
|
||||
@isset($background_color_name)
|
||||
- Background / surfaces ({{ $background_color_name }}): canvas, skies, walls, cards, tiles, negative space, and large neutral areas.
|
||||
@endisset
|
||||
@isset($text_color_name)
|
||||
- Text / in-scene typography ({{ $text_color_name }}): diegetic UI labels, signage lettering, and any readable text inside the scene.
|
||||
@endisset
|
||||
Harmonize the three colours with tasteful lighter and darker variations for depth. Keep the image polished and cohesive; do not introduce unrelated hues that clash with this palette.
|
||||
@endif
|
||||
|
||||
@isset($brand_context)
|
||||
Brand context (use only to inform tasteful detail choices in the scene, not to spell anything out): {{ $brand_context }}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Http\Controllers\App\NotificationController;
|
||||
use App\Http\Controllers\App\PostAiCreateController;
|
||||
use App\Http\Controllers\App\PostAiGenerateController;
|
||||
use App\Http\Controllers\App\PostAiRegenerateMediaController;
|
||||
use App\Http\Controllers\App\PostAiReviewController;
|
||||
use App\Http\Controllers\App\PostCommentController;
|
||||
use App\Http\Controllers\App\PostController;
|
||||
|
|
@ -162,6 +163,7 @@
|
|||
|
||||
// Post AI
|
||||
Route::post('posts/{post}/ai/generate', [PostAiGenerateController::class, 'generate'])->name('app.posts.ai.generate');
|
||||
Route::post('posts/{post}/media/{mediaId}/ai/regenerate', [PostAiRegenerateMediaController::class, 'regenerate'])->name('app.posts.ai.regenerate-media');
|
||||
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::get('posts/ai/{creationId}/loading', [PostAiCreateController::class, 'loading'])->name('app.posts.ai.loading')->whereUuid('creationId');
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Broadcasting\PostChannel;
|
||||
use App\Broadcasting\UserAiCreationChannel;
|
||||
use App\Broadcasting\UserAiGenerationChannel;
|
||||
use App\Broadcasting\UserAiMediaRegenerationChannel;
|
||||
use App\Broadcasting\WorkspaceChannel;
|
||||
use App\Broadcasting\WorkspaceUserChannel;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
|
@ -18,3 +19,5 @@
|
|||
Broadcast::channel('user.{owner}.ai-gen.{generationId}', UserAiGenerationChannel::class);
|
||||
|
||||
Broadcast::channel('user.{owner}.ai-creation.{creationId}', UserAiCreationChannel::class);
|
||||
|
||||
Broadcast::channel('user.{owner}.ai-media.{regenerationId}', UserAiMediaRegenerationChannel::class);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Ai\AutofillBrand;
|
||||
use App\Ai\Agents\BrandAnalyzer;
|
||||
use App\Services\Brand\BrandMetadata;
|
||||
use Illuminate\Http\Client\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
|
|
@ -425,6 +426,22 @@
|
|||
expect($result->voiceNotes)->toBeNull();
|
||||
});
|
||||
|
||||
test('toArray swaps site background and text colours for the image palette fields', function () {
|
||||
$metadata = new BrandMetadata(
|
||||
brandColor: '#eab308',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#1f2937',
|
||||
);
|
||||
|
||||
$array = $metadata->toArray();
|
||||
|
||||
expect($array['background_color'])->toBe('#1f2937')
|
||||
->and($array['text_color'])->toBe('#ffffff')
|
||||
->and($array['brand_color'])->toBe('#eab308')
|
||||
->and($metadata->backgroundColor)->toBe('#ffffff')
|
||||
->and($metadata->textColor)->toBe('#1f2937');
|
||||
});
|
||||
|
||||
test('BrandMetadata toArray exposes the shape the controller expects', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response('<html lang="en"><head><title>Foo</title></head></html>', 200),
|
||||
|
|
|
|||
190
tests/Feature/Ai/PostAiRegenerateMediaTest.php
Normal file
190
tests/Feature/Ai/PostAiRegenerateMediaTest.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Jobs\Ai\RegeneratePostMediaImage;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->user->account_id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->socialAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'media' => [[
|
||||
'id' => 'media-ai-1',
|
||||
'path' => 'ai-images/old.webp',
|
||||
'url' => 'https://example.com/old.webp',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => 'ai',
|
||||
'source_meta' => [
|
||||
'title' => 'Old image headline',
|
||||
'body' => 'Old image body',
|
||||
'keywords' => ['marketing', 'automation'],
|
||||
'width' => 1080,
|
||||
'height' => 1350,
|
||||
],
|
||||
]],
|
||||
]);
|
||||
|
||||
PostPlatform::factory()->instagram()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
test('regenerate media dispatches async job and returns channel payload', function () {
|
||||
Bus::fake();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'media-ai-1',
|
||||
]), [
|
||||
'instruction' => 'Replace ECP with ICP and keep the same visual style.',
|
||||
])
|
||||
->assertStatus(Response::HTTP_ACCEPTED);
|
||||
|
||||
$regenerationId = $response->json('regeneration_id');
|
||||
expect($regenerationId)->toBeString()->not->toBeEmpty();
|
||||
expect($response->json('channel'))->toBe("user.{$this->user->id}.ai-media.{$regenerationId}");
|
||||
|
||||
Bus::assertDispatched(RegeneratePostMediaImage::class, function (RegeneratePostMediaImage $job) use ($regenerationId) {
|
||||
return $job->workspaceId === $this->workspace->id
|
||||
&& $job->postId === $this->post->id
|
||||
&& $job->mediaId === 'media-ai-1'
|
||||
&& $job->instruction === 'Replace ECP with ICP and keep the same visual style.'
|
||||
&& $job->regenerationId === $regenerationId;
|
||||
});
|
||||
});
|
||||
|
||||
test('regenerate media rejects non ai media items', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->post->update([
|
||||
'media' => [[
|
||||
'id' => 'media-static-1',
|
||||
'path' => 'uploads/static.png',
|
||||
'url' => 'https://example.com/static.png',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/png',
|
||||
'source' => 'unsplash',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'media-static-1',
|
||||
]), [
|
||||
'instruction' => 'Change the title text',
|
||||
])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
||||
test('regenerate media returns not found when media id is missing from post', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'missing-media-id',
|
||||
]), [
|
||||
'instruction' => 'Fix typo',
|
||||
])
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
||||
test('regenerate media rejects finalized posts', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->post->update(['status' => PostStatus::Published->value]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'media-ai-1',
|
||||
]), [
|
||||
'instruction' => 'Fix typo',
|
||||
])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
||||
test('regenerate media validates instruction is required', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'media-ai-1',
|
||||
]), [
|
||||
'instruction' => ' ',
|
||||
])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
||||
test('regenerate media denies access when post is from another workspace', function () {
|
||||
Bus::fake();
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
$otherWorkspace = Workspace::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'account_id' => $otherUser->account_id,
|
||||
]);
|
||||
|
||||
$otherPost = Post::factory()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
'user_id' => $otherUser->id,
|
||||
'media' => [[
|
||||
'id' => 'other-ai-media',
|
||||
'path' => 'ai-images/other.webp',
|
||||
'url' => 'https://example.com/other.webp',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => 'ai',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $otherPost->id,
|
||||
'mediaId' => 'other-ai-media',
|
||||
]), [
|
||||
'instruction' => 'Fix typo',
|
||||
])
|
||||
->assertNotFound();
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
43
tests/Feature/Ai/RegeneratePostMediaImageJobTest.php
Normal file
43
tests/Feature/Ai/RegeneratePostMediaImageJobTest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Ai\RegeneratePostMediaImage;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
test('job fallback source context uses post content when source_meta is missing', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $user->account_id,
|
||||
]);
|
||||
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'user_id' => $user->id,
|
||||
'content' => "Headline with typo ECP\nBody line for fallback context.",
|
||||
'media' => [],
|
||||
]);
|
||||
|
||||
$job = new RegeneratePostMediaImage(
|
||||
workspaceId: $workspace->id,
|
||||
postId: $post->id,
|
||||
userId: $user->id,
|
||||
mediaId: 'media-ai-1',
|
||||
regenerationId: '0196f5ca-bf2e-7d15-9a22-5709ab10d6c9',
|
||||
instruction: 'Fix typo from ECP to ICP.',
|
||||
);
|
||||
|
||||
$method = new ReflectionMethod(RegeneratePostMediaImage::class, 'buildSourceContext');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$context = $method->invoke($job, [], $post, $workspace);
|
||||
|
||||
expect(data_get($context, 'title'))->toBe('Headline with typo ECP');
|
||||
expect((string) data_get($context, 'body'))->toContain('Body line for fallback context.');
|
||||
expect(data_get($context, 'keywords'))->toBeArray()->not->toBeEmpty();
|
||||
expect(data_get($context, 'width'))->toBe(1080);
|
||||
expect(data_get($context, 'height'))->toBe(1350);
|
||||
});
|
||||
|
|
@ -492,3 +492,36 @@
|
|||
$response->assertSessionHasErrors('content');
|
||||
expect(session('errors')->get('content')[0])->toContain('Threads');
|
||||
});
|
||||
|
||||
test('draft save accepts media source metadata for ai regeneration', function () {
|
||||
$payload = [
|
||||
[
|
||||
'id' => 'media-ai-keep-meta',
|
||||
'path' => 'ai-images/generated.webp',
|
||||
'url' => 'https://example.com/ai-images/generated.webp',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => 'ai',
|
||||
'source_meta' => [
|
||||
'title' => 'Fix ECP typo',
|
||||
'body' => 'Body copy',
|
||||
'keywords' => ['marketing', 'automation'],
|
||||
'width' => 1080,
|
||||
'height' => 1350,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Draft->value,
|
||||
'media' => $payload,
|
||||
'platforms' => [],
|
||||
]);
|
||||
|
||||
$response->assertSessionDoesntHaveErrors();
|
||||
|
||||
$this->post->refresh();
|
||||
expect(data_get($this->post->media, '0.source'))->toBe('ai');
|
||||
expect(data_get($this->post->media, '0.source_meta.title'))->toBe('Fix ECP typo');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -90,32 +90,43 @@
|
|||
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('English'));
|
||||
});
|
||||
|
||||
test('generate appends brand color accent when brandColor is provided', function () {
|
||||
test('generate appends brand palette when workspace colours are provided', function () {
|
||||
Image::fake();
|
||||
|
||||
$client = new AiImageClient;
|
||||
$client->generate(['x'], ImageStyle::Cinematic, brandColor: '#f47b20');
|
||||
$client->generate(
|
||||
['x'],
|
||||
ImageStyle::Infographic,
|
||||
brandColor: '#facc15',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#0f172a',
|
||||
);
|
||||
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('warm orange')
|
||||
&& $prompt->contains('small accent'));
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('BRAND COLOR PALETTE')
|
||||
&& $prompt->contains('golden yellow')
|
||||
&& $prompt->contains('charts, bars')
|
||||
&& $prompt->contains('off-white')
|
||||
&& $prompt->contains('in-scene typography'));
|
||||
});
|
||||
|
||||
test('generate omits brand color accent when brandColor is null', function () {
|
||||
test('generate omits brand palette when no workspace colours are set', function () {
|
||||
Image::fake();
|
||||
|
||||
$client = new AiImageClient;
|
||||
$client->generate(['x'], ImageStyle::Cinematic);
|
||||
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('small accent'));
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('BRAND COLOR PALETTE'));
|
||||
});
|
||||
|
||||
test('generate skips accent when brandColor hex is malformed', function () {
|
||||
test('generate includes only valid colours in the palette', function () {
|
||||
Image::fake();
|
||||
|
||||
$client = new AiImageClient;
|
||||
$client->generate(['x'], ImageStyle::Cinematic, brandColor: 'not-a-hex');
|
||||
$client->generate(['x'], ImageStyle::Cinematic, brandColor: 'not-a-hex', backgroundColor: '#ffffff');
|
||||
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('small accent'));
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('BRAND COLOR PALETTE')
|
||||
&& $prompt->contains('off-white')
|
||||
&& ! $prompt->contains('Brand / primary accent'));
|
||||
});
|
||||
|
||||
test('generate appends brand context when brandDescription is provided', function () {
|
||||
|
|
|
|||
|
|
@ -78,8 +78,13 @@
|
|||
->toHaveKey('keywords')
|
||||
->toHaveKey('style', 'illustration')
|
||||
->toHaveKey('model', 'gpt-image-2')
|
||||
->toHaveKey('title', 'Hello World');
|
||||
->toHaveKey('title', 'Hello World')
|
||||
->toHaveKey('brand_color', '#0000ff')
|
||||
->toHaveKey('background_color', '#ffffff')
|
||||
->toHaveKey('text_color', '#000000');
|
||||
}
|
||||
|
||||
Image::assertGenerated(fn ($prompt) => $prompt->contains('kitchen'));
|
||||
Image::assertGenerated(fn ($prompt) => $prompt->contains('kitchen')
|
||||
&& $prompt->contains('BRAND COLOR PALETTE')
|
||||
&& $prompt->contains('blue'));
|
||||
})->skip(fn () => ! extension_loaded('gd'), 'GD extension required');
|
||||
|
|
|
|||
37
tests/Unit/Support/BrandImagePaletteTest.php
Normal file
37
tests/Unit/Support/BrandImagePaletteTest.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\BrandImagePalette;
|
||||
|
||||
test('isDefined is false when all colours are missing', function () {
|
||||
$palette = new BrandImagePalette;
|
||||
|
||||
expect($palette->isDefined())->toBeFalse();
|
||||
});
|
||||
|
||||
test('resolves all three workspace colours to approximate names', function () {
|
||||
$palette = new BrandImagePalette(
|
||||
brandColor: '#facc15',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#0f172a',
|
||||
);
|
||||
|
||||
expect($palette->isDefined())->toBeTrue()
|
||||
->and($palette->brandColorName)->toBe('golden yellow')
|
||||
->and($palette->backgroundColorName)->toBe('off-white')
|
||||
->and($palette->textColorName)->toContain('blue');
|
||||
});
|
||||
|
||||
test('ignores malformed hex values', function () {
|
||||
$palette = new BrandImagePalette(
|
||||
brandColor: 'not-a-hex',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '',
|
||||
);
|
||||
|
||||
expect($palette->brandColorName)->toBeNull()
|
||||
->and($palette->backgroundColorName)->toBe('off-white')
|
||||
->and($palette->textColorName)->toBeNull()
|
||||
->and($palette->isDefined())->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in a new issue