diff --git a/app/Actions/Automation/Node/RunGenerateNode.php b/app/Actions/Automation/Node/RunGenerateNode.php index 05cf59cb..2565169b 100644 --- a/app/Actions/Automation/Node/RunGenerateNode.php +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -7,7 +7,11 @@ use App\Actions\Post\CreatePost; use App\Ai\Agents\PostContentGenerator; use App\Ai\Agents\PostContentHumanizer; +use App\Ai\Templates\AiTemplateRegistry; +use App\Ai\Templates\TemplateContext; use App\DataTransferObjects\Automation\NodeRunResult; +use App\Enums\Ai\ContentStyle; +use App\Enums\Ai\GeneratorFormat; use App\Enums\PostPlatform\ContentType; use App\Models\AutomationRun; use App\Models\SocialAccount; @@ -16,7 +20,6 @@ use App\Services\Ai\RecordAiUsage; use App\Services\Automation\ExpressionResolver; use App\Services\Automation\GenerateNodeValidator; -use App\Services\Image\PostImagePipeline; use Illuminate\Support\Facades\Log; use Throwable; @@ -48,41 +51,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ->get() ->keyBy('id'); - // Per-automation toggle: when off, the brand persona/voice is NOT - // injected, so the post stays faithful to a third-party source (news / - // RSS curation) instead of being rewritten in the brand's voice. $applyBrandVoice = (bool) data_get($config, 'use_brand_voice', true); $platformContext = $this->resolvePlatformContext($accountsConfig); - $agent = new PostContentGenerator( - workspace: $workspace, - format: $format, - slideCount: $slideCount, - platformContext: $platformContext, - applyBrandVoice: $applyBrandVoice, - ); - - $generatorResponse = $agent->prompt($prompt); - - RecordAiUsage::recordText( - workspace: $workspace, - promptTokens: $generatorResponse->usage->promptTokens, - completionTokens: $generatorResponse->usage->completionTokens, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), - metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'], - ); - - $structured = $generatorResponse->structured ?? []; - - $structured = $this->humanize($workspace, $structured, $format, $applyBrandVoice, $platformContext); - - $content = $format === 'carousel' - ? (string) data_get($structured, 'caption', '') - : (string) data_get($structured, 'content', ''); - - $user = $this->resolveUser($run); + $style = ContentStyle::tryFrom((string) data_get($config, 'style', ContentStyle::default()->value)) ?? ContentStyle::default(); + $styleTemplate = app(AiTemplateRegistry::class)->find($style); $platforms = []; foreach ($accountsConfig as $entry) { @@ -105,50 +79,71 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ]; } - // A single source of truth for image count: 0 = text-only (no image), - // 1 = single image, 2+ = carousel. For the single-image branch we only - // care whether at least one image was requested. $wantsImage = (int) data_get($config, 'target_slide_count', 1) >= 1; $brandAccount = $platforms !== [] ? $activeAccounts->get(data_get($platforms[0], 'social_account_id')) : null; - $contentType = $platforms !== [] - ? ContentType::tryFrom((string) data_get($platforms[0], 'content_type')) - : null; + $isCarousel = $format->isCarousel(); + $imageCount = $isCarousel ? $slideCount : ($wantsImage ? 1 : 0); - $imageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount); + $templateContext = new TemplateContext( + workspace: $workspace, + socialAccount: $brandAccount, + format: $platformContext ?? $format->value, + imageCount: $imageCount, + isCarousel: $isCarousel, + applyBrandVisuals: (bool) data_get($config, 'use_brand_visuals', true), + ); + + $agent = new PostContentGenerator( + workspace: $workspace, + format: $format, + slideCount: $slideCount, + platformContext: $platformContext, + applyBrandVoice: $applyBrandVoice, + template: $styleTemplate, + templateContext: $templateContext, + ); + + $generatorResponse = $agent->prompt($prompt); + + RecordAiUsage::recordText( + workspace: $workspace, + promptTokens: $generatorResponse->usage->promptTokens, + completionTokens: $generatorResponse->usage->completionTokens, + provider: (string) config('ai.default'), + model: (string) config('ai.default_text_model'), + metadata: ['agent' => 'post_generator', 'format' => $format->value, 'source' => 'automation'], + ); + + $structured = $generatorResponse->structured ?? []; + + $structured = $this->humanize($workspace, $structured, $format, $style, $applyBrandVoice, $platformContext); + + $intendedImageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount, $style); - // Dry runs do the AI work (so the user sees a real generation) but - // never persist a Post. Downstream nodes (Publish) read `is_dry_run` - // and skip their persistence too. if ($run->is_dry_run) { + $dryContent = $this->extractContent($structured, $format, $style); + return NodeRunResult::completed(output: [ 'generated' => [ 'post_id' => null, - 'content' => $content, + 'content' => $dryContent, 'dry_run' => true, - 'image_count' => $imageCount, + 'image_count' => $intendedImageCount, ], ]); } - $media = []; + $generated = $styleTemplate->assemble($structured, $templateContext); - $applyBrandVisuals = (bool) data_get($config, 'use_brand_visuals', true); - - if ($brandAccount) { - if ($format === 'carousel') { - $media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals); - } elseif ($wantsImage) { - $media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals); - } - } + $user = $this->resolveUser($run); $post = CreatePost::execute($workspace, $user, [ - 'content' => $content, - 'media' => $media, + 'content' => $generated->content, + 'media' => $generated->media, 'platforms' => $platforms, ]); @@ -157,7 +152,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult return NodeRunResult::completed(output: [ 'generated' => [ 'post_id' => $post->id, - 'content' => $content, + 'content' => $generated->content, 'post_url' => route('app.posts.show', $post->id), ], ]); @@ -167,10 +162,14 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult * @param array $structured * @return array */ - private function humanize(Workspace $workspace, array $structured, string $format, bool $applyBrandVoice = true, ?string $platformContext = null): array + private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style, bool $applyBrandVoice = true, ?string $platformContext = null): array { + if (! $style->humanizes()) { + return $structured; + } + try { - $input = $format === 'carousel' + $input = $format->isCarousel() ? [ 'caption' => data_get($structured, 'caption', ''), 'slides' => array_map( @@ -197,10 +196,10 @@ private function humanize(Workspace $workspace, array $structured, string $forma completionTokens: $response->usage->completionTokens, provider: (string) config('ai.default'), model: (string) config('ai.default_text_model'), - metadata: ['agent' => 'post_humanizer', 'format' => $format, 'source' => 'automation'], + metadata: ['agent' => 'post_humanizer', 'format' => $format->value, 'source' => 'automation'], ); - if ($format === 'carousel') { + if ($format->isCarousel()) { $structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? ''); $originalSlides = $structured['slides'] ?? []; $humanizedSlides = data_get($humanized, 'slides', []); @@ -227,6 +226,26 @@ private function humanize(Workspace $workspace, array $structured, string $forma return $structured; } + /** + * Extract the post caption from the raw structured output without calling + * assemble() (which triggers image generation). Used for dry-run responses + * so no pipeline work happens during test runs. + * + * @param array $structured + */ + private function extractContent(array $structured, GeneratorFormat $format, ContentStyle $style): string + { + if ($style->isTweetCard()) { + return $format->isCarousel() + ? (string) data_get($structured, 'caption', '') + : (string) data_get($structured, 'tweet_text', ''); + } + + return $format->isCarousel() + ? (string) data_get($structured, 'caption', '') + : (string) data_get($structured, 'content', ''); + } + /** * Derive the generator format and slide count from per-account content types. * @@ -243,15 +262,10 @@ private function humanize(Workspace $workspace, array $structured, string $forma * * @param array}> $accountsConfig * @param array $config - * @return array{format: string, slide_count: int} + * @return array{format: GeneratorFormat, slide_count: int} */ public function deriveFormat(array $accountsConfig, array $config): array { - // Multi-image capability comes from the same ContentType rules the - // publish flow uses — never a hardcoded list — so facebook_post, - // tiktok_photo, linkedin_carousel etc. are all recognised. We cap the - // slide count at MAX_GENERATED_IMAGES and at the tightest selected - // account's limit. $maxImagesAcross = 0; foreach ($accountsConfig as $entry) { $contentType = ContentType::tryFrom((string) data_get($entry, 'content_type')); @@ -265,10 +279,10 @@ public function deriveFormat(array $accountsConfig, array $config): array if ($maxImagesAcross > 1 && $targetSlideCount > 1) { $cap = min(GenerateNodeValidator::MAX_GENERATED_IMAGES, $maxImagesAcross); - return ['format' => 'carousel', 'slide_count' => min($targetSlideCount, $cap)]; + return ['format' => GeneratorFormat::Carousel, 'slide_count' => min($targetSlideCount, $cap)]; } - return ['format' => 'single', 'slide_count' => 1]; + return ['format' => GeneratorFormat::Single, 'slide_count' => 1]; } /** @@ -294,16 +308,21 @@ private function resolvePlatformContext(array $accountsConfig): ?string * Number of images that would be attached for the resolved format. Used as * the dry-run indicator and mirrors the non-dry image generation branches: * one per slide for carousels, one for single posts when images are enabled. + * Tweet styles always produce one image per slide/post when an account is set. * * @param array $structured */ - private function intendedImageCount(string $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount): int + private function intendedImageCount(GeneratorFormat $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount, ContentStyle $style): int { if (! $brandAccount) { return 0; } - if ($format === 'carousel') { + if ($style->isTweetCard()) { + return $format->isCarousel() ? $slideCount : 1; + } + + if ($format->isCarousel()) { $slides = data_get($structured, 'slides', []); return is_array($slides) ? count($slides) : $slideCount; diff --git a/app/Ai/Agents/PostContentGenerator.php b/app/Ai/Agents/PostContentGenerator.php index cb9c09b2..14a82146 100644 --- a/app/Ai/Agents/PostContentGenerator.php +++ b/app/Ai/Agents/PostContentGenerator.php @@ -5,6 +5,9 @@ namespace App\Ai\Agents; use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget; +use App\Ai\Templates\AiContentTemplate; +use App\Ai\Templates\TemplateContext; +use App\Enums\Ai\GeneratorFormat; use App\Models\Workspace; use App\Services\Ai\TemplateContextResolver; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -23,10 +26,12 @@ class PostContentGenerator implements Agent, HasStructuredOutput public function __construct( public Workspace $workspace, public ?string $currentContent = null, - public string $format = 'single', + public GeneratorFormat $format = GeneratorFormat::Single, public int $slideCount = 1, public ?string $platformContext = null, public bool $applyBrandVoice = true, + public ?AiContentTemplate $template = null, + public ?TemplateContext $templateContext = null, ) {} public function instructions(): string @@ -45,18 +50,24 @@ public function instructions(): string ->all(); } - // Two budgets: the platform's HARD cap (must never exceed — would break - // publishing) and the recommended SWEET SPOT length (what well-performing - // posts actually look like, way below the hard cap on most platforms). $budget = $this->platformCopyBudget($this->platformContext); - return view('prompts.post_content.generator', [ + if ($this->template?->style()->isTweetCard()) { + $budget['hard_max_chars'] = 560; + $budget['target_chars'] = 280; + } + + $view = $this->template !== null && $this->templateContext !== null + ? $this->template->promptView($this->templateContext) + : 'prompts.post_content.generator'; + + return view($view, [ 'brand_name' => $this->workspace->name ?? '', 'brand_description' => $this->applyBrandVoice ? ($this->workspace->brand_description ?? '') : '', 'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [], 'content_language' => $this->workspace->content_language, 'current_content' => $this->currentContent, - 'format' => $this->format, + 'format' => $this->format->value, 'slide_count' => $this->slideCount, 'examples' => $examples, 'hard_max_chars' => $budget['hard_max_chars'], @@ -67,7 +78,11 @@ public function instructions(): string public function schema(JsonSchema $schema): array { - if ($this->format === 'carousel') { + if ($this->template !== null && $this->templateContext !== null) { + return $this->template->schema($schema, $this->templateContext); + } + + if ($this->format->isCarousel()) { return [ 'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(), 'slides' => $schema->array() @@ -77,7 +92,7 @@ public function schema(JsonSchema $schema): array ->description('The role of this slide in the carousel arc. First slide is `hook` (specific real problem). Last slide is `cta` (one specific next action). Middle slides are `development` (unfold the idea) or `proof` (concrete result, before/after, behind-the-scenes, real learning). For 4+ slides, at least one middle slide must be `proof`.') ->required(), 'title' => $s->string()->description('Headline of the slide. Short, impactful.')->required(), - 'body' => $s->string()->description('Supporting text below the headline. 1-3 sentences.')->required(), + 'body' => $s->string()->description('Supporting body below the headline. 1-3 sentences.')->required(), 'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(), ])) ->min($this->slideCount) diff --git a/app/Ai/Agents/PostContentHumanizer.php b/app/Ai/Agents/PostContentHumanizer.php index 4eba6f5c..36eb5701 100644 --- a/app/Ai/Agents/PostContentHumanizer.php +++ b/app/Ai/Agents/PostContentHumanizer.php @@ -5,6 +5,7 @@ namespace App\Ai\Agents; use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget; +use App\Enums\Ai\GeneratorFormat; use App\Models\Workspace; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Ai\Attributes\Temperature; @@ -27,7 +28,7 @@ class PostContentHumanizer implements Agent, HasStructuredOutput public function __construct( public Workspace $workspace, - public string $format = 'single', + public GeneratorFormat $format = GeneratorFormat::Single, public ?string $platformContext = null, public bool $applyBrandVoice = true, ) {} @@ -43,7 +44,7 @@ public function instructions(): string 'brand_name' => $this->workspace->name ?? '', 'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [], 'content_language' => $this->workspace->content_language, - 'format' => $this->format, + 'format' => $this->format->value, 'hard_max_chars' => $budget['hard_max_chars'], 'target_chars' => $budget['target_chars'], 'platform_label' => $budget['platform_label'], @@ -52,7 +53,7 @@ public function instructions(): string public function schema(JsonSchema $schema): array { - if ($this->format === 'carousel') { + if ($this->format->isCarousel()) { return [ 'caption' => $schema->string()->description('The humanized Instagram caption.')->required(), 'slides' => $schema->array() diff --git a/app/Ai/Agents/PostContentStreamer.php b/app/Ai/Agents/PostContentStreamer.php index 545c8af6..3bd800e1 100644 --- a/app/Ai/Agents/PostContentStreamer.php +++ b/app/Ai/Agents/PostContentStreamer.php @@ -4,6 +4,7 @@ namespace App\Ai\Agents; +use App\Enums\Ai\GeneratorFormat; use App\Models\Workspace; use Laravel\Ai\Attributes\Temperature; use Laravel\Ai\Contracts\Agent; @@ -36,7 +37,7 @@ public function instructions(): string 'brand_voice_traits' => $this->workspace->brand_voice_traits ?? [], 'content_language' => $this->workspace->content_language, 'current_content' => $this->currentContent, - 'format' => 'single', + 'format' => GeneratorFormat::Single->value, 'slide_count' => 1, ])->render(); } diff --git a/app/Ai/Templates/AiContentTemplate.php b/app/Ai/Templates/AiContentTemplate.php new file mode 100644 index 00000000..d49fb10c --- /dev/null +++ b/app/Ai/Templates/AiContentTemplate.php @@ -0,0 +1,53 @@ + + */ + public function supportedFormats(): array; + + /** The Blade view path for the generator prompt. */ + public function promptView(TemplateContext $context): string; + + /** + * The structured-output schema for the generator. + * + * @return array + */ + public function schema(JsonSchema $schema, TemplateContext $context): array; + + /** + * Build the post from the (humanized) structured output. + * + * @param array $structured + */ + public function assemble(array $structured, TemplateContext $context): GeneratedPost; +} diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php new file mode 100644 index 00000000..3c4e27dd --- /dev/null +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -0,0 +1,57 @@ +> */ + private const TEMPLATES = [ + ImageCardTemplate::class, + TweetCardTemplate::class, + TweetCardImageTemplate::class, + ]; + + /** @var array|null */ + private ?array $templates = null; + + /** @return array */ + public function all(): array + { + return $this->templates ??= array_map(fn (string $class) => app($class), self::TEMPLATES); + } + + /** @return array */ + public function keys(): array + { + return array_map(fn (AiContentTemplate $t) => $t->key(), $this->all()); + } + + /** @return array */ + public function styles(): array + { + return ContentStyle::cases(); + } + + public function find(ContentStyle|string $style): AiContentTemplate + { + $value = $style instanceof ContentStyle ? $style->value : $style; + + foreach ($this->all() as $template) { + if ($template->key() === $value) { + return $template; + } + } + + throw new InvalidArgumentException("Unknown AI content template: {$value}"); + } + + public function default(): AiContentTemplate + { + return app(ImageCardTemplate::class); + } +} diff --git a/app/Ai/Templates/Concerns/ResolvesContentType.php b/app/Ai/Templates/Concerns/ResolvesContentType.php new file mode 100644 index 00000000..20087553 --- /dev/null +++ b/app/Ai/Templates/Concerns/ResolvesContentType.php @@ -0,0 +1,19 @@ +> $media + */ + public function __construct( + public string $content, + public array $media, + public ?ContentType $contentType, + ) {} +} diff --git a/app/Ai/Templates/ImageCardTemplate.php b/app/Ai/Templates/ImageCardTemplate.php new file mode 100644 index 00000000..ce2102fd --- /dev/null +++ b/app/Ai/Templates/ImageCardTemplate.php @@ -0,0 +1,149 @@ +style()->value; + } + + public function name(): string + { + return $this->style()->label(); + } + + public function description(): string + { + return $this->style()->description(); + } + + public function previewAsset(): string + { + return $this->style()->previewAsset(); + } + + public function needsAccount(): bool + { + return $this->style()->needsAccount(); + } + + public function supportedFormats(): array + { + return []; + } + + public function promptView(TemplateContext $context): string + { + return 'prompts.post_content.generator'; + } + + public function schema(JsonSchema $schema, TemplateContext $context): array + { + if ($context->isCarousel) { + $slideCount = $context->imageCount > 0 ? $context->imageCount : 1; + + return [ + 'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(), + 'slides' => $schema->array() + ->items($schema->object(fn ($s) => [ + 'role' => $s->string() + ->enum(['hook', 'development', 'proof', 'cta']) + ->description('The role of this slide in the carousel arc. First slide is `hook` (specific real problem). Last slide is `cta` (one specific next action). Middle slides are `development` (unfold the idea) or `proof` (concrete result, before/after, behind-the-scenes, real learning). For 4+ slides, at least one middle slide must be `proof`.') + ->required(), + 'title' => $s->string()->description('Headline of the slide. Short, impactful.')->required(), + 'body' => $s->string()->description('Supporting text below the headline. 1-3 sentences.')->required(), + 'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(), + ])) + ->min($slideCount) + ->max($slideCount) + ->description("Exactly {$slideCount} slides for the carousel, in order. First slide must have role `hook`, last slide must have role `cta`.") + ->required(), + ]; + } + + return [ + 'content' => $schema->string()->description('The full post caption text that will be published on the platform.')->required(), + 'image_title' => $schema->string()->description('Short headline (5-12 words) overlaid on the image. The hook — should make a scroller stop. Distinct from content.')->required(), + 'image_body' => $schema->string()->description('1-2 short sentences (max 25 words) overlaid below the image_title. Expands the hook just enough to compel reading the caption.')->required(), + 'image_keywords' => $schema->array()->items($schema->string())->description('2-4 search keywords for Unsplash for the single image.')->required(), + ]; + } + + /** + * @param array $structured + */ + public function assemble(array $structured, TemplateContext $context): GeneratedPost + { + if ($context->isCarousel) { + return $this->assembleCarousel($structured, $context); + } + + return $this->assembleSingle($structured, $context); + } + + /** + * @param array $structured + */ + private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost + { + $caption = (string) data_get($structured, 'caption', ''); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forCarousel( + workspace: $context->workspace, + account: $context->socialAccount, + structured: $structured, + contentType: ContentType::InstagramFeed, + applyBrandVisuals: $context->applyBrandVisuals, + ); + } + + return new GeneratedPost($caption, $media, ContentType::InstagramFeed); + } + + /** + * @param array $structured + */ + private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost + { + $contentType = self::resolveContentType($context->format); + $supportsCaption = $contentType?->supportsCaption() ?? true; + + $rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', '')); + + $media = []; + + if ($context->imageCount > 0 && $context->socialAccount) { + $media = app(PostImagePipeline::class)->forSingle( + workspace: $context->workspace, + account: $context->socialAccount, + structured: $structured, + contentType: $contentType, + applyBrandVisuals: $context->applyBrandVisuals, + ); + } + + $caption = $supportsCaption ? $rawContent : ''; + + return new GeneratedPost($caption, $media, $contentType); + } +} diff --git a/app/Ai/Templates/TemplateContext.php b/app/Ai/Templates/TemplateContext.php new file mode 100644 index 00000000..dc2009e3 --- /dev/null +++ b/app/Ai/Templates/TemplateContext.php @@ -0,0 +1,23 @@ +style()->value; + } + + public function name(): string + { + return $this->style()->label(); + } + + public function description(): string + { + return $this->style()->description(); + } + + public function previewAsset(): string + { + return $this->style()->previewAsset(); + } + + public function needsAccount(): bool + { + return $this->style()->needsAccount(); + } + + /** @return array */ + public function supportedFormats(): array + { + return []; + } + + public function promptView(TemplateContext $context): string + { + return $context->isCarousel + ? 'prompts.post_content.tweet_card_image_carousel' + : 'prompts.post_content.tweet_card_image'; + } + + /** + * @return array + */ + public function schema(JsonSchema $schema, TemplateContext $context): array + { + if ($context->isCarousel) { + $slideCount = $context->imageCount > 0 ? $context->imageCount : 1; + + return [ + 'caption' => $schema->string()->description('The caption for the carousel post (teases the content, encourages swiping).')->required(), + 'slides' => $schema->array() + ->items($schema->object(fn ($s) => [ + 'tweet_text' => $s->string() + ->description('The tweet-style text for this slide. First-person, punchy, max ~560 characters.') + ->required(), + 'image_keywords' => $s->array() + ->items($schema->string()) + ->description('2-4 English keywords for a background photo that fits this slide\'s theme. The photo will be blurred and darkened behind the card.') + ->required(), + ])) + ->min($slideCount) + ->max($slideCount) + ->description("Exactly {$slideCount} slides, each a self-contained tweet-card. First slide must hook the reader.") + ->required(), + ]; + } + + return [ + 'tweet_text' => $schema->string() + ->description('The post body, written as a punchy first-person X/Twitter-style take. Paragraph breaks (\\n\\n) allowed. Max ~560 characters.') + ->required(), + 'image_keywords' => $schema->array() + ->items($schema->string()) + ->description('2-4 English keywords for a background photo that fits the post\'s theme. The photo will be blurred and darkened behind the card.') + ->required(), + ]; + } + + /** + * @param array $structured + */ + public function assemble(array $structured, TemplateContext $context): GeneratedPost + { + if ($context->isCarousel) { + return $this->assembleCarousel($structured, $context); + } + + return $this->assembleSingle($structured, $context); + } + + /** + * @param array $structured + */ + private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost + { + $text = (string) data_get($structured, 'tweet_text', ''); + $imageKeywords = data_get($structured, 'image_keywords', []); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forTweetCard( + workspace: $context->workspace, + account: $context->socialAccount, + tweetText: $text, + imageKeywords: $imageKeywords, + ); + } + + return new GeneratedPost( + content: $text, + media: $media, + contentType: ContentType::tryFrom($context->format), + ); + } + + /** + * @param array $structured + */ + private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost + { + $caption = (string) data_get($structured, 'caption', ''); + + $slides = array_map( + fn ($slide) => [ + 'tweet_text' => (string) data_get($slide, 'tweet_text', ''), + 'image_keywords' => data_get($slide, 'image_keywords', []), + ], + data_get($structured, 'slides', []), + ); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forTweetCardCarousel( + workspace: $context->workspace, + account: $context->socialAccount, + slides: $slides, + ); + } + + return new GeneratedPost( + content: $caption, + media: $media, + contentType: ContentType::InstagramFeed, + ); + } +} diff --git a/app/Ai/Templates/TweetCardTemplate.php b/app/Ai/Templates/TweetCardTemplate.php new file mode 100644 index 00000000..5811f24d --- /dev/null +++ b/app/Ai/Templates/TweetCardTemplate.php @@ -0,0 +1,151 @@ +style()->value; + } + + public function name(): string + { + return $this->style()->label(); + } + + public function description(): string + { + return $this->style()->description(); + } + + public function previewAsset(): string + { + return $this->style()->previewAsset(); + } + + public function needsAccount(): bool + { + return $this->style()->needsAccount(); + } + + /** @return array */ + public function supportedFormats(): array + { + return []; + } + + public function promptView(TemplateContext $context): string + { + return $context->isCarousel + ? 'prompts.post_content.tweet_card_carousel' + : 'prompts.post_content.tweet_card'; + } + + /** + * @return array + */ + public function schema(JsonSchema $schema, TemplateContext $context): array + { + if ($context->isCarousel) { + $slideCount = $context->imageCount > 0 ? $context->imageCount : 1; + + return [ + 'caption' => $schema->string()->description('The caption for the carousel post (teases the content, encourages swiping).')->required(), + 'slides' => $schema->array() + ->items($schema->object(fn ($s) => [ + 'tweet_text' => $s->string() + ->description('The tweet-style text for this slide. First-person, punchy, max ~560 characters.') + ->required(), + ])) + ->min($slideCount) + ->max($slideCount) + ->description("Exactly {$slideCount} slides, each a self-contained tweet-card. First slide must hook the reader.") + ->required(), + ]; + } + + return [ + 'tweet_text' => $schema->string() + ->description('The post body, written as a punchy first-person X/Twitter-style take. Paragraph breaks (\\n\\n) allowed. Max ~560 characters.') + ->required(), + ]; + } + + /** + * @param array $structured + */ + public function assemble(array $structured, TemplateContext $context): GeneratedPost + { + if ($context->isCarousel) { + return $this->assembleCarousel($structured, $context); + } + + return $this->assembleSingle($structured, $context); + } + + /** + * @param array $structured + */ + private function assembleSingle(array $structured, TemplateContext $context): GeneratedPost + { + $text = (string) data_get($structured, 'tweet_text', ''); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forTweetCard( + workspace: $context->workspace, + account: $context->socialAccount, + tweetText: $text, + ); + } + + return new GeneratedPost( + content: $text, + media: $media, + contentType: ContentType::tryFrom($context->format), + ); + } + + /** + * @param array $structured + */ + private function assembleCarousel(array $structured, TemplateContext $context): GeneratedPost + { + $caption = (string) data_get($structured, 'caption', ''); + + $slideTexts = array_map( + fn ($slide) => (string) data_get($slide, 'tweet_text', ''), + data_get($structured, 'slides', []), + ); + + $media = []; + + if ($context->socialAccount) { + $media = app(PostImagePipeline::class)->forTweetCardCarousel( + workspace: $context->workspace, + account: $context->socialAccount, + slides: $slideTexts, + ); + } + + return new GeneratedPost( + content: $caption, + media: $media, + contentType: ContentType::InstagramFeed, + ); + } +} diff --git a/app/Enums/Ai/ContentStyle.php b/app/Enums/Ai/ContentStyle.php new file mode 100644 index 00000000..37e081b0 --- /dev/null +++ b/app/Enums/Ai/ContentStyle.php @@ -0,0 +1,66 @@ +value}.name"; + } + + /** i18n key for the style's description. */ + public function description(): string + { + return "posts.ai.templates.{$this->value}.description"; + } + + /** Whether this style requires a connected social account. */ + public function needsAccount(): bool + { + return match ($this) { + self::ImageCard => false, + self::TweetCard, self::TweetCardImage => true, + }; + } + + /** Whether this style's generated text is run through the humanizer pass. */ + public function humanizes(): bool + { + return match ($this) { + self::ImageCard => true, + self::TweetCard, self::TweetCardImage => false, + }; + } + + /** Whether this style renders the post as a tweet-style card. */ + public function isTweetCard(): bool + { + return match ($this) { + self::TweetCard, self::TweetCardImage => true, + self::ImageCard => false, + }; + } + + /** Public path to the picker preview thumbnail. */ + public function previewAsset(): string + { + return match ($this) { + self::ImageCard => '/images/ai-templates/image-card.png', + self::TweetCard => '/images/ai-templates/tweet-card.png', + self::TweetCardImage => '/images/ai-templates/tweet-card-image.png', + }; + } + + public static function default(): self + { + return self::ImageCard; + } +} diff --git a/app/Enums/Ai/GeneratorFormat.php b/app/Enums/Ai/GeneratorFormat.php new file mode 100644 index 00000000..3d4d5bea --- /dev/null +++ b/app/Enums/Ai/GeneratorFormat.php @@ -0,0 +1,16 @@ +input('image_count', 0), prompt: $request->string('prompt')->toString(), date: $request->input('date'), + template: $request->input('template', 'image_card'), ); return response()->json([ diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 8635b4e5..65576b9c 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -9,6 +9,8 @@ use App\Actions\Post\DuplicatePost; use App\Actions\Post\SyncPostPlatforms; use App\Actions\Post\UpdatePost; +use App\Ai\Templates\AiContentTemplate; +use App\Ai\Templates\AiTemplateRegistry; use App\Enums\Post\Action as PostAction; use App\Enums\Post\Status as PostStatus; use App\Enums\SocialAccount\Platform; @@ -142,11 +144,23 @@ public function create(Request $request): Response $this->authorize('createPost', $workspace); + $registry = app(AiTemplateRegistry::class); + + $templates = array_map(fn (AiContentTemplate $t) => [ + 'key' => $t->key(), + 'name' => trans($t->name()), + 'description' => trans($t->description()), + 'preview' => $t->previewAsset(), + 'needs_account' => $t->needsAccount(), + 'supported_formats' => $t->supportedFormats(), + ], $registry->all()); + return Inertia::render('posts/Create', [ 'date' => $request->query('date'), 'socialAccounts' => SocialAccountResource::collection( $workspace->socialAccounts()->active()->get() ), + 'templates' => $templates, ]); } diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index 40fe56a4..2d24a4fa 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -4,9 +4,11 @@ namespace App\Http\Requests\App\Ai; +use App\Enums\Ai\ContentStyle; use App\Enums\PostPlatform\ContentType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StartPostCreationRequest extends FormRequest { @@ -33,6 +35,19 @@ public function rules(): array 'image_count' => ['nullable', 'integer', 'min:0', 'max:10'], 'prompt' => ['required', 'string', 'max:2000'], 'date' => ['nullable', 'date_format:Y-m-d'], + 'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)], ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $style = ContentStyle::tryFrom((string) $this->input('template', ContentStyle::default()->value)) + ?? ContentStyle::default(); + + if ($style->needsAccount() && blank($this->input('social_account_id'))) { + $validator->errors()->add('social_account_id', trans('validation.required', ['attribute' => 'social account'])); + } + }); + } } diff --git a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php index 0b0a3677..f89de16f 100644 --- a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\App\Automations; +use App\Enums\Ai\ContentStyle; use App\Enums\Automation\AuthType; use App\Enums\Automation\Condition\Operator as ConditionOperator; use App\Enums\Automation\DelayUnit; @@ -156,6 +157,7 @@ private function dataRulesForNodeType(?string $type, int $i): array 'target_slide_count' => ['nullable', 'integer', 'min:0', 'max:'.GenerateNodeValidator::MAX_GENERATED_IMAGES], 'use_brand_voice' => ['sometimes', 'boolean'], 'use_brand_visuals' => ['sometimes', 'boolean'], + 'style' => ['sometimes', Rule::in(array_column(ContentStyle::cases(), 'value'))], ], NodeType::Delay->value => [ 'duration' => ['required', 'integer', 'min:1'], diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 7d45f776..78bfdcad 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -7,6 +7,11 @@ use App\Actions\Post\CreatePost; use App\Ai\Agents\PostContentGenerator; use App\Ai\Agents\PostContentHumanizer; +use App\Ai\Templates\AiTemplateRegistry; +use App\Ai\Templates\GeneratedPost; +use App\Ai\Templates\TemplateContext; +use App\Enums\Ai\ContentStyle; +use App\Enums\Ai\GeneratorFormat; use App\Enums\Notification\Channel as NotificationChannel; use App\Enums\Notification\Type as NotificationType; use App\Enums\PostPlatform\ContentType; @@ -17,7 +22,6 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Ai\RecordAiUsage; -use App\Services\Image\PostImagePipeline; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -38,6 +42,7 @@ public function __construct( public int $imageCount, public string $prompt, public ?string $date = null, + public string $template = 'image_card', ) { $this->onQueue('ai'); } @@ -47,16 +52,27 @@ public function handle(): void $workspace = Workspace::findOrFail($this->workspaceId); $socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null; - $isCarousel = $this->format === ContentType::CAROUSEL_FORMAT; - $agentFormat = $isCarousel ? 'carousel' : 'single'; + $style = app(AiTemplateRegistry::class)->find($this->template); + $isCarousel = $this->format === ContentType::CAROUSEL_FORMAT; + $agentFormat = $isCarousel ? GeneratorFormat::Carousel : GeneratorFormat::Single; $slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1; + $context = new TemplateContext( + workspace: $workspace, + socialAccount: $socialAccount, + format: $this->format, + imageCount: $this->imageCount, + isCarousel: $isCarousel, + ); + $agent = new PostContentGenerator( workspace: $workspace, format: $agentFormat, slideCount: $slideCount, platformContext: $this->format, + template: $style, + templateContext: $context, ); try { @@ -72,18 +88,13 @@ public function handle(): void metadata: ['agent' => 'post_generator', 'format' => $this->format], ); - // StructuredAgentResponse implements ArrayAccess: access via $response['key'] $structured = $response->structured ?? []; - // Second pass: rewrite human-readable text to remove AI-tells. Image - // keywords pass through untouched (they need to stay in English). - $structured = $this->humanize($workspace, $structured, $isCarousel ? 'carousel' : 'single'); + $structured = $this->humanize($workspace, $structured, $agentFormat, $style->style()); - if ($isCarousel) { - $this->handleCarousel($workspace, $socialAccount, $structured); - } else { - $this->handleSingle($workspace, $socialAccount, $structured); - } + $generated = $style->assemble($structured, $context); + $post = $this->createPostFromGenerated($workspace, $generated, $socialAccount); + $this->notifyReady($workspace, $post); } catch (\Throwable $e) { Log::error('StreamPostCreation failed', [ 'creation_id' => $this->creationId, @@ -96,19 +107,6 @@ public function handle(): void } } - /** - * The stored content type for the requested generation format. The carousel - * generation format is persisted as an Instagram feed post. - */ - private function resolvedContentType(): ?ContentType - { - if ($this->format === ContentType::CAROUSEL_FORMAT) { - return ContentType::InstagramFeed; - } - - return ContentType::tryFrom($this->format); - } - /** * Run the structured generator output through the humanizer pass and merge * the humanized text fields back over the original structure (preserving @@ -119,10 +117,14 @@ private function resolvedContentType(): ?ContentType * @param array $structured * @return array */ - private function humanize(Workspace $workspace, array $structured, string $format): array + private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style): array { + if (! $style->humanizes()) { + return $structured; + } + try { - $input = $format === 'carousel' + $input = $format->isCarousel() ? [ 'caption' => data_get($structured, 'caption', ''), 'slides' => array_map( @@ -150,7 +152,7 @@ private function humanize(Workspace $workspace, array $structured, string $forma provider: (string) config('ai.default'), model: (string) config('ai.default_text_model'), userId: $this->userId, - metadata: ['agent' => 'post_humanizer', 'format' => $format], + metadata: ['agent' => 'post_humanizer', 'format' => $format->value], ); } catch (\Throwable $e) { Log::warning('PostContentHumanizer failed, using generator output as-is', [ @@ -161,7 +163,7 @@ private function humanize(Workspace $workspace, array $structured, string $forma return $structured; } - if ($format === 'carousel') { + if ($format->isCarousel()) { $structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? ''); $originalSlides = $structured['slides'] ?? []; $humanizedSlides = data_get($humanized, 'slides', []); @@ -183,80 +185,28 @@ private function humanize(Workspace $workspace, array $structured, string $forma return $structured; } - private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void - { - $caption = (string) data_get($structured, 'caption', ''); - - $media = []; - - if ($socialAccount) { - $media = app(PostImagePipeline::class)->forCarousel( - workspace: $workspace, - account: $socialAccount, - structured: $structured, - contentType: $this->resolvedContentType(), - ); - } - - $post = $this->createPost($workspace, $caption, $media, $socialAccount); - - $this->notifyReady($workspace, $post); - } - - /** - * @param array $structured - */ - private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void - { - $contentType = ContentType::tryFrom($this->format); - $supportsCaption = $contentType?->supportsCaption() ?? true; - - $rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', '')); - - $media = []; - - if ($this->imageCount > 0 && $socialAccount) { - $media = app(PostImagePipeline::class)->forSingle( - workspace: $workspace, - account: $socialAccount, - structured: $structured, - contentType: $this->resolvedContentType(), - ); - } - - $caption = $supportsCaption ? $rawContent : ''; - $post = $this->createPost($workspace, $caption, $media, $socialAccount); - - $this->notifyReady($workspace, $post); - } - - /** - * @param array> $media - */ - private function createPost(Workspace $workspace, string $content, array $media, ?SocialAccount $socialAccount): Post + private function createPostFromGenerated(Workspace $workspace, GeneratedPost $generated, ?SocialAccount $socialAccount): Post { $user = User::findOrFail($this->userId); $post = CreatePost::execute($workspace, $user, [ - 'content' => $content, - 'media' => $media, + 'content' => $generated->content, + 'media' => $generated->media, 'date' => $this->date, ]); - $contentType = $this->resolvedContentType(); - - if ($contentType && $socialAccount) { - $aspectRatio = $this->aspectRatioFor($contentType); + if ($generated->contentType && $socialAccount) { + $aspectRatio = $this->aspectRatioFor($generated->contentType); $post->postPlatforms() ->where('social_account_id', $socialAccount->id) - ->each(function ($platform) use ($aspectRatio, $contentType): void { + ->each(function ($platform) use ($aspectRatio, $generated): void { $meta = $platform->meta ?? []; if ($aspectRatio !== null) { $meta['aspect_ratio'] = $aspectRatio; } $platform->meta = $meta; - $platform->content_type = $contentType->value; + $platform->content_type = $generated->contentType->value; $platform->enabled = true; $platform->save(); }); diff --git a/app/Services/Image/PostImagePipeline.php b/app/Services/Image/PostImagePipeline.php index eb5b4336..0de6d4d8 100644 --- a/app/Services/Image/PostImagePipeline.php +++ b/app/Services/Image/PostImagePipeline.php @@ -80,6 +80,60 @@ public function forCarousel(Workspace $workspace, SocialAccount $account, array return $media; } + /** + * Render a tweet-card image for the given text and return a one-element + * media-item array, or an empty array when the generator renders nothing. + * + * When $imageKeywords is non-null a blurred AI-photo background is used + * (tweet_card_image); null produces the solid brand-color background (tweet_card). + * + * @param array|null $imageKeywords + * @return array> + */ + public function forTweetCard(Workspace $workspace, SocialAccount $account, string $tweetText, ?array $imageKeywords = null): array + { + $rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText, $imageKeywords); + + if (! $rendered) { + return []; + } + + return [$this->buildAiMediaItem($workspace, $rendered)]; + } + + /** + * Render one tweet-card image per slide and return the media-item array. + * Slides that render nothing are skipped. + * + * Each entry in $slides is either a plain string (tweet text, solid background) + * or an array with keys `tweet_text` and optionally `image_keywords` (image bg). + * + * @param array> $slides + * @return array> + */ + public function forTweetCardCarousel(Workspace $workspace, SocialAccount $account, array $slides): array + { + $media = []; + + foreach ($slides as $slide) { + if (is_string($slide)) { + $tweetText = $slide; + $imageKeywords = null; + } else { + $tweetText = (string) data_get($slide, 'tweet_text', ''); + $imageKeywords = data_get($slide, 'image_keywords'); + } + + $rendered = $this->generator->renderTweetCard($workspace, $account, $tweetText, $imageKeywords); + + if ($rendered) { + $media[] = $this->buildAiMediaItem($workspace, $rendered); + } + } + + return $media; + } + /** * Resolve the AI image dimensions for the given content type, falling back * to the generator defaults (4:5 portrait) when no content type is known. diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index 4f5d0fbd..64fc1193 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -548,6 +548,285 @@ private function drawHorizontalLine($core, int $x1, int $x2, int $y, string $hex imagecolordeallocate($core, $color); } + /** + * Renders the post as a fake X/Twitter card. + * + * When $imageKeywords is null (default), a solid brand-color background is used + * (original tweet_card behaviour). When $imageKeywords is a non-empty array, an + * AI-generated photo is fetched, blurred, darkened, and used as the background + * (tweet_card_image behaviour). Falls back to the solid colour when AI generation + * returns null so the render never hard-fails. + * + * @param array|null $imageKeywords + * @return array{path: string, source_meta: array}|null + */ + public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccount, string $tweetText, ?array $imageKeywords = null): ?array + { + $this->width = self::DEFAULT_WIDTH; + $this->height = self::DEFAULT_HEIGHT; + + $manager = new ImageManager(Driver::class); + + $brandColor = $workspace->brand_color ?? '#1d9bf0'; + [$pr, $pg, $pb] = $this->hexToRgb($brandColor); + + $canvas = $manager->createImage($this->width, $this->height); + $core = $canvas->core()->native(); + + imagealphablending($core, true); + imagesavealpha($core, false); + + $useImageBackground = $imageKeywords !== null && $imageKeywords !== []; + + if ($useImageBackground) { + $canvas = $this->applyTweetCardImageBackground($manager, $canvas, $core, $workspace, $imageKeywords); + $core = $canvas->core()->native(); + } else { + $pageBg = imagecolorallocate($core, $pr, $pg, $pb); + imagefill($core, 0, 0, $pageBg); + } + + $this->drawTweetCardContent($canvas, $core, $socialAccount, $tweetText); + + $template = $useImageBackground ? 'tweet_card_image' : 'tweet_card'; + $filename = "ai-images/tweet_{$template}_".uniqid('', true).'.webp'; + Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85))); + + RecordAiUsage::recordTemplate( + workspace: $workspace, + provider: 'internal', + metadata: [ + 'template' => $template, + 'width' => $this->width, + 'height' => $this->height, + ], + ); + + $sourceMeta = [ + 'template' => $template, + 'tweet_text' => $tweetText, + 'width' => $this->width, + 'height' => $this->height, + ]; + + if ($useImageBackground) { + $sourceMeta['keywords'] = array_values($imageKeywords); + } + + return [ + 'path' => $filename, + 'source_meta' => $sourceMeta, + ]; + } + + /** + * Generate and apply the blurred + darkened AI photo background for tweet_card_image. + * Falls back to a solid brand-color fill when the AI client returns null. + * + * @param array $imageKeywords + */ + private function applyTweetCardImageBackground( + ImageManager $manager, + ImageInterface $canvas, + mixed $core, + Workspace $workspace, + array $imageKeywords, + ): ImageInterface { + $rawStyle = $workspace->image_style; + $imageStyle = match (true) { + $rawStyle instanceof ImageStyle => $rawStyle, + is_string($rawStyle) => ImageStyle::tryFrom($rawStyle) ?? ImageStyle::DEFAULT, + default => ImageStyle::DEFAULT, + }; + + $imageData = $this->aiImage->generate( + keywords: $imageKeywords, + style: $imageStyle, + orientation: 'portrait', + language: $workspace->content_language, + brandColor: $workspace->brand_color, + backgroundColor: $workspace->background_color, + textColor: $workspace->text_color, + brandDescription: $workspace->brand_description, + ); + + if ($imageData === null) { + $brandColor = $workspace->brand_color ?? '#1d9bf0'; + [$pr, $pg, $pb] = $this->hexToRgb($brandColor); + $pageBg = imagecolorallocate($core, $pr, $pg, $pb); + imagefill($core, 0, 0, $pageBg); + + return $canvas; + } + + RecordAiUsage::recordImage( + workspace: $workspace, + provider: 'openai', + model: AiImageClient::MODEL, + metadata: [ + 'image_style' => $imageStyle->value, + 'width' => $this->width, + 'height' => $this->height, + ], + ); + + $photo = $manager->decodeBinary($imageData)->cover($this->width, $this->height); + + // Apply Gaussian blur passes to soften the background photo. + $photoCoreNative = $photo->core()->native(); + for ($i = 0; $i < 8; $i++) { + imagefilter($photoCoreNative, IMG_FILTER_GAUSSIAN_BLUR); + } + + // Composite the blurred photo onto the canvas. + imagecopy($core, $photoCoreNative, 0, 0, 0, 0, $this->width, $this->height); + + // Paint a semi-transparent dark overlay (~50% opacity) so the white card pops. + imagealphablending($core, true); + $dark = imagecolorallocatealpha($core, 0, 0, 0, 63); + imagefilledrectangle($core, 0, 0, $this->width - 1, $this->height - 1, $dark); + imagecolordeallocate($core, $dark); + + return $canvas; + } + + /** + * Draw the tweet card content (rounded white card, avatar, name, badge, handle, + * body text) onto the given canvas / GD core. Shared by the solid-color and + * image-background render paths. + */ + private function drawTweetCardContent(ImageInterface $canvas, mixed $core, SocialAccount $socialAccount, string $tweetText): void + { + $cardPadding = 64; + $cardX = 72; + $cardW = $this->width - 2 * $cardX; + $cardRadius = 24; + + $fontBold = $this->fontPath('Inter-Bold.ttf'); + $fontMedium = $this->fontPath('Inter-Medium.ttf'); + $fontLight = $this->fontPath('Inter-Light.ttf'); + + $avatarSize = 72; + $headerH = $avatarSize + 2 * $cardPadding; + $nameSize = 28; + $handleSize = 22; + $bodySize = 30; + $bodyLineHeight = 1.55; + $paragraphGap = (int) round($bodySize * $bodyLineHeight * 0.6); + + $paragraphs = array_values(array_filter(array_map('trim', explode("\n\n", $tweetText)), fn ($p) => $p !== '')); + $allBodyLines = []; + foreach ($paragraphs as $i => $para) { + $lines = ($fontMedium ? $this->wrapText($para, $fontMedium, $bodySize, $cardW - 2 * $cardPadding) : [str_replace("\n", ' ', $para)]); + if ($i > 0) { + $allBodyLines[] = ''; + } + foreach ($lines as $line) { + $allBodyLines[] = $line; + } + } + + $bodyBlockH = 0; + $lineSpacing = (int) round($bodySize * $bodyLineHeight); + foreach ($allBodyLines as $line) { + $bodyBlockH += ($line === '') ? $paragraphGap : $lineSpacing; + } + + $cardContentH = $headerH + 24 + $bodyBlockH + $cardPadding; + $cardH = max(400, $cardContentH); + $cardY = (int) (($this->height - $cardH) / 2); + + $this->drawRoundedRect($core, $cardX, $cardY, $cardX + $cardW, $cardY + $cardH, $cardRadius, '#ffffff'); + + $avatarX = $cardX + $cardPadding; + $avatarY = $cardY + $cardPadding; + + $avatarBinary = $this->fetchAvatarBinary($socialAccount); + if ($avatarBinary !== null) { + $this->drawCircularAvatar($canvas, $avatarBinary, $avatarX, $avatarY, $avatarSize); + } + + $nameX = $avatarX + $avatarSize + 16; + + $displayNameText = $socialAccount->display_name ?? ''; + $handleText = '@'.($socialAccount->username ?? ''); + $nameBox = $fontBold ? imagettfbbox($nameSize, 0, $fontBold, $displayNameText) : [0, 0, 0, 0, 0, 0, 0, 0]; + $handleBox = $fontLight ? imagettfbbox($handleSize, 0, $fontLight, $handleText) : [0, 0, 0, 0, 0, 0, 0, 0]; + $nameWidth = abs($nameBox[2] - $nameBox[0]); + $nameAscent = (int) round(abs($nameBox[7])); + $handleAscent = (int) round(abs($handleBox[7])); + + // Vertically center the two-line (name + handle) block against the avatar. + $handleSpacing = (int) round($nameSize * 1.4); + $blockHeight = $handleSpacing + $handleAscent; + $nameY = $avatarY + (int) round(($avatarSize - $blockHeight) / 2); + $nameBaselineY = $nameY + $nameAscent; + $handleBaselineY = $nameY + $handleSpacing + $handleAscent; + + if ($fontBold && $displayNameText !== '') { + $this->drawTextAt($core, $displayNameText, $fontBold, $nameSize, '#0f1419', $nameX, $nameBaselineY); + } + + $verifiedPath = public_path('images/ai-templates/verified.png'); + if ($fontBold && $displayNameText !== '' && file_exists($verifiedPath)) { + $badgeSize = (int) round($nameSize * 1.15); + $badgeX = $nameX + $nameWidth + 8; + $badgeY = $nameY + (int) round(($nameAscent - $badgeSize) / 2); + $verifiedSrc = @imagecreatefrompng($verifiedPath); + if ($verifiedSrc) { + $verifiedResized = imagecreatetruecolor($badgeSize, $badgeSize); + imagealphablending($verifiedResized, false); + imagesavealpha($verifiedResized, true); + $transparent = imagecolorallocatealpha($verifiedResized, 0, 0, 0, 127); + imagefill($verifiedResized, 0, 0, $transparent); + imagecopyresampled($verifiedResized, $verifiedSrc, 0, 0, 0, 0, $badgeSize, $badgeSize, imagesx($verifiedSrc), imagesy($verifiedSrc)); + imagealphablending($core, true); + imagecopy($core, $verifiedResized, $badgeX, $badgeY, 0, 0, $badgeSize, $badgeSize); + imagedestroy($verifiedSrc); + imagedestroy($verifiedResized); + } + } + + if ($fontLight && $socialAccount->username) { + $this->drawTextAt($core, $handleText, $fontLight, $handleSize, '#536471', $nameX, $handleBaselineY); + } + + $bodyStartY = $cardY + $headerH + 8; + $bodyX = $cardX + $cardPadding; + + if ($fontMedium) { + $ascent = (int) round($bodySize * 0.82); + $curY = $bodyStartY + $ascent; + foreach ($allBodyLines as $line) { + if ($line === '') { + $curY += $paragraphGap; + + continue; + } + $color = $this->allocateColor($core, '#0f1419'); + imagettftext($core, $bodySize, 0, $bodyX, $curY, $color, $fontMedium, $line); + $curY += $lineSpacing; + } + } + } + + /** + * Draw a filled rounded rectangle on the GD resource. + */ + private function drawRoundedRect($core, int $x1, int $y1, int $x2, int $y2, int $radius, string $hexColor): void + { + [$r, $g, $b] = $this->hexToRgb($hexColor); + $color = imagecolorallocate($core, $r, $g, $b); + + imagefilledrectangle($core, $x1 + $radius, $y1, $x2 - $radius, $y2, $color); + imagefilledrectangle($core, $x1, $y1 + $radius, $x2, $y2 - $radius, $color); + + imagefilledellipse($core, $x1 + $radius, $y1 + $radius, $radius * 2, $radius * 2, $color); + imagefilledellipse($core, $x2 - $radius, $y1 + $radius, $radius * 2, $radius * 2, $color); + imagefilledellipse($core, $x1 + $radius, $y2 - $radius, $radius * 2, $radius * 2, $color); + imagefilledellipse($core, $x2 - $radius, $y2 - $radius, $radius * 2, $radius * 2, $color); + } + private function fontPath(string $filename): ?string { $path = base_path('resources/fonts/'.$filename); diff --git a/lang/en/automations.php b/lang/en/automations.php index 308f748e..195fabdc 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -285,6 +285,7 @@ 'use_brand_voice_hint' => 'Apply your brand description and voice. Turn off for faithful curation of third-party sources (news, RSS).', 'use_brand_visuals' => 'Use brand visuals', 'use_brand_visuals_hint' => 'Steer AI images with your brand colors and identity. Turn off for neutral imagery driven only by the post topic.', + 'style' => 'Style', 'account_summary' => ':count account · :format|:count accounts · :format', 'formats' => [ 'single' => 'single', diff --git a/lang/en/posts.php b/lang/en/posts.php index 6fe57b73..f0034ec4 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -260,6 +260,24 @@ 'not_ai_media' => 'Only AI-generated media can be regenerated.', ], ], + 'templates' => [ + 'image_card' => [ + 'name' => 'Image post', + 'description' => 'AI image with a headline and caption.', + ], + 'carousel' => [ + 'name' => 'Carousel', + 'description' => 'Multi-slide carousel with a caption.', + ], + 'tweet_card' => [ + 'name' => 'Tweet card', + 'description' => 'Your post styled as an X/Twitter post.', + ], + 'tweet_card_image' => [ + 'name' => 'Tweet card with photo', + 'description' => 'Your post as an X/Twitter card over a blurred photo.', + ], + ], ], 'show' => [ @@ -558,6 +576,7 @@ 'ai_title' => 'Generate with AI', 'ai_description' => 'Describe what you want and AI generates the content for you.', 'ai_configure_description' => 'Pick a format and describe the post you want to create.', + 'ai_pick_template_description' => 'Choose a style for your AI-generated post.', 'template_title' => 'Use a template', 'template_description' => 'Pick from our curated templates and customize.', 'coming_soon' => 'Coming soon', @@ -568,6 +587,7 @@ ], 'steps' => [ + 'template_picker_title' => 'Choose a style', 'format_title' => 'Choose a format', 'format_description' => 'Select the type of post you want to create.', 'account_title' => 'Choose an account', @@ -604,6 +624,7 @@ 'retry' => 'Try again', 'no_platforms' => 'No connected accounts', 'connect_first' => 'Connect at least one social account to use AI generation.', + 'no_account_for_template' => 'Connect a compatible account to use this template.', 'format' => [ 'instagram_feed' => 'Instagram Feed Post', diff --git a/lang/es/automations.php b/lang/es/automations.php index 0371e776..9313bf82 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -285,6 +285,7 @@ 'use_brand_voice_hint' => 'Aplica la descripción y la voz de tu marca. Desactiva para curaduría fiel de fuentes de terceros (noticias, RSS).', 'use_brand_visuals' => 'Usar visual de marca', 'use_brand_visuals_hint' => 'Guía las imágenes de IA con los colores e identidad de tu marca. Desactiva para imágenes neutrales, guiadas solo por el tema del post.', + 'style' => 'Estilo', 'account_summary' => ':count cuenta · :format|:count cuentas · :format', 'formats' => [ 'single' => 'único', diff --git a/lang/es/posts.php b/lang/es/posts.php index c1da9111..69376882 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -260,6 +260,24 @@ 'not_ai_media' => 'Solo se puede regenerar contenido generado por IA.', ], ], + 'templates' => [ + 'image_card' => [ + 'name' => 'Publicación con imagen', + 'description' => 'Imagen con IA, titular y descripción.', + ], + 'carousel' => [ + 'name' => 'Carrusel', + 'description' => 'Carrusel de varias diapositivas con descripción.', + ], + 'tweet_card' => [ + 'name' => 'Tarjeta de tweet', + 'description' => 'Tu publicación con estilo de post de X/Twitter.', + ], + 'tweet_card_image' => [ + 'name' => 'Tarjeta de tweet con foto', + 'description' => 'Tu publicación como tarjeta de X/Twitter sobre una foto difuminada.', + ], + ], ], 'show' => [ @@ -558,6 +576,7 @@ 'ai_title' => 'Generar con IA', 'ai_description' => 'Describe lo que quieres y la IA genera el contenido por ti.', 'ai_configure_description' => 'Elige un formato y describe el post que quieres crear.', + 'ai_pick_template_description' => 'Elige un estilo para tu post generado por IA.', 'template_title' => 'Usar una plantilla', 'template_description' => 'Elige una de nuestras plantillas y personalízala.', @@ -569,6 +588,7 @@ 'coming_soon' => 'Próximamente', 'steps' => [ + 'template_picker_title' => 'Elige un estilo', 'format_title' => 'Elige un formato', 'format_description' => 'Selecciona el tipo de post que quieres crear.', 'account_title' => 'Elige una cuenta', @@ -605,6 +625,7 @@ 'retry' => 'Intentar de nuevo', 'no_platforms' => 'Sin cuentas conectadas', 'connect_first' => 'Conecta al menos una cuenta social para usar la generación con IA.', + 'no_account_for_template' => 'Conecta una cuenta compatible para usar esta plantilla.', 'format' => [ 'instagram_feed' => 'Post de Feed de Instagram', diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index d937d8f0..1b971194 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -285,6 +285,7 @@ 'use_brand_voice_hint' => 'Aplica a descrição e a voz da sua marca. Desligue para curadoria fiel de fontes de terceiros (notícias, RSS).', 'use_brand_visuals' => 'Usar visual da marca', 'use_brand_visuals_hint' => 'Guia as imagens de IA com as cores e identidade da sua marca. Desligue para imagens neutras, guiadas só pelo tema do post.', + 'style' => 'Estilo', 'account_summary' => ':count conta · :format|:count contas · :format', 'formats' => [ 'single' => 'único', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 831b745e..333c5e97 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -260,6 +260,24 @@ 'not_ai_media' => 'Só é possível regenerar mídia gerada por IA.', ], ], + 'templates' => [ + 'image_card' => [ + 'name' => 'Post com imagem', + 'description' => 'Imagem com IA, título e legenda.', + ], + 'carousel' => [ + 'name' => 'Carrossel', + 'description' => 'Carrossel de vários slides com legenda.', + ], + 'tweet_card' => [ + 'name' => 'Card de tweet', + 'description' => 'Seu post no estilo de um post do X/Twitter.', + ], + 'tweet_card_image' => [ + 'name' => 'Card de tweet com foto', + 'description' => 'Seu post como card do X/Twitter sobre uma foto desfocada.', + ], + ], ], 'show' => [ @@ -558,6 +576,7 @@ 'ai_title' => 'Gerar com IA', 'ai_description' => 'Descreva o que quer e a IA gera o conteúdo pra você.', 'ai_configure_description' => 'Escolha o formato e descreva o post que quer criar.', + 'ai_pick_template_description' => 'Escolha um estilo para o seu post gerado por IA.', 'template_title' => 'Usar um template', 'template_description' => 'Escolha um dos nossos templates e personalize.', 'coming_soon' => 'Em breve', @@ -568,6 +587,7 @@ ], 'steps' => [ + 'template_picker_title' => 'Escolha um estilo', 'format_title' => 'Escolha um formato', 'format_description' => 'Selecione o tipo de post que deseja criar.', 'account_title' => 'Escolha uma conta', @@ -604,6 +624,7 @@ 'retry' => 'Tentar novamente', 'no_platforms' => 'Nenhuma conta conectada', 'connect_first' => 'Conecte pelo menos uma conta social para usar a geração com IA.', + 'no_account_for_template' => 'Conecte uma conta compatível para usar este template.', 'format' => [ 'instagram_feed' => 'Post no Feed do Instagram', diff --git a/public/images/ai-templates/image-card.png b/public/images/ai-templates/image-card.png new file mode 100644 index 00000000..583d74f8 Binary files /dev/null and b/public/images/ai-templates/image-card.png differ diff --git a/public/images/ai-templates/tweet-card-image.png b/public/images/ai-templates/tweet-card-image.png new file mode 100644 index 00000000..059464ad Binary files /dev/null and b/public/images/ai-templates/tweet-card-image.png differ diff --git a/public/images/ai-templates/tweet-card.png b/public/images/ai-templates/tweet-card.png new file mode 100644 index 00000000..202e6132 Binary files /dev/null and b/public/images/ai-templates/tweet-card.png differ diff --git a/public/images/ai-templates/verified.png b/public/images/ai-templates/verified.png new file mode 100644 index 00000000..3220b379 Binary files /dev/null and b/public/images/ai-templates/verified.png differ diff --git a/resources/js/components/ai/ContentStylePicker.vue b/resources/js/components/ai/ContentStylePicker.vue new file mode 100644 index 00000000..258a3aa5 --- /dev/null +++ b/resources/js/components/ai/ContentStylePicker.vue @@ -0,0 +1,98 @@ + + + diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index c5452d25..e6912e26 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -1,7 +1,9 @@