trypost/resources/js/composables/useMedia.ts
Paulo Castellano 90ce205763 Harden Instagram story image fitting and cover it end to end
- Guard MediaOptimizer::fitToCanvas and cropToAspectRatio against huge-dimension sources (getimagesize budget check) so they fail cleanly instead of exhausting GD memory.
- Fit and crop now translate decode/process failures into a clean InstagramPublishException and remove their temp files via finally; the two paths are symmetric.
- publishStory reads the story canvas dimensions via data_get.
- Previews: restore the IG empty-state background, align the autoFitsImage suppression predicate to isImage, drop the explanatory comment, and use single-quote imports.
- Tests: real end-to-end story fit, undecodable/download/container failures, the memory guard (fit and crop), temp-file cleanup, blurred-background pixel assertions, and the aspect-ratio warning suppression.
2026-07-16 20:09:43 -03:00

173 lines
6.8 KiB
TypeScript

import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import date from '@/date';
import { isDocument, isGif, isImage, isVideo } from '@/lib/mediaType';
import type { MediaItem } from '@/types/media';
export type { MediaItem } from '@/types/media';
// Media-type detection lives in '@/lib/mediaType' — these aliases keep the
// names the rest of the app already imports from here pointing at it.
export const isVideoMedia = isVideo;
export const isDocumentMedia = isDocument;
export const isImageMedia = isImage;
export interface MediaValidationWarning {
key: string; // short key, e.g. 'gif_not_allowed'
params: Record<string, string>;
}
export const formatBytes = (bytes: number): string => {
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
};
const formatAspect = (ratio: number): string => ratio.toFixed(2);
/**
* Return the first violation found for a given content_type + media list.
* Returns null when everything is valid.
* Checks are prioritized: presence → counts → format → per-item constraints.
*/
export const getMediaValidationWarning = (
contentType: string,
media: MediaItem[],
): MediaValidationWarning | null => {
if (! contentType) return { key: 'no_variant', params: {} };
const rules = getMediaRulesForContentType(contentType);
const videos = media.filter(isVideo);
const documents = media.filter(isDocument);
const images = media.filter(isImage);
const gifs = media.filter(isGif);
const total = media.length;
if (rules.requiresMedia && total === 0) {
return { key: 'requires_media', params: {} };
}
if (total > rules.maxFiles) {
return { key: 'max_files_exceeded', params: { max: String(rules.maxFiles), current: String(total) } };
}
if (rules.minFiles && total < rules.minFiles) {
return { key: 'min_files_required', params: { min: String(rules.minFiles), current: String(total) } };
}
if (! rules.acceptVideos && videos.length > 0) {
return { key: 'no_video_allowed', params: {} };
}
if (! rules.acceptImages && images.length > 0) {
return { key: 'no_image_allowed', params: {} };
}
if (! rules.acceptDocuments && documents.length > 0) {
return { key: 'no_document_allowed', params: {} };
}
if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) {
return { key: 'no_mixed_media', params: {} };
}
if (rules.acceptDocuments && documents.length > 0 && total > 1) {
return { key: 'document_not_alone', params: {} };
}
if (! rules.acceptsGif && gifs.length > 0) {
return { key: 'gif_not_allowed', params: {} };
}
for (const m of media) {
const size = m.size ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
const duration = m.meta?.duration ?? 0;
if (isDocument(m)) {
if (rules.maxDocumentBytes && size > rules.maxDocumentBytes) {
return {
key: 'document_too_large',
params: { max: formatBytes(rules.maxDocumentBytes), current: formatBytes(size) },
};
}
continue;
}
if (isVideo(m)) {
if (rules.maxVideoBytes && size > rules.maxVideoBytes) {
return {
key: 'video_too_large',
params: { max: formatBytes(rules.maxVideoBytes), current: formatBytes(size) },
};
}
if (rules.maxVideoDurationSec && duration > rules.maxVideoDurationSec) {
return {
key: 'video_too_long',
params: { max: date.formatDurationWords(rules.maxVideoDurationSec), current: date.formatDurationWords(duration) },
};
}
} else if (rules.maxImageBytes && size > rules.maxImageBytes) {
return {
key: 'image_too_large',
params: { max: formatBytes(rules.maxImageBytes), current: formatBytes(size) },
};
}
if (width > 0 && height > 0 && ! (rules.autoFitsImage && isImage(m))) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) {
return {
key: 'aspect_ratio_too_narrow',
params: { current: formatAspect(ratio), min: formatAspect(rules.aspectRatioMin) },
};
}
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) {
return {
key: 'aspect_ratio_too_wide',
params: { current: formatAspect(ratio), max: formatAspect(rules.aspectRatioMax) },
};
}
}
}
return null;
};
/**
* Returns a short reason key when a single media item doesn't fit a content
* type's per-item rules (type, size, duration, aspect ratio). Set-level rules
* (count, requires_media) are NOT checked here — use getMediaValidationWarning
* for those.
*/
export const getMediaItemIssue = (item: MediaItem, contentType: string): string | null => {
if (! contentType) return null;
const rules = getMediaRulesForContentType(contentType);
const itemIsVideo = isVideo(item);
const itemIsDocument = isDocument(item);
const itemIsGif = isGif(item);
if (itemIsDocument) {
if (! rules.acceptDocuments) return 'no_document_allowed';
const docSize = item.size ?? 0;
if (rules.maxDocumentBytes && docSize > rules.maxDocumentBytes) return 'document_too_large';
return null;
}
if (itemIsVideo && ! rules.acceptVideos) return 'no_video_allowed';
if (! itemIsVideo && ! rules.acceptImages) return 'no_image_allowed';
if (itemIsGif && ! rules.acceptsGif) return 'gif_not_allowed';
const size = item.size ?? 0;
if (itemIsVideo && rules.maxVideoBytes && size > rules.maxVideoBytes) return 'video_too_large';
if (! itemIsVideo && rules.maxImageBytes && size > rules.maxImageBytes) return 'image_too_large';
const duration = item.meta?.duration ?? 0;
if (itemIsVideo && rules.maxVideoDurationSec && duration > rules.maxVideoDurationSec) {
return 'video_too_long';
}
const width = item.meta?.width ?? 0;
const height = item.meta?.height ?? 0;
if (width > 0 && height > 0 && ! (rules.autoFitsImage && isImage(item))) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return 'aspect_ratio_too_narrow';
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return 'aspect_ratio_too_wide';
}
return null;
};