Enforce carousel min slides in Generate and expose min_media_count on API/MCP.

GenerateNodeValidator and the Generate UI now respect ContentType::minMediaCount, and content-type listings share accept/min flags via toListingArray().

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-24 23:51:10 -03:00
parent 4cbdfa37d7
commit d35cc5277f
9 changed files with 118 additions and 29 deletions

View file

@ -361,6 +361,49 @@ public function mediaRules(): array
];
}
/**
* Content-type row for REST / MCP listings (agents + API clients).
* Keep in sync with mediaRules() capability fields do not omit mins.
*
* @return array{
* value: string,
* label: string,
* description: string,
* max_media_count: int,
* min_media_count: int,
* requires_media: bool,
* accept_images: bool,
* accept_videos: bool,
* accept_documents: bool,
* accepts_gif: bool,
* forbids_mixed_media: bool,
* max_video_duration_sec: int|null,
* max_image_bytes: int|null,
* max_video_bytes: int|null,
* max_document_bytes: int|null
* }
*/
public function toListingArray(): array
{
return [
'value' => $this->value,
'label' => $this->label(),
'description' => $this->description(),
'max_media_count' => $this->maxMediaCount(),
'min_media_count' => $this->minMediaCount(),
'requires_media' => $this->requiresMedia(),
'accept_images' => $this->supportsImage(),
'accept_videos' => $this->supportsVideo(),
'accept_documents' => $this->supportsDocument(),
'accepts_gif' => $this->acceptsGif(),
'forbids_mixed_media' => ! $this->supportsMixedMedia(),
'max_video_duration_sec' => $this->maxVideoDurationSec(),
'max_image_bytes' => $this->maxImageBytes(),
'max_video_bytes' => $this->maxVideoBytes(),
'max_document_bytes' => $this->maxDocumentBytes(),
];
}
/**
* @return array<string, array<string, mixed>>
*/

View file

@ -36,17 +36,7 @@ public function toArray(Request $request): array
),
'default_content_type' => ContentType::defaultFor($platform)->value,
'content_types' => array_map(
fn (ContentType $type) => [
'value' => $type->value,
'label' => $type->label(),
'description' => $type->description(),
'max_media_count' => $type->maxMediaCount(),
'requires_media' => $type->requiresMedia(),
'max_video_duration_sec' => $type->maxVideoDurationSec(),
'max_image_bytes' => $type->maxImageBytes(),
'max_video_bytes' => $type->maxVideoBytes(),
'max_document_bytes' => $type->maxDocumentBytes(),
],
fn (ContentType $type) => $type->toListingArray(),
array_values(ContentType::forPlatform($platform)),
),
];

View file

@ -14,7 +14,7 @@
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, per-type max image/video/document bytes, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')]
#[Description('List the valid content_types per social platform plus their constraints (max/min media count, whether media is required, accept_images/videos/documents/gif, forbids_mixed_media, max video duration in seconds, per-type max image/video/document bytes, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')]
class ListContentTypesTool extends Tool
{
public function handle(Request $request): ResponseFactory
@ -23,17 +23,7 @@ public function handle(Request $request): ResponseFactory
foreach (Platform::cases() as $platform) {
$contentTypes = array_map(
fn (ContentType $type) => [
'value' => $type->value,
'label' => $type->label(),
'description' => $type->description(),
'max_media_count' => $type->maxMediaCount(),
'requires_media' => $type->requiresMedia(),
'max_video_duration_sec' => $type->maxVideoDurationSec(),
'max_image_bytes' => $type->maxImageBytes(),
'max_video_bytes' => $type->maxVideoBytes(),
'max_document_bytes' => $type->maxDocumentBytes(),
],
fn (ContentType $type) => $type->toListingArray(),
array_values(ContentType::forPlatform($platform)),
);

View file

@ -60,6 +60,12 @@ private function issueForAccount(ContentType $contentType, int $imageCount): ?st
return __('automations.errors.generate_image_format_required');
}
$min = $contentType->minMediaCount();
if ($min > 0 && $imageCount < $min) {
return __('posts.edit.compliance.too_few_files', ['min' => (string) $min]);
}
if ($contentType->requiresMedia() && $imageCount === 0) {
return __('posts.edit.compliance.requires_media');
}

View file

@ -232,13 +232,21 @@ const imageCountCap = computed(() =>
),
);
// Floor at 1 when any selected account requires media (Pinterest pin, IG feed,
// etc.) otherwise the empty-accounts clamp to 0 sticks after selecting them
// and surfaces a false "add an image" compliance error.
// Floor at requiresMedia (1) or content-type minFiles (e.g. Pinterest carousel = 2)
// otherwise the empty-accounts clamp to 0 sticks after selecting them and
// surfaces a false "add an image" compliance error / under-min carousel.
const minImageCount = computed(() =>
local.value.accounts.some((a) => getMediaRulesForContentType(a.content_type).requiresMedia)
? 1
: 0,
local.value.accounts.reduce((floor, a) => {
const rules = getMediaRulesForContentType(a.content_type);
let accountMin = 0;
if (rules.requiresMedia) {
accountMin = Math.max(accountMin, 1);
}
if (rules.minFiles) {
accountMin = Math.max(accountMin, rules.minFiles);
}
return Math.max(floor, accountMin);
}, 0),
);
// Single picker: 0 = no image (text-only), 1 = single image, 2+ = carousel.

View file

@ -28,7 +28,13 @@
'label',
'description',
'max_media_count',
'min_media_count',
'requires_media',
'accept_images',
'accept_videos',
'accept_documents',
'accepts_gif',
'forbids_mixed_media',
'max_video_duration_sec',
'max_image_bytes',
'max_video_bytes',
@ -42,10 +48,14 @@
$platforms = collect($response->json('platforms'));
$instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']);
$facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']);
$pinterestTypes = collect($platforms->firstWhere('platform', 'pinterest')['content_types']);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['accept_images'])->toBeFalse();
expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90);
expect($pinterestTypes->firstWhere('value', 'pinterest_carousel')['min_media_count'])->toBe(2);
expect($pinterestTypes->firstWhere('value', 'pinterest_pin')['min_media_count'])->toBe(0);
});
it('rejects content-types without auth', function () {

View file

@ -37,6 +37,25 @@
->assertJsonValidationErrors(['nodes.1.data.accounts']);
});
it('rejects a generate node that targets Pinterest carousel below the minimum slide count', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$this->actingAs($this->user)
->putJson(route('app.automations.update', $automation->id), [
'nodes' => [
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel', 'meta' => ['board_id' => 'board-1']]],
'prompt_template' => 'hi',
'target_slide_count' => 1,
]],
],
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
])
->assertStatus(422)
->assertJsonValidationErrors(['nodes.1.data.accounts']);
});
it('allows saving a generate node within the content type limit', function () {
$automation = Automation::factory()->for($this->workspace)->create();

View file

@ -40,7 +40,13 @@
'label',
'description',
'max_media_count',
'min_media_count',
'requires_media',
'accept_images',
'accept_videos',
'accept_documents',
'accepts_gif',
'forbids_mixed_media',
'max_video_duration_sec',
'max_image_bytes',
'max_video_bytes',
@ -72,9 +78,11 @@
$platforms = collect($json->toArray()['platforms']);
$instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']);
$facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']);
$pinterestTypes = collect($platforms->firstWhere('platform', 'pinterest')['content_types']);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024);
expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90);
expect($pinterestTypes->firstWhere('value', 'pinterest_carousel')['min_media_count'])->toBe(2);
});
});

View file

@ -61,6 +61,21 @@
expect($rules['telegram_post']['accepts_gif'])->toBeTrue();
});
test('listing array mirrors media capability fields for api and mcp', function () {
$listing = ContentType::PinterestCarousel->toListingArray();
expect($listing)->toMatchArray([
'value' => 'pinterest_carousel',
'max_media_count' => 5,
'min_media_count' => 2,
'requires_media' => true,
'accept_images' => true,
'accept_videos' => false,
]);
expect(ContentType::InstagramReel->toListingArray()['accept_images'])->toBeFalse();
});
test('media rules reuse enum capability helpers', function () {
$rules = ContentType::InstagramStory->mediaRules();