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:
parent
4cbdfa37d7
commit
d35cc5277f
9 changed files with 118 additions and 29 deletions
|
|
@ -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>>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue