diff --git a/app/Ai/Templates/CarouselTemplate.php b/app/Ai/Templates/CarouselTemplate.php new file mode 100644 index 00000000..9bdf3fcf --- /dev/null +++ b/app/Ai/Templates/CarouselTemplate.php @@ -0,0 +1,104 @@ +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(), + ]; + } + + public function humanizableFields(): array + { + return ['caption' => 'caption']; + } + + /** + * @param array $structured + */ + public function assemble(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, + ); + } + + return new GeneratedPost($caption, $media, ContentType::InstagramFeed); + } +} 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 @@ + $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(), + ]; + } + + public function humanizableFields(): array + { + return [ + 'content' => 'content', + 'image_title' => 'image_title', + 'image_body' => 'image_body', + ]; + } + + /** + * @param array $structured + */ + public function assemble(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, + ); + } + + $caption = $supportsCaption ? $rawContent : ''; + + return new GeneratedPost($caption, $media, $contentType); + } +} diff --git a/public/images/ai-templates/carousel.png b/public/images/ai-templates/carousel.png new file mode 100644 index 00000000..389d7eb5 Binary files /dev/null and b/public/images/ai-templates/carousel.png differ diff --git a/public/images/ai-templates/image-card.png b/public/images/ai-templates/image-card.png new file mode 100644 index 00000000..389d7eb5 Binary files /dev/null and b/public/images/ai-templates/image-card.png differ diff --git a/tests/Feature/Ai/Templates/TemplateContractTest.php b/tests/Feature/Ai/Templates/TemplateContractTest.php new file mode 100644 index 00000000..f2c5ef9d --- /dev/null +++ b/tests/Feature/Ai/Templates/TemplateContractTest.php @@ -0,0 +1,23 @@ +key())->toBe('image_card') + ->and($t->needsAccount())->toBeFalse() + ->and($t->generatorFormat())->toBe('single') + ->and($t->promptView())->toBe('prompts.post_content.generator') + ->and($t->supportedFormats())->toBe([]); +}); + +test('carousel template exposes its identity', function () { + $t = new CarouselTemplate; + expect($t->key())->toBe('carousel') + ->and($t->generatorFormat())->toBe('carousel') + ->and($t->supportedFormats())->toContain(ContentType::CAROUSEL_FORMAT); +});