Merge pull request #57 from trypostit/feat/ai-image-regenerate

feat: regenerate AI post images with brand palette
This commit is contained in:
Paulo Castellano 2026-05-21 20:25:27 -03:00 committed by GitHub
commit 7a598615c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 1848 additions and 171 deletions

View file

@ -61,3 +61,9 @@ If you hit `Illuminate\Foundation\ViteException: Unable to locate file in Vite m
## Deployment
Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/) — the fastest path to production for Laravel apps.
## AI agents (`app/Ai/Agents`)
- Do not write prompts inside agent classes (no heredocs, no long strings in `instructions()`).
- Store prompt copy in `resources/views/prompts/**/*.blade.php` and render with `view('prompts....', $vars)->render()`.
- Follow `PostContentStreamer`, `PostContentReviewer`, or `BrandAnalyzer` for structure.

View file

@ -28,6 +28,8 @@ 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`).
- AI agents: prompts live in `resources/views/prompts/`; `instructions()` uses `view(...)->render()` only — never heredocs or inline prompt strings in `app/Ai/Agents/` (see `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,17 @@ ## 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.
## AI agents (`app/Ai/Agents`)
- **Never** embed prompts in PHP (`<<<PROMPT`, heredocs, or long string literals in `instructions()`).
- Put system/instruction text in Blade under `resources/views/prompts/` (e.g. `prompts.post_content.generator`, `prompts.post_image.regenerator`).
- In `instructions()`, return `view('prompts....', [...])->render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`.
## Icons (@tabler/icons-vue)
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.

View file

@ -9,6 +9,7 @@
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Models\Workspace;
use App\Support\PostStatusRules;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
@ -20,9 +21,7 @@ class UpdatePost
*/
public static function execute(Workspace $workspace, Post $post, array $data): array
{
$terminalStatuses = [PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed, PostStatus::Publishing];
if (in_array($post->status, $terminalStatuses, true)) {
if (PostStatusRules::blocksEditing($post)) {
return ['post' => $post, 'action' => PostAction::Finalized];
}

View file

@ -0,0 +1,64 @@
<?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
{
return view('prompts.post_image.regenerator', [
'content_language' => $this->workspace->content_language ?: 'en',
])->render();
}
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(),
'change_mode' => $schema->string()
->enum(['image_only', 'text_only', 'both'])
->description('Set to image_only (change visual only), text_only (change text only), or both (change visual and text).')
->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

@ -19,6 +19,7 @@
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use App\Services\Post\MediaAttacher;
use App\Support\PostStatusRules;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -69,7 +70,7 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
if (data_get($result, 'action') === PostAction::Finalized) {
return response()->json(
['message' => 'Cannot edit a post in a terminal state.'],
['message' => PostStatusRules::editBlockedMessage()],
Response::HTTP_UNPROCESSABLE_ENTITY
);
}

View file

@ -18,9 +18,7 @@ public function generate(GeneratePostContentRequest $request, Post $post): JsonR
{
$workspace = $request->user()->currentWorkspace;
if ($post->workspace_id !== $workspace->id) {
abort(Response::HTTP_FORBIDDEN);
}
$this->authorize('update', $post);
$gate = Gate::inspect('useAi', $workspace->account);
if ($gate->denied()) {

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Enums\Media\Source;
use App\Http\Requests\App\Ai\RegeneratePostMediaImageRequest;
use App\Jobs\Ai\RegeneratePostMediaImage;
use App\Models\Post;
use App\Support\PostStatusRules;
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;
if (PostStatusRules::blocksEditing($post)) {
return response()->json([
'message' => PostStatusRules::editBlockedMessage(),
], 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

@ -18,9 +18,7 @@ public function review(ReviewPostContentRequest $request, Post $post): JsonRespo
{
$workspace = $request->user()->currentWorkspace;
if ($post->workspace_id !== $workspace->id) {
abort(Response::HTTP_FORBIDDEN);
}
$this->authorize('update', $post);
$gate = Gate::inspect('useAi', $workspace->account);
if ($gate->denied()) {

View file

@ -22,6 +22,7 @@
use App\Services\Post\PostMetricsFetcher;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\TikTokCreatorInfo;
use App\Support\PostStatusRules;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@ -219,7 +220,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
$this->authorize('update', $post);
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed], true)) {
if (PostStatusRules::blocksEditing($post)) {
return redirect()->route('app.posts.show', $post);
}
@ -313,7 +314,7 @@ public function destroy(Request $request, Post $post): RedirectResponse
$this->authorize('delete', $post);
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
if (PostStatusRules::blocksDeletion($post)) {
session()->flash('flash.banner', __('posts.flash.cannot_delete_published'));
session()->flash('flash.bannerStyle', 'danger');

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,463 @@
<?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 Illuminate\Support\Str;
use RuntimeException;
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 = $this->loadPost($workspace);
$target = $this->resolveAiMediaTarget($post);
$baseContext = $this->buildSourceContext(
sourceMeta: is_array(data_get($target, 'source_meta')) ? data_get($target, 'source_meta') : [],
post: $post,
workspace: $workspace,
);
$copy = $this->regenerateSlideCopy($workspace, $post, $baseContext);
$rendered = $this->renderRegeneratedImage($workspace, $post, $copy, $baseContext);
$newMediaItem = $this->replaceMediaOnPost($post, $target, $workspace, $rendered);
PostMediaRegenerated::dispatch(
userId: $this->userId,
regenerationId: $this->regenerationId,
postId: $post->id,
media: $newMediaItem,
error: null,
);
}
private function loadPost(Workspace $workspace): Post
{
return Post::query()
->where('workspace_id', $workspace->id)
->with(['postPlatforms.socialAccount', 'workspace'])
->findOrFail($this->postId);
}
/**
* @return array<string, mixed>
*/
private function resolveAiMediaTarget(Post $post): array
{
$target = collect($post->media ?? [])
->first(fn ($item) => data_get($item, 'id') === $this->mediaId);
if (! is_array($target)) {
throw new RuntimeException('Media item no longer exists in post.');
}
if (data_get($target, 'source') !== Source::Ai->value) {
throw new RuntimeException('Only AI media can be regenerated.');
}
return $target;
}
/**
* @param array{
* title: string,
* body: string,
* keywords: array<int, string>,
* background_path: string,
* language: string,
* width: int,
* height: int
* } $baseContext
* @return array{
* title: string,
* body: string,
* keywords: array<int, string>,
* regenerate_image: bool,
* regenerate_text: bool,
* change_mode: 'image_only'|'text_only'|'both'
* }
*/
private function regenerateSlideCopy(Workspace $workspace, Post $post, array $baseContext): array
{
/** @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'],
);
return $this->mergeStructuredCopy($baseContext, $response->structured ?? []);
}
/**
* @param array{
* title: string,
* body: string,
* keywords: array<int, string>,
* background_path: string,
* language: string,
* width: int,
* height: int
* } $baseContext
* @param array<string, mixed> $structured
* @return array{
* title: string,
* body: string,
* keywords: array<int, string>,
* regenerate_image: bool,
* regenerate_text: bool,
* change_mode: 'image_only'|'text_only'|'both'
* }
*/
private function mergeStructuredCopy(array $baseContext, array $structured): array
{
$changeMode = $this->resolveChangeMode((string) data_get($structured, 'change_mode', 'both'));
$regenerateImage = in_array($changeMode, ['image_only', 'both'], true);
$regenerateText = in_array($changeMode, ['text_only', 'both'], true);
$keywords = $this->normalizeKeywords(data_get($structured, 'keywords', $baseContext['keywords']));
return [
'title' => $regenerateText
? trim((string) data_get($structured, 'title', $baseContext['title']))
: $baseContext['title'],
'body' => $regenerateText
? trim((string) data_get($structured, 'body', $baseContext['body']))
: $baseContext['body'],
'keywords' => $regenerateImage && $keywords !== [] ? $keywords : $baseContext['keywords'],
'regenerate_image' => $regenerateImage,
'regenerate_text' => $regenerateText,
'change_mode' => $changeMode,
];
}
/**
* @param array{
* title: string,
* body: string,
* keywords: array<int, string>,
* regenerate_image: bool,
* regenerate_text: bool,
* change_mode: 'image_only'|'text_only'|'both'
* } $copy
* @param array{
* title: string,
* body: string,
* keywords: array<int, string>,
* language: string,
* width: int,
* height: int
* } $baseContext
* @return array{path: string, source_meta: array<string, mixed>}
*/
private function renderRegeneratedImage(
Workspace $workspace,
Post $post,
array $copy,
array $baseContext,
): array {
$socialAccount = $this->resolveSocialAccount($post, $workspace);
if (! $socialAccount) {
throw new RuntimeException('No social account available for image footer rendering.');
}
$reusedBackgroundPath = null;
if (! $copy['regenerate_image']) {
$reusedBackgroundPath = (string) data_get($baseContext, 'background_path', '');
if ($reusedBackgroundPath === '') {
$reusedBackgroundPath = null;
}
}
$rendered = app(TemplateImageGenerator::class)->render(
workspace: $workspace,
socialAccount: $socialAccount,
title: $copy['title'],
body: $copy['body'],
imageKeywords: $copy['keywords'],
width: $baseContext['width'],
height: $baseContext['height'],
backgroundPath: $reusedBackgroundPath,
);
if (! $rendered) {
throw new RuntimeException('Image generator failed to produce media.');
}
return $rendered;
}
/**
* @param array<string, mixed> $target
* @param array{path: string, source_meta: array<string, mixed>} $rendered
* @return array<string, mixed>
*/
private function replaceMediaOnPost(
Post $post,
array $target,
Workspace $workspace,
array $rendered,
): array {
$renderedPath = $rendered['path'];
$newBackgroundPath = (string) data_get($rendered, 'source_meta.background_path', '');
$oldBackgroundPath = (string) data_get($target, 'source_meta.background_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()]);
Media::query()->where('id', data_get($target, 'id'))->first()?->delete();
return $newMediaItem;
});
if ($oldBackgroundPath !== '' && $oldBackgroundPath !== $newBackgroundPath && Storage::exists($oldBackgroundPath)) {
Storage::delete($oldBackgroundPath);
}
return $newMediaItem;
} catch (Throwable $exception) {
$this->discardRenderedFile($renderedPath);
if ($newBackgroundPath !== '' && $newBackgroundPath !== $oldBackgroundPath && Storage::exists($newBackgroundPath)) {
Storage::delete($newBackgroundPath);
}
throw $exception;
}
}
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>,
* background_path: 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 = $this->normalizeKeywords(data_get($sourceMeta, 'keywords', []));
if ($title === '' && $body === '') {
[$title, $body] = $this->titleAndBodyFromPostContent($post);
}
if ($title === '') {
$title = __('posts.ai.image_regenerate.fallback_title');
}
if ($keywords === []) {
$keywords = $this->keywordsFromCopy($title, $body);
}
if ($keywords === []) {
$keywords = ['social media', 'marketing'];
}
return [
'title' => $title,
'body' => $body,
'keywords' => $keywords,
'background_path' => (string) data_get($sourceMeta, 'background_path', ''),
'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),
];
}
/**
* @return array{0: string, 1: string}
*/
private function titleAndBodyFromPostContent(Post $post): array
{
$lines = Str::of((string) $post->content)
->replace(["\r\n", "\r"], "\n")
->trim()
->explode("\n")
->map(fn (string $line) => trim($line))
->filter()
->values();
if ($lines->isEmpty()) {
return ['', ''];
}
return [
(string) $lines->first(),
$lines->slice(1)->implode(' '),
];
}
/**
* @return array<int, string>
*/
private function keywordsFromCopy(string $title, string $body): array
{
return Str::of("{$title} {$body}")
->squish()
->explode(' ')
->map(fn (string $word) => (string) Str::of($word)->trim(".,!?;:\"'()[]{}"))
->filter(fn (string $word) => mb_strlen($word) >= 4)
->take(8)
->values()
->all();
}
/**
* @return array<int, string>
*/
private function normalizeKeywords(mixed $keywords): array
{
return collect($keywords)
->filter(fn ($keyword) => is_string($keyword) && trim($keyword) !== '')
->map(fn (string $keyword) => trim($keyword))
->values()
->all();
}
/**
* @return 'image_only'|'text_only'|'both'
*/
private function resolveChangeMode(string $value): string
{
return match ($value) {
'image_only', 'text_only', 'both' => $value,
default => 'both',
};
}
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

@ -9,6 +9,7 @@
use App\Enums\Post\Status;
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use App\Support\PostStatusRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -48,7 +49,7 @@ public function handle(Request $request): Response|ResponseFactory
]);
if (data_get($result, 'action') === PostAction::Finalized) {
return Response::error('Post is already published or in a terminal state.');
return Response::error(PostStatusRules::editBlockedMessage());
}
/** @var Post $updated */

View file

@ -11,6 +11,7 @@
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use App\Rules\ContentTypeMatchesPostPlatform;
use App\Support\PostStatusRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Laravel\Mcp\Request;
@ -55,7 +56,7 @@ public function handle(Request $request): Response|ResponseFactory
$result = UpdatePost::execute($workspace, $post, $payload);
if (data_get($result, 'action') === PostAction::Finalized) {
return Response::error('Cannot edit a post in a terminal state.');
return Response::error(PostStatusRules::editBlockedMessage());
}
/** @var Post $updated */

View file

@ -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,7 @@ public function generate(
return null;
}
$brandColorName = $brandColor !== null
? HexColorName::approximate($brandColor)
: null;
$palette = $this->buildPaletteContext($brandColor, $backgroundColor, $textColor);
$brandContext = null;
if ($brandDescription !== null) {
@ -55,7 +55,10 @@ public function generate(
'style' => $style->value,
'scene' => implode(', ', $clean),
'language_name' => $this->languageName($language),
'brand_color_name' => $brandColorName,
'has_brand_palette' => data_get($palette, 'is_defined', false),
'brand_color_name' => data_get($palette, 'brand_color_name'),
'background_color_name' => data_get($palette, 'background_color_name'),
'text_color_name' => data_get($palette, 'text_color_name'),
'brand_context' => $brandContext,
])->render();
@ -92,4 +95,38 @@ private function languageName(string $code): string
default => 'English',
};
}
/**
* @return array{
* is_defined: bool,
* brand_color_name: ?string,
* background_color_name: ?string,
* text_color_name: ?string
* }
*/
private function buildPaletteContext(
?string $brandColor,
?string $backgroundColor,
?string $textColor,
): array {
$brandColorName = $this->resolveColorName($brandColor);
$backgroundColorName = $this->resolveColorName($backgroundColor);
$textColorName = $this->resolveColorName($textColor);
return [
'is_defined' => $brandColorName !== null || $backgroundColorName !== null || $textColorName !== null,
'brand_color_name' => $brandColorName,
'background_color_name' => $backgroundColorName,
'text_color_name' => $textColorName,
];
}
private function resolveColorName(?string $hex): ?string
{
if ($hex === null || trim($hex) === '') {
return null;
}
return HexColorName::approximate($hex);
}
}

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

@ -50,6 +50,7 @@ public function render(
array $imageKeywords,
int $width = self::DEFAULT_WIDTH,
int $height = self::DEFAULT_HEIGHT,
?string $backgroundPath = null,
): ?array {
$this->width = $width;
$this->height = $height;
@ -63,16 +64,33 @@ public function render(
};
$language = $workspace->content_language;
$imageData = $this->aiImage->generate(
keywords: $imageKeywords,
style: $imageStyle,
orientation: $orientation,
language: $language,
brandColor: $workspace->brand_color,
brandDescription: $workspace->brand_description,
);
if ($imageData === null) {
return null;
$generatedNewBackground = false;
$resolvedBackgroundPath = null;
$imageData = null;
if (is_string($backgroundPath) && trim($backgroundPath) !== '' && Storage::exists($backgroundPath)) {
$resolvedBackgroundPath = $backgroundPath;
$imageData = Storage::get($backgroundPath);
}
if (! is_string($imageData) || $imageData === '') {
$imageData = $this->aiImage->generate(
keywords: $imageKeywords,
style: $imageStyle,
orientation: $orientation,
language: $language,
brandColor: $workspace->brand_color,
backgroundColor: $workspace->background_color,
textColor: $workspace->text_color,
brandDescription: $workspace->brand_description,
);
if ($imageData === null) {
return null;
}
$generatedNewBackground = true;
$resolvedBackgroundPath = $this->storeBackgroundImage($imageData);
}
$manager = new ImageManager(Driver::class);
@ -83,16 +101,18 @@ public function render(
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
RecordAiUsage::recordImage(
workspace: $workspace,
provider: 'openai',
model: AiImageClient::MODEL,
metadata: [
'image_style' => $imageStyle->value,
'width' => $this->width,
'height' => $this->height,
],
);
if ($generatedNewBackground) {
RecordAiUsage::recordImage(
workspace: $workspace,
provider: 'openai',
model: AiImageClient::MODEL,
metadata: [
'image_style' => $imageStyle->value,
'width' => $this->width,
'height' => $this->height,
],
);
}
RecordAiUsage::recordTemplate(
workspace: $workspace,
@ -114,10 +134,25 @@ 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,
'background_path' => $resolvedBackgroundPath,
],
];
}
private function storeBackgroundImage(string $imageData): string
{
$manager = new ImageManager(Driver::class);
$background = $manager->decodeBinary($imageData)->cover($this->width, $this->height);
$backgroundPath = 'ai-images/'.uniqid('bg_', true).'.webp';
Storage::put($backgroundPath, (string) $background->encode(new WebpEncoder(quality: 85)));
return $backgroundPath;
}
/**
* Pick the closest aspect ratio for the active canvas so the AI image
* generator returns a photo that doesn't need heavy cropping.

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\Post\Status as PostStatus;
use App\Models\Post;
class PostStatusRules
{
private const EDIT_BLOCKED_MESSAGE_KEY = 'posts.cannot_edit_finalized';
/**
* Statuses where the post can no longer be edited.
*
* @var array<int, PostStatus>
*/
private const EDIT_BLOCKED_STATUSES = [
PostStatus::Published,
PostStatus::PartiallyPublished,
PostStatus::Failed,
PostStatus::Publishing,
];
/**
* Statuses where the post can no longer be deleted.
*
* @var array<int, PostStatus>
*/
private const DELETE_BLOCKED_STATUSES = [
PostStatus::Publishing,
PostStatus::Published,
PostStatus::PartiallyPublished,
];
public static function blocksEditing(Post $post): bool
{
return in_array($post->status, self::EDIT_BLOCKED_STATUSES, true);
}
public static function blocksDeletion(Post $post): bool
{
return in_array($post->status, self::DELETE_BLOCKED_STATUSES, true);
}
public static function editBlockedMessage(): string
{
return __(self::EDIT_BLOCKED_MESSAGE_KEY);
}
}

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

@ -2,12 +2,7 @@
import { IconCloudUpload, IconPhoto, IconVideo, IconX, IconLoader2, IconPlus } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
import type { MediaItem } from '@/types/media';
interface Props {
media: MediaItem[];

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import { watch } from 'vue';
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 { useAiMediaRegeneration, type RegenerationPayload } from '@/composables/useAiMediaRegeneration';
import type { MediaItem } from '@/types/media';
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, errorMessage, status, isBusy, isProcessing, canSubmit, submit, resetState, blockDismissWhileBusy } = useAiMediaRegeneration({
postId: props.postId,
getMediaItem: () => props.mediaItem,
onRegenerated: (payload) => emit('regenerated', payload),
onCompleted: () => {
open.value = false;
},
});
watch(open, (isOpen) => {
if (!isOpen) {
if (isProcessing.value) {
open.value = true;
return;
}
resetState();
}
});
</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="!canSubmit"
@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

@ -3,8 +3,9 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -3,9 +3,10 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -3,9 +3,10 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -14,11 +14,12 @@ import {
ComboboxList,
ComboboxTrigger,
} from '@/components/ui/combobox';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import type { PinterestBoard } from '@/types';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -5,6 +5,7 @@ import {
IconHash,
IconLibraryPhoto,
IconMoodSmile,
IconRefresh,
IconSparkles,
IconTrash,
IconVideo,
@ -21,17 +22,7 @@ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { formatBytes } from '@/composables/useMedia';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
interface MediaItem {
id: string;
path: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
}
import type { MediaItem } from '@/types/media';
interface Signature {
id: string;
@ -49,11 +40,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 +58,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 +208,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 +295,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

@ -6,17 +6,7 @@ import PreviewTab from '@/components/posts/editor/PreviewTab.vue';
import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { PinterestBoard } from '@/types';
interface MediaItem {
id: string;
path: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
}
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -6,8 +6,8 @@ import PhoneMockup from '@/components/PhoneMockup.vue';
import { PlatformPreview } from '@/components/posts/previews';
import { Avatar } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { MediaItem } from '@/composables/useMedia';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -15,6 +15,7 @@ import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { Platform } from '@/enums/platform';
import type { PinterestBoard } from '@/types';
import type { MediaItem } from '@/types/media';
import { PostPlatformStatus } from '@/types/post';
interface SocialAccount {
@ -70,14 +71,6 @@ interface TikTokCreatorInfo {
max_video_post_duration_sec: number | null;
}
interface MediaItem {
id: string;
url: string;
type?: string;
mime_type?: string;
meta?: Record<string, any>;
}
const props = defineProps<{
postPlatforms: PostPlatform[];
selectedPlatformIds: string[];

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -3,7 +3,7 @@ import { IconDots, IconPhoto } from '@tabler/icons-vue';
import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import type { MediaItem } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -14,8 +14,8 @@ import {
import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import type { MediaItem } from '@/composables/useMedia';
import { ContentType } from '@/enums/content-type';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -3,7 +3,8 @@ import { IconPhoto, IconStack2 } from '@tabler/icons-vue';
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { MediaItem } from '@/types/media';
import BlueskyPreview from './BlueskyPreview.vue';
import FacebookPreview from './FacebookPreview.vue';
import InstagramPreview from './InstagramPreview.vue';
@ -20,14 +22,6 @@ interface SocialAccount {
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
}
interface Props {
platform: string;
socialAccount: SocialAccount | null | undefined;

View file

@ -3,7 +3,8 @@ import { IconChevronLeft, IconChevronRight, IconPhoto } from '@tabler/icons-vue'
import { computed, ref, watch, type Component } from 'vue';
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface Props {
media: MediaItem[];

View file

@ -2,7 +2,8 @@
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -3,7 +3,8 @@ import { IconPlus } from '@tabler/icons-vue';
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -2,7 +2,8 @@
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -2,7 +2,8 @@
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;

View file

@ -0,0 +1,172 @@
import { useHttp } from '@inertiajs/vue3';
import { echo } from '@laravel/echo-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref } from 'vue';
import { toast } from 'vue-sonner';
import { regenerateMedia as regeneratePostAiMedia } from '@/routes/app/posts/ai';
import type { MediaItem } from '@/types/media';
export interface RegenerationPayload {
media: MediaItem;
targetMediaId: string;
}
type RegenerationStatus = 'idle' | 'starting' | 'processing';
interface RegenerationStartResponse {
channel?: string;
}
interface RegenerationEvent {
media: MediaItem | null;
error?: string | null;
}
interface UseAiMediaRegenerationOptions {
postId: string;
getMediaItem: () => MediaItem | null;
onRegenerated: (payload: RegenerationPayload) => void;
onCompleted?: () => void;
}
const REGENERATION_TIMEOUT_MS = 180_000;
export const useAiMediaRegeneration = (options: UseAiMediaRegenerationOptions) => {
const instruction = ref('');
const errorMessage = ref<string | null>(null);
const status = ref<RegenerationStatus>('idle');
const httpRegenerate = useHttp<{ instruction: string }>({
instruction: '',
});
let subscribedChannel: string | null = null;
let regenerationTimeout: ReturnType<typeof setTimeout> | null = null;
const isBusy = computed(() => status.value !== 'idle');
const isProcessing = computed(() => status.value === 'processing');
const normalizedInstruction = computed(() => instruction.value.trim());
const canSubmit = computed(() => normalizedInstruction.value.length > 0 && !isBusy.value);
const unsubscribe = () => {
if (subscribedChannel) {
echo().leave(`private-${subscribedChannel}`);
subscribedChannel = null;
}
};
const clearRegenerationTimeout = () => {
if (regenerationTimeout !== null) {
clearTimeout(regenerationTimeout);
regenerationTimeout = null;
}
};
const setIdleWithError = (message: string) => {
errorMessage.value = message;
status.value = 'idle';
};
const resetState = () => {
instruction.value = '';
errorMessage.value = null;
status.value = 'idle';
clearRegenerationTimeout();
unsubscribe();
};
const blockDismissWhileBusy = (event: Event) => {
if (isBusy.value) {
event.preventDefault();
}
};
const handleRegenerationResult = (event: RegenerationEvent) => {
clearRegenerationTimeout();
const mediaItem = options.getMediaItem();
if (event.error || !event.media || !mediaItem) {
setIdleWithError(event.error ?? trans('posts.ai.image_regenerate.errors.unavailable'));
unsubscribe();
return;
}
toast.success(trans('posts.ai.image_regenerate.success'));
options.onRegenerated({
media: event.media,
targetMediaId: mediaItem.id,
});
resetState();
options.onCompleted?.();
};
const subscribe = (channel: string) => {
subscribedChannel = channel;
status.value = 'processing';
clearRegenerationTimeout();
regenerationTimeout = setTimeout(() => {
setIdleWithError(trans('posts.ai.image_regenerate.errors.timeout'));
unsubscribe();
}, REGENERATION_TIMEOUT_MS);
echo()
.private(channel)
.listen('.ai.media.regenerated', (event: RegenerationEvent) => handleRegenerationResult(event));
};
const submit = async () => {
const mediaItem = options.getMediaItem();
const instructionValue = normalizedInstruction.value;
if (!mediaItem) {
return;
}
if (!instructionValue) {
setIdleWithError(trans('posts.ai.image_regenerate.errors.required'));
return;
}
errorMessage.value = null;
status.value = 'starting';
httpRegenerate.instruction = instructionValue;
try {
const response = (await httpRegenerate.post(
regeneratePostAiMedia.url({ post: options.postId, mediaId: mediaItem.id }),
)) as RegenerationStartResponse;
const channel = String(response.channel ?? '');
if (!channel) {
throw new Error('Missing channel in regeneration response.');
}
subscribe(channel);
} catch (error: unknown) {
const responseMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
setIdleWithError(responseMessage ?? trans('posts.ai.image_regenerate.errors.start_failed'));
}
};
onUnmounted(() => {
clearRegenerationTimeout();
unsubscribe();
});
return {
instruction,
errorMessage,
status,
isBusy,
isProcessing,
canSubmit,
submit,
resetState,
blockDismissWhileBusy,
};
};

View file

@ -1,18 +1,7 @@
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import type { MediaItem } from '@/types/media';
export interface MediaItem {
id: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
meta?: {
width?: number;
height?: number;
duration?: number;
};
}
export type { MediaItem } from '@/types/media';
export interface MediaValidationWarning {
key: string; // short key, e.g. 'gif_not_allowed'

View file

@ -1,11 +1,12 @@
import { trans } from 'laravel-vue-i18n';
import { computed, type ComputedRef, type Ref } from 'vue';
import { getMediaItemIssue, type MediaItem } from '@/composables/useMedia';
import { getMediaItemIssue } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { getPlatformLabel } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import { Platform } from '@/enums/platform';
import type { MediaItem } from '@/types/media';
export interface CompliancePostPlatform {
id: string;

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';
@ -22,19 +23,9 @@ import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts';
import type { PinterestBoard } from '@/types';
import type { MediaItem } from '@/types/media';
import { PostStatus } from '@/types/post';
interface MediaItem {
id: string;
path: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
}
interface SocialAccount {
id: string;
platform: string;
@ -176,6 +167,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 +178,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 +394,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 +453,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

@ -18,16 +18,9 @@ import { getPlatformStatusConfig, getPostStatusConfig } from '@/composables/useP
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as postsIndex } from '@/routes/app/posts';
import type { MediaItem } from '@/types/media';
import { PostPlatformStatus, PostStatus } from '@/types/post';
interface MediaItem {
id: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
}
interface SocialAccount {
id: string;
platform: string;

View file

@ -0,0 +1,18 @@
export type MediaSource = 'ai' | 'unsplash' | 'giphy';
export interface MediaItem {
id: string;
url: string;
path?: string;
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
source?: MediaSource;
source_meta?: Record<string, unknown>;
meta?: {
width?: number;
height?: number;
duration?: number;
};
}

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

@ -0,0 +1,16 @@
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 the instruction explicitly asks to change it.
- Keep output concise and suitable for image overlays.
- Preserve intent and topic; only change what is needed.
- Return one `change_mode` value:
- `text_only`: user asked to change only wording/typos/capitalization/punctuation. Keep visual unchanged.
- `image_only`: user asked to change visual only. Keep title/body exactly as provided (no spelling/casing normalization).
- `both`: user asked to change both visual and text.
- In `image_only`, do not rewrite title/body and do not "fix" grammar/casing automatically.
Return JSON only, following the schema.
Language preference: {{ $content_language ?? 'en' }}.

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

@ -22,14 +22,14 @@
});
test('endpoint requires authentication', function () {
$this->postJson("/posts/{$this->post->id}/ai/generate", ['prompt' => 'hi'])
$this->postJson(route('app.posts.ai.generate', $this->post), ['prompt' => 'hi'])
->assertStatus(Response::HTTP_UNAUTHORIZED);
});
test('endpoint validates prompt is required', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson("/posts/{$this->post->id}/ai/generate", [])
->postJson(route('app.posts.ai.generate', $this->post), [])
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
->assertJsonValidationErrors(['prompt']);
});
@ -40,15 +40,15 @@
$foreignPost = Post::factory()->create(['workspace_id' => $otherWorkspace->id]);
$this->actingAs($this->user)
->postJson("/posts/{$foreignPost->id}/ai/generate", ['prompt' => 'hi'])
->assertStatus(Response::HTTP_FORBIDDEN);
->postJson(route('app.posts.ai.generate', $foreignPost), ['prompt' => 'hi'])
->assertStatus(Response::HTTP_NOT_FOUND);
});
test('endpoint dispatches StreamPostContent and returns generation id and channel', function () {
Bus::fake();
$response = $this->actingAs($this->user)
->postJson("/posts/{$this->post->id}/ai/generate", [
->postJson(route('app.posts.ai.generate', $this->post), [
'prompt' => 'Write a post about Mondays',
'current_content' => 'Old content',
])

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

@ -21,13 +21,13 @@
});
test('endpoint requires authentication', function () {
$this->postJson("/posts/{$this->post->id}/ai/review", ['content' => 'hi'])
$this->postJson(route('app.posts.ai.review', $this->post), ['content' => 'hi'])
->assertStatus(Response::HTTP_UNAUTHORIZED);
});
test('endpoint validates content required', function () {
$this->actingAs($this->user)
->postJson("/posts/{$this->post->id}/ai/review", [])
->postJson(route('app.posts.ai.review', $this->post), [])
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
->assertJsonValidationErrors(['content']);
});
@ -37,8 +37,8 @@
$foreignPost = Post::factory()->create(['workspace_id' => $otherWorkspace->id]);
$this->actingAs($this->user)
->postJson("/posts/{$foreignPost->id}/ai/review", ['content' => 'hi'])
->assertStatus(Response::HTTP_FORBIDDEN);
->postJson(route('app.posts.ai.review', $foreignPost), ['content' => 'hi'])
->assertStatus(Response::HTTP_NOT_FOUND);
});
test('endpoint returns suggestions array', function () {
@ -49,7 +49,7 @@
]);
$response = $this->actingAs($this->user)
->postJson("/posts/{$this->post->id}/ai/review", ['content' => 'i was here'])
->postJson(route('app.posts.ai.review', $this->post), ['content' => 'i was here'])
->assertOk();
expect($response->json('suggestions'))->toBeArray()->toHaveCount(1);

View file

@ -0,0 +1,123 @@
<?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);
});
test('merge structured copy keeps text unchanged for image_only mode', function () {
$job = new RegeneratePostMediaImage(
workspaceId: 'workspace-id',
postId: 'post-id',
userId: 'user-id',
mediaId: 'media-id',
regenerationId: 'regen-id',
instruction: 'change image only',
);
$method = new ReflectionMethod(RegeneratePostMediaImage::class, 'mergeStructuredCopy');
$method->setAccessible(true);
$baseContext = [
'title' => 'Keep THIS Text',
'body' => 'Body should remain exactly the same.',
'keywords' => ['saas', 'dashboard'],
'background_path' => 'ai-images/bg.webp',
'language' => 'en',
'width' => 1080,
'height' => 1350,
];
$structured = [
'change_mode' => 'image_only',
'title' => 'Model tried to rewrite',
'body' => 'Model tried to rewrite body',
'keywords' => ['modern office', 'team'],
];
$copy = $method->invoke($job, $baseContext, $structured);
expect(data_get($copy, 'title'))->toBe($baseContext['title'])
->and(data_get($copy, 'body'))->toBe($baseContext['body'])
->and(data_get($copy, 'keywords'))->toBe(['modern office', 'team'])
->and(data_get($copy, 'regenerate_image'))->toBeTrue()
->and(data_get($copy, 'regenerate_text'))->toBeFalse()
->and(data_get($copy, 'change_mode'))->toBe('image_only');
});
test('merge structured copy keeps keywords unchanged for text_only mode', function () {
$job = new RegeneratePostMediaImage(
workspaceId: 'workspace-id',
postId: 'post-id',
userId: 'user-id',
mediaId: 'media-id',
regenerationId: 'regen-id',
instruction: 'change text only',
);
$method = new ReflectionMethod(RegeneratePostMediaImage::class, 'mergeStructuredCopy');
$method->setAccessible(true);
$baseContext = [
'title' => 'Old title',
'body' => 'Old body',
'keywords' => ['saas', 'dashboard'],
'background_path' => 'ai-images/bg.webp',
'language' => 'en',
'width' => 1080,
'height' => 1350,
];
$structured = [
'change_mode' => 'text_only',
'title' => 'New title',
'body' => 'New body',
'keywords' => ['ignored', 'for', 'text-only'],
];
$copy = $method->invoke($job, $baseContext, $structured);
expect(data_get($copy, 'title'))->toBe('New title')
->and(data_get($copy, 'body'))->toBe('New body')
->and(data_get($copy, 'keywords'))->toBe($baseContext['keywords'])
->and(data_get($copy, 'regenerate_image'))->toBeFalse()
->and(data_get($copy, 'regenerate_text'))->toBeTrue()
->and(data_get($copy, 'change_mode'))->toBe('text_only');
});

View file

@ -116,7 +116,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(UpdatePostTool::class, ['post_id' => $post->id, 'content' => 'x']);
$response->assertHasErrors(['Cannot edit a post in a terminal state.']);
$response->assertHasErrors([__('posts.cannot_edit_finalized')]);
})->with([
PostStatus::Published,
PostStatus::PartiallyPublished,
@ -277,7 +277,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(PublishPostTool::class, ['post_id' => $post->id]);
$response->assertHasErrors(['Post is already published or in a terminal state.']);
$response->assertHasErrors([__('posts.cannot_edit_finalized')]);
})->with([
PostStatus::Published,
PostStatus::PartiallyPublished,

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,37 @@
->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')
->toHaveKey('background_path');
}
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');
test('reuses existing background path when provided', function () use ($minimalPng) {
if (! file_exists(base_path('resources/fonts/Inter-Bold.ttf'))) {
$this->markTestSkipped('Inter fonts not available — skipping render-dependent test.');
}
$backgroundPath = 'ai-images/reuse-bg.webp';
Storage::put($backgroundPath, $minimalPng());
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
$result = $service->render(
workspace: Workspace::factory()->make(),
socialAccount: SocialAccount::factory()->make(['username' => 'testuser', 'display_name' => 'Test User']),
title: 'Updated title',
body: 'Updated body',
imageKeywords: [],
backgroundPath: $backgroundPath,
);
expect($result)->not->toBeNull();
expect(data_get($result, 'source_meta.background_path'))->toBe($backgroundPath);
Image::assertNothingGenerated();
})->skip(fn () => ! extension_loaded('gd'), 'GD extension required');

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Enums\Post\Status as PostStatus;
use App\Models\Post;
use App\Support\PostStatusRules;
test('blocks editing for terminal statuses', function (PostStatus $status) {
$post = Post::factory()->make(['status' => $status]);
expect(PostStatusRules::blocksEditing($post))->toBeTrue();
})->with([
PostStatus::Publishing,
PostStatus::Published,
PostStatus::PartiallyPublished,
PostStatus::Failed,
]);
test('allows editing for non terminal statuses', function (PostStatus $status) {
$post = Post::factory()->make(['status' => $status]);
expect(PostStatusRules::blocksEditing($post))->toBeFalse();
})->with([
PostStatus::Draft,
PostStatus::Scheduled,
]);
test('blocks deletion for published statuses', function (PostStatus $status) {
$post = Post::factory()->make(['status' => $status]);
expect(PostStatusRules::blocksDeletion($post))->toBeTrue();
})->with([
PostStatus::Publishing,
PostStatus::Published,
PostStatus::PartiallyPublished,
]);
test('allows deletion for draft, scheduled and failed statuses', function (PostStatus $status) {
$post = Post::factory()->make(['status' => $status]);
expect(PostStatusRules::blocksDeletion($post))->toBeFalse();
})->with([
PostStatus::Draft,
PostStatus::Scheduled,
PostStatus::Failed,
]);