Hide video-only formats in the automation Generate node.

AI generate only produces images, so Video Pin / Reel / TikTok Video are filtered out in previewOnly and rejected server-side with a clear error.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-24 23:29:58 -03:00
parent 6bfb88dede
commit 4cbdfa37d7
24 changed files with 141 additions and 15 deletions

View file

@ -506,6 +506,7 @@ public static function aiSupported(): array
self::MastodonPost,
self::FacebookPost,
self::PinterestPin,
self::PinterestCarousel,
];
}

View file

@ -54,12 +54,14 @@ public function issueFor(array $config): ?string
private function issueForAccount(ContentType $contentType, int $imageCount): ?string
{
if ($contentType->requiresMedia() && $imageCount === 0) {
return __('posts.edit.compliance.requires_media');
// Generate only produces images — video-only formats (Reel, Video Pin, …)
// can never be satisfied by this node.
if (! $contentType->supportsImage()) {
return __('automations.errors.generate_image_format_required');
}
if ($imageCount > 0 && ! $contentType->supportsImage()) {
return __('posts.edit.compliance.no_images');
if ($contentType->requiresMedia() && $imageCount === 0) {
return __('posts.edit.compliance.requires_media');
}
$max = min(self::MAX_GENERATED_IMAGES, $contentType->maxMediaCount());

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'أطلق طلب HTTP استثناءً.',
'http_request_failed' => 'فشل طلب HTTP.',
'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.',
'generate_image_format_required' => 'توليد الذكاء الاصطناعي ينشئ صورًا فقط. اختر تنسيق صورة (وليس فيديو).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'Die HTTP-Anfrage hat eine Ausnahme ausgelöst.',
'http_request_failed' => 'Die HTTP-Anfrage ist fehlgeschlagen.',
'http_items_path_not_array' => 'Der Pfad zu den Einträgen ergab keine Liste.',
'generate_image_format_required' => 'KI-Generierung erstellt nur Bilder. Wähle ein Bildformat (kein Video).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'Το αίτημα HTTP προκάλεσε εξαίρεση.',
'http_request_failed' => 'Το αίτημα HTTP απέτυχε.',
'http_items_path_not_array' => 'Η διαδρομή στοιχείων δεν αντιστοιχήθηκε σε λίστα.',
'generate_image_format_required' => 'Η δημιουργία AI παράγει μόνο εικόνες. Επίλεξε μορφή εικόνας (όχι βίντεο).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'The HTTP request threw an exception.',
'http_request_failed' => 'The HTTP request failed.',
'http_items_path_not_array' => 'The items path did not resolve to a list.',
'generate_image_format_required' => 'AI generate only creates images. Pick an image format (not video).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'La petición HTTP lanzó una excepción.',
'http_request_failed' => 'La petición HTTP falló.',
'http_items_path_not_array' => 'El items path no resolvió a una lista.',
'generate_image_format_required' => 'La generación con IA solo crea imágenes. Elige un formato de imagen (no vídeo).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'La requête HTTP a levé une exception.',
'http_request_failed' => 'La requête HTTP a échoué.',
'http_items_path_not_array' => 'Le chemin des éléments ne correspond pas à une liste.',
'generate_image_format_required' => 'La génération IA ne crée que des images. Choisissez un format image (pas vidéo).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'La richiesta HTTP ha generato un\'eccezione.',
'http_request_failed' => 'La richiesta HTTP non è riuscita.',
'http_items_path_not_array' => 'Il percorso degli elementi non ha restituito un elenco.',
'generate_image_format_required' => 'La generazione AI crea solo immagini. Scegli un formato immagine (non video).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP リクエストで例外が発生しました。',
'http_request_failed' => 'HTTP リクエストが失敗しました。',
'http_items_path_not_array' => 'アイテムのパスがリストとして解決されませんでした。',
'generate_image_format_required' => 'AI生成は画像のみ作成します。画像フォーマットを選んでください動画不可。',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP 요청에서 예외가 발생했습니다.',
'http_request_failed' => 'HTTP 요청이 실패했습니다.',
'http_items_path_not_array' => '항목 경로가 목록으로 해석되지 않았습니다.',
'generate_image_format_required' => 'AI 생성은 이미지만 만듭니다. 이미지 형식을 선택하세요(동영상 불가).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'Het HTTP-verzoek gaf een uitzondering.',
'http_request_failed' => 'Het HTTP-verzoek is mislukt.',
'http_items_path_not_array' => 'Het itempad verwees niet naar een lijst.',
'generate_image_format_required' => 'AI-generatie maakt alleen afbeeldingen. Kies een afbeeldingsformaat (geen video).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'Żądanie HTTP zgłosiło wyjątek.',
'http_request_failed' => 'Żądanie HTTP nie powiodło się.',
'http_items_path_not_array' => 'Ścieżka do elementów nie zwróciła listy.',
'generate_image_format_required' => 'Generowanie AI tworzy tylko obrazy. Wybierz format obrazu (nie wideo).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'A requisição HTTP lançou uma exceção.',
'http_request_failed' => 'A requisição HTTP falhou.',
'http_items_path_not_array' => 'O items path não resultou em uma lista.',
'generate_image_format_required' => 'A geração por IA só cria imagens. Escolha um formato de imagem (não vídeo).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP-запрос вызвал исключение.',
'http_request_failed' => 'HTTP-запрос не удался.',
'http_items_path_not_array' => 'Путь к элементам не привёл к списку.',
'generate_image_format_required' => 'ИИ генерирует только изображения. Выберите формат изображения (не видео).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP isteği bir istisna oluşturdu.',
'http_request_failed' => 'HTTP isteği başarısız oldu.',
'http_items_path_not_array' => 'Öğe yolu bir listeye çözümlenmedi.',
'generate_image_format_required' => 'AI oluşturma yalnızca görsel üretir. Görsel formatı seçin (video değil).',
],
];

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP 请求抛出了异常。',
'http_request_failed' => 'HTTP 请求失败。',
'http_items_path_not_array' => '条目路径未解析为列表。',
'generate_image_format_required' => 'AI 生成仅创建图片。请选择图片格式(不支持视频)。',
],
];

