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:
Paulo Castellano 2026-05-21 16:27:55 -03:00
parent a4e1757535
commit 560393db2a
31 changed files with 1335 additions and 33 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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`.

View 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');
}
}

View 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);
}
}

View 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';
}
}

View 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);
}
}

View 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')),
]);
}
}
}

View file

@ -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',

View 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'],
];
}
}

View file

@ -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();

View file

@ -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,
];
}

View file

@ -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,
],
];
}

View 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);
}
}

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View 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>

View file

@ -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"

View file

@ -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;

View file

@ -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>

View file

@ -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 }}

View file

@ -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');

View file

@ -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);

View file

@ -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),

View 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);
});

View 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);
});

View file

@ -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');
});

View file

@ -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 () {

View file

@ -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');

View 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();
});