import type { MediaKind, MediaUploadLimits, MediaUploadPolicy } from "@/api/media-upload-contracts"; export const EMERGENCY_PHOTO_MAX_BYTES = 10_000_000; export const EMERGENCY_VIDEO_MAX_BYTES = 100_000_000; export const EMERGENCY_VIDEO_MAX_SECONDS = 90; const PHOTO_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/heic"]); const PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic"]); const VIDEO_MIME_TYPES = new Set(["video/mp4", "video/quicktime"]); const VIDEO_EXTENSIONS = new Set([".mp4", ".mov"]); export type MediaValidationProbe = (file: File) => Promise; export type MediaValidationResult = { ok: true } | { ok: false; reason: string }; function fileExtension(file: File): string { const name = file.name.toLowerCase(); const dot = name.lastIndexOf("."); return dot >= 0 ? name.slice(dot) : ""; } export function classifyMediaKind(file: File): MediaKind | null { const mime = file.type.toLowerCase(); if (PHOTO_MIME_TYPES.has(mime) || PHOTO_EXTENSIONS.has(fileExtension(file))) { return "Photo"; } if (VIDEO_MIME_TYPES.has(mime) || VIDEO_EXTENSIONS.has(fileExtension(file))) { return "Video"; } return null; } /** Inclusive limit: exactly at the cap passes, one byte over fails. */ export function isMediaSizeWithinLimit( file: File, kind: MediaKind, limits: MediaUploadLimits, ): boolean { const maxBytes = kind === "Photo" ? limits.photoMaxBytes : limits.videoMaxBytes; return file.size <= maxBytes; } export function mediaOverLimitNotice( file: File, kind: MediaKind, limits: MediaUploadLimits, ): string { if (kind === "Video") { return `"${file.name}" exceeds the video limit (${Math.round(limits.videoMaxBytes / 1_000_000)} MB, ${limits.videoMaxSeconds} seconds).`; } return `"${file.name}" exceeds the photo limit (${Math.round(limits.photoMaxBytes / 1_000_000)} MB).`; } export function mediaKindLimitReachedNotice(kind: MediaKind, limits: MediaUploadLimits): string { const max = kind === "Photo" ? limits.maxPhotos : limits.maxVideos; return `${kind} limit reached (${max} per work order). Remove a file to add another.`; } export function emergencyMediaBatchCountError( file: File, acceptedFiles: readonly File[], policy?: Pick, ): string | undefined { const kind = classifyMediaKind(file); if (!kind || !policy) return undefined; const available = kind === "Photo" ? policy.remaining.photos : policy.remaining.videos; const alreadyAccepted = acceptedFiles.filter((accepted) => classifyMediaKind(accepted) === kind); if (alreadyAccepted.length < available) return undefined; return mediaKindLimitReachedNotice(kind, defaultLimits(policy.limits)); } export function emergencyMediaAcceptAttribute(): string { return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov"; } /** * Media limits apply only to Emergency/Reactive work orders; `canUpload` from the * server policy is authoritative and work-order binding/category are fixed by route. */ export function isEmergencyReactiveWorkOrderType(workOrderType: string | undefined): boolean { if (!workOrderType) return false; const normalized = workOrderType.trim().toLowerCase(); return normalized === "emergency" || normalized === "reactive"; } /** * Probe video duration from browser metadata. Returns null whenever probing or * preview decoding fails (e.g. some MOV files) — unknown duration proceeds to the * server, which enforces the cap at commit. Temporary preview URL is always revoked. */ export function probeVideoDurationSeconds(file: File): Promise { return new Promise((resolve) => { const url = URL.createObjectURL(file); const video = document.createElement("video"); let settled = false; const finish = (value: number | null) => { if (settled) return; settled = true; clearTimeout(timer); URL.revokeObjectURL(url); video.removeAttribute("src"); resolve(value); }; const timer = setTimeout(() => finish(null), 4_000); video.preload = "metadata"; video.addEventListener("loadedmetadata", () => { const duration = video.duration; finish(Number.isFinite(duration) && duration > 0 ? duration : null); }); video.addEventListener("error", () => finish(null)); video.src = url; }); } function defaultLimits(limits?: Partial): MediaUploadLimits { return { photoMaxBytes: limits?.photoMaxBytes ?? EMERGENCY_PHOTO_MAX_BYTES, videoMaxBytes: limits?.videoMaxBytes ?? EMERGENCY_VIDEO_MAX_BYTES, videoMaxSeconds: limits?.videoMaxSeconds ?? EMERGENCY_VIDEO_MAX_SECONDS, maxPhotos: limits?.maxPhotos ?? 10, maxVideos: limits?.maxVideos ?? 3, }; } export function emergencyMediaSizeError( file: File, limits?: Partial, ): string | undefined { const kind = classifyMediaKind(file); if (!kind) return undefined; const resolvedLimits = defaultLimits(limits); return isMediaSizeWithinLimit(file, kind, resolvedLimits) ? undefined : mediaOverLimitNotice(file, kind, resolvedLimits); } /** * Validate one media file against the policy limits: kind, inclusive size caps and * duration when it can be probed. A failed duration probe keeps the file eligible. */ export async function validateMediaFileAgainstLimits( file: File, policy: Pick, probe: MediaValidationProbe = probeVideoDurationSeconds, ): Promise { const limits = defaultLimits(policy.limits); const kind = classifyMediaKind(file); if (!kind) { return { ok: false, reason: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.", }; } if (!isMediaSizeWithinLimit(file, kind, limits)) { return { ok: false, reason: mediaOverLimitNotice(file, kind, limits) }; } const remaining = kind === "Photo" ? policy.remaining?.photos : policy.remaining?.videos; if (typeof remaining === "number" && remaining <= 0) { return { ok: false, reason: mediaKindLimitReachedNotice(kind, limits) }; } if (kind === "Video") { const seconds = await probe(file); if (seconds != null && seconds > limits.videoMaxSeconds) { return { ok: false, reason: `"${file.name}" is longer than the ${limits.videoMaxSeconds} second video limit.`, }; } } return { ok: true }; }