View file

@ -113,7 +113,7 @@ const defaultContentTypeFor = (platform: string): string => {
case Platform.LinkedInPage:
return ContentType.LinkedInPagePost;
case Platform.TikTok:
return ContentType.TikTokVideo;
return ContentType.TikTokPhoto;
case Platform.Pinterest:
return ContentType.PinterestPin;
case Platform.YouTube:

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
@ -38,11 +39,24 @@ const emit = defineEmits<{
const open = ref(false);
const variants = [
const allVariants = [
{ value: ContentType.FacebookPost, labelKey: 'posts.form.facebook.variant.post' },
{ value: ContentType.FacebookReel, labelKey: 'posts.form.facebook.variant.reel' },
{ value: ContentType.FacebookStory, labelKey: 'posts.form.facebook.variant.story' },
];
] as const;
const variants = computed(() => filterImageCapableVariants(allVariants, props.previewOnly));
watch(
() => [props.previewOnly, props.contentType, variants.value] as const,
() => {
const fallback = fallbackImageCapableVariant(props.contentType, variants.value);
if (fallback) {
emit('update:contentType', fallback);
}
},
{ immediate: true },
);
const aspectRatios = [
{ value: '1:1', labelKey: 'posts.form.facebook.aspect.square' },

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
@ -38,11 +39,24 @@ const emit = defineEmits<{
const open = ref(false);
const variants = [
const allVariants = [
{ value: ContentType.InstagramFeed, labelKey: 'posts.form.instagram.variant.feed' },
{ value: ContentType.InstagramReel, labelKey: 'posts.form.instagram.variant.reel' },
{ value: ContentType.InstagramStory, labelKey: 'posts.form.instagram.variant.story' },
];
] as const;
const variants = computed(() => filterImageCapableVariants(allVariants, props.previewOnly));
watch(
() => [props.previewOnly, props.contentType, variants.value] as const,
() => {
const fallback = fallbackImageCapableVariant(props.contentType, variants.value);
if (fallback) {
emit('update:contentType', fallback);
}
},
{ immediate: true },
);
const aspectRatios = [
{ value: '1:1', labelKey: 'posts.form.instagram.aspect.square' },

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Avatar } from '@/components/ui/avatar';
@ -17,6 +17,7 @@ import {
import { getMediaValidationWarning } from '@/composables/useMedia';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
import type { PinterestBoard } from '@/types';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
@ -58,11 +59,25 @@ const emit = defineEmits<{
const open = ref(false);
const variants = [
const allVariants = [
{ value: ContentType.PinterestPin, labelKey: 'posts.form.pinterest.variant.pin' },
{ value: ContentType.PinterestVideoPin, labelKey: 'posts.form.pinterest.variant.video_pin' },
{ value: ContentType.PinterestCarousel, labelKey: 'posts.form.pinterest.variant.carousel' },
];
] as const;
// Generate node only creates images hide Video Pin there.
const variants = computed(() => filterImageCapableVariants(allVariants, props.previewOnly));
watch(
() => [props.previewOnly, props.contentType, variants.value] as const,
() => {
const fallback = fallbackImageCapableVariant(props.contentType, variants.value);
if (fallback) {
emit('update:contentType', fallback);
}
},
{ immediate: true },
);
const pickVariant = (value: string) => {
if (props.disabled) return;

View file

@ -16,6 +16,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
import { ContentType } from '@/types/content-type';
interface SocialAccount {
@ -62,11 +63,24 @@ const emit = defineEmits<{
'update:contentType': [value: string];
}>();
const variants = [
const allVariants = [
{ value: ContentType.TikTokVideo, labelKey: 'posts.form.tiktok.variant.video' },
{ value: ContentType.TikTokPhoto, labelKey: 'posts.form.tiktok.variant.photo' },
] as const;
const variants = computed(() => filterImageCapableVariants(allVariants, props.previewOnly));
watch(
() => [props.previewOnly, props.contentType, variants.value] as const,
() => {
const fallback = fallbackImageCapableVariant(props.contentType, variants.value);
if (fallback) {
emit('update:contentType', fallback);
}
},
{ immediate: true },
);
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);

View file

@ -0,0 +1,32 @@
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
/**
* The automation Generate node (and AI wizard) only produce images. When
* `imageOnly` is true (previewOnly / generate context), drop video-only
* content types so they can't be selected.
*/
export const filterImageCapableVariants = <T extends { value: string }>(
variants: readonly T[],
imageOnly: boolean,
): T[] => {
if (! imageOnly) {
return [...variants];
}
return variants.filter((variant) => getMediaRulesForContentType(variant.value).acceptImages);
};
/**
* When the current content type was filtered out (e.g. Video Pin in Generate),
* return the first remaining variant to switch to or null when already valid.
*/
export const fallbackImageCapableVariant = (
contentType: string,
available: readonly { value: string }[],
): string | null => {
if (available.some((variant) => variant.value === contentType)) {
return null;
}
return available[0]?.value ?? null;
};

View file

@ -77,6 +77,25 @@
->assertJsonValidationErrors(['nodes.1.data.accounts']);
});
it('rejects a generate node that targets a video-only Pinterest format', 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_video_pin', '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 Pinterest pin generate node with one image', function () {
$automation = Automation::factory()->for($this->workspace)->create();