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:
parent
6bfb88dede
commit
4cbdfa37d7
24 changed files with 141 additions and 15 deletions
|
|
@ -506,6 +506,7 @@ public static function aiSupported(): array
|
|||
self::MastodonPost,
|
||||
self::FacebookPost,
|
||||
self::PinterestPin,
|
||||
self::PinterestCarousel,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'أطلق طلب HTTP استثناءً.',
|
||||
'http_request_failed' => 'فشل طلب HTTP.',
|
||||
'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.',
|
||||
'generate_image_format_required' => 'توليد الذكاء الاصطناعي ينشئ صورًا فقط. اختر تنسيق صورة (وليس فيديو).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'Το αίτημα HTTP προκάλεσε εξαίρεση.',
|
||||
'http_request_failed' => 'Το αίτημα HTTP απέτυχε.',
|
||||
'http_items_path_not_array' => 'Η διαδρομή στοιχείων δεν αντιστοιχήθηκε σε λίστα.',
|
||||
'generate_image_format_required' => 'Η δημιουργία AI παράγει μόνο εικόνες. Επίλεξε μορφή εικόνας (όχι βίντεο).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'HTTP リクエストで例外が発生しました。',
|
||||
'http_request_failed' => 'HTTP リクエストが失敗しました。',
|
||||
'http_items_path_not_array' => 'アイテムのパスがリストとして解決されませんでした。',
|
||||
'generate_image_format_required' => 'AI生成は画像のみ作成します。画像フォーマットを選んでください(動画不可)。',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'HTTP 요청에서 예외가 발생했습니다.',
|
||||
'http_request_failed' => 'HTTP 요청이 실패했습니다.',
|
||||
'http_items_path_not_array' => '항목 경로가 목록으로 해석되지 않았습니다.',
|
||||
'generate_image_format_required' => 'AI 생성은 이미지만 만듭니다. 이미지 형식을 선택하세요(동영상 불가).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'HTTP-запрос вызвал исключение.',
|
||||
'http_request_failed' => 'HTTP-запрос не удался.',
|
||||
'http_items_path_not_array' => 'Путь к элементам не привёл к списку.',
|
||||
'generate_image_format_required' => 'ИИ генерирует только изображения. Выберите формат изображения (не видео).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'HTTP 请求抛出了异常。',
|
||||
'http_request_failed' => 'HTTP 请求失败。',
|
||||
'http_items_path_not_array' => '条目路径未解析为列表。',
|
||||
'generate_image_format_required' => 'AI 生成仅创建图片。请选择图片格式(不支持视频)。',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
32
resources/js/lib/aiGenerateVariants.ts
Normal file
32
resources/js/lib/aiGenerateVariants.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue