import {
VIDEO_MAX_DURATION_MINUTES,
VIDEO_MAX_DURATION_MS,
VIDEO_MAX_SIZE,
VIDEO_MAX_SIZE_MB,
} from '#/lib/constants/video';
import { getImageDimensions, getVideoMetadata } from '#/lib/media/metadata';
import { openMediaPicker } from '#/lib/media/picker';
import type { VideoAsset } from '#/lib/media/video/types';
import { MAX_GALLERY_IMAGES } from '#/features/composer/state/composer';
import ImageIcon from '#/icons/central/Images1_round_outlined_radius1_stroke2.svg';
import { m } from '#/paraglide/messages';
import { ComposerToolbarButton } from './ComposerToolbarButton';
import { isAnimatedGif } from './videos/isAnimatedGif';
/** Generic asset classes, or buckets, that we support. */
export type AssetType = 'video' | 'image' | 'gif';
/** The outcome of a media selection, reported back to the composer. */
export type SelectedAssets = {
type: AssetType | undefined;
/** Selected image files, when `type` is `image`. */
images: File[];
/** The selected video or animated GIF, when `type` is `video` or `gif`. */
video: VideoAsset | undefined;
errors: string[];
};
export type SelectMediaButtonProps = {
disabled?: boolean;
/** If set, this limits the types of assets that can be selected. */
allowedAssetTypes: AssetType | undefined;
selectedAssetsCount: number;
onSelectAssets: (assets: SelectedAssets) => void | Promise<void>;
};
/** Codes for known validation states */
enum SelectedAssetError {
Unsupported = 'Unsupported',
MixedTypes = 'MixedTypes',
MaxImages = 'MaxImages',
MaxVideos = 'MaxVideos',
VideoTooLong = 'VideoTooLong',
FileTooBig = 'FileTooBig',
MaxGIFs = 'MaxGIFs',
}
const SUPPORTED_VIDEO_MIME_TYPES = ['video/mp4', 'video/mpeg', 'video/quicktime', 'video/webm'] as const;
type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number];
function isSupportedVideoMimeType(mimeType: string): mimeType is SupportedVideoMimeType {
return SUPPORTED_VIDEO_MIME_TYPES.some((supported) => supported === mimeType);
}
const SUPPORTED_IMAGE_MIME_TYPES = [
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp',
] as const;
type SupportedImageMimeType = (typeof SUPPORTED_IMAGE_MIME_TYPES)[number];
function isSupportedImageMimeType(mimeType: string): mimeType is SupportedImageMimeType {
return SUPPORTED_IMAGE_MIME_TYPES.some((supported) => supported === mimeType);
}
/** Fallback mime types inferred from a file extension, for files the browser left untyped. */
const extensionToMimeType: Record<string, string> = {
gif: 'image/gif',
heic: 'image/heic',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
mov: 'video/quicktime',
mp4: 'video/mp4',
png: 'image/png',
svg: 'image/svg+xml',
webm: 'video/webm',
webp: 'image/webp',
};
/** Bucket a file into one of our known asset types, inferring the mime type when the browser omits it. */
async function classifyFile(file: File): Promise<{ type: AssetType; mimeType: string } | undefined> {
let mimeType = file.type;
if (!mimeType) {
const extension = file.name.split('.').pop()?.toLowerCase();
mimeType = extensionToMimeType[extension || ''] ?? '';
}
if (!mimeType) {
return undefined;
}
if (mimeType === 'image/gif') {
const { isAnimated } = isAnimatedGif(await file.arrayBuffer());
return { type: isAnimated ? 'gif' : 'image', mimeType };
}
if (mimeType.startsWith('video/')) {
return { type: 'video', mimeType };
}
if (mimeType.startsWith('image/')) {
return { type: 'image', mimeType };
}
return undefined;
}
/**
* validate selected files and bucket them into a single asset type.
*
* @param files the files to validate.
* @param allowedAssetTypes optional set of allowed asset types to constrain validation.
* @returns the validated asset type.
*/
async function processFiles(
files: File[],
{
selectionCountRemaining,
allowedAssetTypes,
}: {
selectionCountRemaining: number;
allowedAssetTypes: AssetType | undefined;
},
): Promise<SelectedAssets & { errorCodes: Set<SelectedAssetError> }> {
const errors = new Set<SelectedAssetError>();
let selectableAssetType = allowedAssetTypes;
const supported: { file: File; type: AssetType; mimeType: string }[] = [];
for (const file of files) {
const classified = await classifyFile(file);
if (!classified) {
errors.add(SelectedAssetError.Unsupported);
continue;
}
const { type, mimeType } = classified;
selectableAssetType ||= type;
if (type !== selectableAssetType) {
errors.add(SelectedAssetError.MixedTypes);
continue;
}
if (type === 'video' && !isSupportedVideoMimeType(mimeType)) {
errors.add(SelectedAssetError.Unsupported);
continue;
}
if (type === 'image' && !isSupportedImageMimeType(mimeType)) {
errors.add(SelectedAssetError.Unsupported);
continue;
}
if ((type === 'video' || type === 'gif') && file.size > VIDEO_MAX_SIZE) {
errors.add(SelectedAssetError.FileTooBig);
continue;
}
supported.push({ file, type, mimeType });
}
const empty: SelectedAssets & { errorCodes: Set<SelectedAssetError> } = {
type: selectableAssetType,
images: [],
video: undefined,
errors: [],
errorCodes: errors,
};
if (supported.length === 0) {
return empty;
}
if (selectableAssetType === 'image') {
let images = supported.map((asset) => asset.file);
if (images.length > selectionCountRemaining) {
errors.add(SelectedAssetError.MaxImages);
images = images.slice(0, selectionCountRemaining);
}
return { ...empty, images };
}
if (selectableAssetType === 'video') {
if (supported.length > 1) {
errors.add(SelectedAssetError.MaxVideos);
}
const { file, mimeType } = supported[0]!;
const meta = await getVideoMetadata(file).catch(() => undefined);
if (!meta || meta.duration === null) {
errors.add(SelectedAssetError.Unsupported);
return empty;
}
if (meta.duration > VIDEO_MAX_DURATION_MS) {
errors.add(SelectedAssetError.VideoTooLong);
return empty;
}
return {
...empty,
video: { blob: file, width: meta.width, height: meta.height, mimeType, duration: meta.duration },
};
}
if (selectableAssetType === 'gif') {
if (supported.length > 1) {
errors.add(SelectedAssetError.MaxGIFs);
}
const { file, mimeType } = supported[0]!;
const dims = await getImageDimensions(file).catch(() => undefined);
if (!dims) {
errors.add(SelectedAssetError.Unsupported);
return empty;
}
return {
...empty,
video: { blob: file, width: dims.width, height: dims.height, mimeType, duration: null },
};
}
return empty;
}
export function SelectMediaButton({
disabled,
allowedAssetTypes,
selectedAssetsCount,
onSelectAssets,
}: SelectMediaButtonProps) {
const selectionCountRemaining = MAX_GALLERY_IMAGES - selectedAssetsCount;
const onPressSelectMedia = async () => {
const files = await openMediaPicker();
if (files.length === 0) {
return;
}
const { type, images, video, errorCodes } = await processFiles(files, {
selectionCountRemaining,
allowedAssetTypes,
});
const errors = Array.from(errorCodes).map((error) => {
return {
[SelectedAssetError.Unsupported]: m['view.composer.video.error.fileUnsupported'](),
[SelectedAssetError.MixedTypes]: m['view.composer.media.multipleTypes'](),
[SelectedAssetError.MaxImages]: m['view.composer.gallery.error.maxSelect']({
max: MAX_GALLERY_IMAGES,
}),
[SelectedAssetError.MaxVideos]: m['view.composer.video.error.oneOnly'](),
[SelectedAssetError.VideoTooLong]: m['view.composer.video.error.tooLong']({
minutes: VIDEO_MAX_DURATION_MINUTES,
}),
[SelectedAssetError.MaxGIFs]: m['view.composer.gif.error.oneOnly'](),
[SelectedAssetError.FileTooBig]: m['view.composer.video.error.fileTooLarge']({
max: VIDEO_MAX_SIZE_MB,
}),
}[error];
});
void onSelectAssets({ type, images, video, errors });
};
return (
<ComposerToolbarButton
icon={ImageIcon}
onClick={() => void onPressSelectMedia()}
label={m['view.composer.media.a11y.add']()}
aria-description={m['view.composer.media.a11y.addHint']({ max: MAX_GALLERY_IMAGES })}
disabled={disabled}
/>
);
}