diff --git a/src/App.tsx b/src/App.tsx index 6f9ffe1..fc5461c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { SettingsPanel } from "./components/SettingsPanel"; import { ThemeToggle } from "./components/ThemeToggle"; import { useSettings } from "./hooks/useSettings"; import { isAbortError } from "./lib/abort"; +import { describeError } from "./lib/errors"; import { ACCEPTED_TYPES, canPreviewOriginal, @@ -34,7 +35,7 @@ import { takeSharedFiles } from "./lib/share-target"; /** Files compressed in parallel. FFmpeg jobs are still serialized internally. */ const CONCURRENCY = 3; -type CompressionScope = "all" | "new" | "old"; +type CompressionScope = "all" | "new"; function createId(): string { return typeof crypto !== "undefined" && "randomUUID" in crypto @@ -45,23 +46,33 @@ function createId(): string { function isInScope(f: FileItem, scope: CompressionScope): boolean { if (f.status === "compressing" || f.status === "unsupported") return false; if (scope === "new") return f.status === "idle"; - if (scope === "old") return f.status === "done"; return true; } -async function runPool( - items: T[], - limit: number, - worker: (item: T) => Promise, -) { - let next = 0; - await Promise.all( - Array.from({ length: Math.min(limit, items.length) }, async () => { - while (next < items.length) await worker(items[next++]); - }), - ); +/** Files not compressed yet (shown under "Newly added" once others have run). */ +const isPending = (f: FileItem) => f.status === "idle" || f.status === "unsupported"; + +/** + * Runs at most `limit` tasks at once. Shared by all batches, so starting a + * new batch while another runs doesn't multiply the work in parallel. + */ +function createLimiter(limit: number) { + let active = 0; + const waiting: (() => void)[] = []; + return async function run(task: () => Promise): Promise { + if (active >= limit) await new Promise((resolve) => waiting.push(resolve)); + active++; + try { + return await task(); + } finally { + active--; + waiting.shift()?.(); + } + }; } +const limit = createLimiter(CONCURRENCY); + function outputFile(item: FileItem): File { const blob = item.compressedBlob || item.file; return new File([blob], getOutputName(item.file.name, blob.type), { type: blob.type }); @@ -191,11 +202,13 @@ export default function App() { if (newFiles.length === 0) return; const newItems: FileItem[] = newFiles.map((f) => { const kind = getFileKind(f); + const empty = f.size === 0; return { id: createId(), file: f, kind, - status: kind ? "idle" : "unsupported", + status: kind && !empty ? "idle" : "unsupported", + ...(empty && { error: "This file is empty." }), progress: 0, originalSize: f.size, previewUrl: @@ -307,6 +320,15 @@ export default function App() { const cancelFile = useCallback((id: string) => { controllers.current.get(id)?.abort(); + // A file still waiting for a slot has no running job to wind down, so + // return it to the queue right away. + setFiles((prev) => + prev.map((f) => + f.id === id && f.status === "compressing" && f.waiting + ? { ...f, status: "idle", waiting: false, progress: 0 } + : f, + ), + ); }, []); const removeFile = useCallback((id: string) => { @@ -373,6 +395,9 @@ export default function App() { if (selected.length === 0) return; const ids = new Set(selected.map((f) => f.id)); + // Controllers exist from the start so files still waiting for a free slot + // can be cancelled too. + for (const id of ids) controllers.current.set(id, new AbortController()); setSummary(null); setFiles((prev) => prev.map((f) => @@ -380,11 +405,16 @@ export default function App() { ? { ...f, status: "compressing", + waiting: true, progress: 0, compressedSize: undefined, compressedBlob: undefined, keptOriginal: false, - metadataRemoved: false, + underTarget: false, + missedTarget: undefined, + missedTargetHint: undefined, + error: undefined, + metadata: undefined, } : f, ), @@ -397,11 +427,15 @@ export default function App() { let originalBytes = 0; let failed = 0; - await runPool(selected, CONCURRENCY, async (fileItem: FileItem) => { - const controller = new AbortController(); - controllers.current.set(fileItem.id, controller); + const processFile = async (fileItem: FileItem) => { + const controller = controllers.current.get(fileItem.id)!; try { - const { blob, keptOriginal, metadataRemoved } = await compressFile( + if (controller.signal.aborted) throw new DOMException("", "AbortError"); + setFiles((prev) => + prev.map((f) => (f.id === fileItem.id ? { ...f, waiting: false } : f)), + ); + const { blob, keptOriginal, underTarget, missedTarget, missedTargetHint, metadata } = + await compressFile( fileItem.file, fileItem.id, applyOverrides(settings, fileItem.overrides), @@ -421,7 +455,10 @@ export default function App() { compressedSize: blob.size, compressedBlob: blob, keptOriginal, - metadataRemoved, + underTarget, + missedTarget, + missedTargetHint, + metadata, } : f, ), @@ -442,14 +479,22 @@ export default function App() { setFiles((prev) => prev.map((f) => f.id === fileItem.id - ? { ...f, status: cancelled ? "idle" : "error", progress: 0 } + ? { + ...f, + status: cancelled ? "idle" : "error", + waiting: false, + progress: 0, + error: cancelled ? undefined : describeError(error, fileItem.kind, fileItem.file), + } : f, ), ); } finally { controllers.current.delete(fileItem.id); } - }); + }; + + await Promise.all(selected.map((fileItem) => limit(() => processFile(fileItem)))); setProcessedBytes((prev) => prev + originalBytes); if (processed + failed > 0) { @@ -492,6 +537,8 @@ export default function App() { }; const compressible = files.filter((f) => f.status !== "unsupported"); + const newFiles = files.filter((f) => f.status === "idle"); + const hasStarted = files.some((f) => !isPending(f)); const isBusy = compressible.length === 0 || compressible.every((f) => f.status === "compressing") || @@ -506,13 +553,18 @@ export default function App() { [files], ); + // Estimate for what the main button will compress: only the newly added + // files when some have already run, otherwise everything not in progress. + const estimateForNew = newFiles.length > 0 && hasStarted; const estimate = useMemo( () => estimateSavings( - files.filter((f) => isInScope(f, "all")).map((f) => f.originalSize), + files + .filter((f) => isInScope(f, estimateForNew ? "new" : "all")) + .map((f) => f.originalSize), settings, ), - [files, settings], + [files, settings, estimateForNew], ); const compareItem = compare && files.find((f) => f.id === compare.id); @@ -522,30 +574,29 @@ export default function App() { else window.scrollTo({ top: 0, behavior: "smooth" }); }; + const primaryButton = + "w-full sm:w-auto h-12 px-8 rounded-full bg-primary text-on-accent hover:bg-primary-hover hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium whitespace-nowrap"; + const secondaryButton = + "w-full sm:w-auto h-12 px-8 rounded-full border-2 border-accent text-accent hover:bg-accent hover:text-on-accent transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium whitespace-nowrap"; + + // New files added after a batch started: compress just those, or everything. const actionButtons = - files.some((f) => f.status === "idle") && files.some((f) => f.status === "done") ? ( -
+ newFiles.length > 0 && hasStarted ? ( +
-
) : ( - ); + const renderRows = (items: FileItem[]) => + items.map((file) => ( + + )); + + // Once some files have been compressed (or are compressing), files added + // afterwards are listed separately under "Newly added". + const processedFiles = files.filter((f) => !isPending(f)); + const pendingFiles = files.filter(isPending); + const showSections = processedFiles.length > 0 && pendingFiles.length > 0; + const processedTitle = processedFiles.some((f) => f.status === "compressing") + ? "Processing" + : "Compressed"; + return (
{/* Decorative Background Elements (gradients instead of costly blur filters) */} @@ -659,6 +734,7 @@ export default function App() { update={update} targetValid={settingsValid} estimate={estimate} + estimateLabel={estimateForNew ? "Estimated savings for new files" : "Estimated savings"} kinds={kinds} actions={actionButtons} /> @@ -688,21 +764,30 @@ export default function App() {
- - {files.map((file) => ( - - ))} - + {showSections ? ( + <> +
+
+ {processedTitle} ({processedFiles.length}) +
+ {renderRows(processedFiles)} +
+
+
+ Newly added ({pendingFiles.length}) +
+ {renderRows(pendingFiles)} +
+ + ) : ( + {renderRows(files)} + )} )} diff --git a/src/components/FileRow.tsx b/src/components/FileRow.tsx index acc47bc..5adcefc 100644 --- a/src/components/FileRow.tsx +++ b/src/components/FileRow.tsx @@ -1,4 +1,5 @@ import { memo, useState } from "react"; +import { Select } from "./Select"; import { motion } from "motion/react"; import { Columns2, @@ -6,6 +7,7 @@ import { File, FileText, Image as ImageIcon, + MapPin, MapPinOff, Music, Share2, @@ -15,6 +17,7 @@ import { X, } from "lucide-react"; import type { FileKind } from "../lib/compress"; +import type { MetadataOutcome } from "../lib/image"; import { type FileOverrides, PRESET_LEVELS, formatSize } from "../lib/settings"; export interface FileItem { @@ -23,10 +26,19 @@ export interface FileItem { kind: FileKind | null; status: "idle" | "compressing" | "done" | "error" | "unsupported"; progress: number; + /** Compressing, but still waiting for a free slot. */ + waiting?: boolean; originalSize: number; compressedSize?: number; keptOriginal?: boolean; - metadataRemoved?: boolean; + /** The original already met the Custom target, so it was left as is. */ + underTarget?: boolean; + /** The Custom target in bytes, when the result is still larger. */ + missedTarget?: number; + missedTargetHint?: string; + /** Why the file failed or can't be compressed, for the user. */ + error?: string; + metadata?: MetadataOutcome; previewUrl?: string; compressedBlob?: Blob; overrides?: FileOverrides; @@ -123,7 +135,17 @@ export const FileRow = memo(function FileRow({
Original: {formatSize(file.originalSize)} {file.status === "done" && file.keptOriginal && ( - Already optimized, original kept + + {file.underTarget + ? "Already under your target, original kept" + : "Already optimized, original kept"} + + )} + {file.status === "done" && file.missedTarget !== undefined && ( + + Couldn't get under {formatSize(file.missedTarget)}; this is as small as it gets. + {file.missedTargetHint && ` ${file.missedTargetHint}`} + )} {file.status === "done" && !file.keptOriginal && file.compressedSize !== undefined && (
@@ -136,16 +158,30 @@ export const FileRow = memo(function FileRow({
)} - {file.status === "done" && file.metadataRemoved && ( + {file.status === "done" && file.metadata === "removed" && ( )} + {file.status === "done" && file.metadata === "kept" && ( + + + )} + {file.status === "done" && file.metadata === "unsupported" && ( + + + )} {file.status === "unsupported" && ( - File type not supported + {file.error ?? "File type not supported"} + )} + {file.status === "error" && ( + {file.error ?? "Compression failed"} )} - {file.status === "error" && Compression failed} {overrides.level && ( Level for this file: {overrides.level} )} @@ -156,7 +192,7 @@ export const FileRow = memo(function FileRow({ {busy && ( <> - {Math.round(file.progress)}% + {file.waiting ? "Waiting" : `${Math.round(file.progress)}%`}
- +
@@ -254,7 +258,7 @@ export function SettingsPanel({ - +
- +
{stored.video.format !== "gif" && ( @@ -316,12 +320,16 @@ export function SettingsPanel({
- Estimated savings: -
-
- {formatSize(estimate.saved)} - ({estimate.percentage}%) + {estimateLabel}:
+ {estimate.saved > 0 ? ( +
+ ≈ {formatSize(estimate.saved)} + ({estimate.percentage}%) +
+ ) : ( + none, the files are already within your target + )}
)} diff --git a/src/lib/compress.ts b/src/lib/compress.ts index 45c6123..e92b48f 100644 --- a/src/lib/compress.ts +++ b/src/lib/compress.ts @@ -1,4 +1,10 @@ -import type { CompressionSettings, FileOverrides } from "./settings"; +import type { MetadataOutcome } from "./image"; +import { CompressionError } from "./errors"; +import { + type CompressionSettings, + type FileOverrides, + isUnderTarget, +} from "./settings"; export type FileKind = "image" | "gif" | "svg" | "media" | "pdf"; @@ -41,8 +47,60 @@ export interface CompressResult { blob: Blob; /** True when compression didn't make the file smaller, so the original is returned. */ keptOriginal: boolean; - /** True when image metadata such as GPS location was stripped. */ - metadataRemoved: boolean; + /** True when the original already met the Custom target, so nothing was done. */ + underTarget?: boolean; + /** The Custom target in bytes, when the result is still larger than it. */ + missedTarget?: number; + /** What to try instead when the target was missed. */ + missedTargetHint?: string; + /** What happened to the photo's EXIF data (images only). */ + metadata?: MetadataOutcome; +} + +/** + * Whether the user asked for something beyond "make it smaller" (a new + * format, size, length or no audio), which has to happen even when the file + * already fits the target. + */ +function wantsConversion( + kind: FileKind, + file: File, + settings: CompressionSettings, + overrides?: FileOverrides, +): boolean { + if (kind === "image") { + return ( + /^image\/hei[cf]/.test(file.type) || + /\.(heic|heif)$/i.test(file.name) || + settings.image.format !== "original" || + settings.image.maxDimension !== null + ); + } + if (kind === "media") { + const trimmed = !!(overrides?.trimStart || overrides?.trimEnd); + const video = settings.video; + return ( + trimmed || + (file.type.startsWith("video/") && + (video.format !== "mp4" || video.resolution !== null || video.removeAudio)) + ); + } + return false; +} + +/** Returns the original, stripped of EXIF/XMP when metadata shouldn't be kept. */ +async function originalResult( + file: File, + kind: FileKind, + settings: CompressionSettings, + extra: Partial = {}, +): Promise { + if (kind === "image" && !settings.image.keepMetadata) { + const { stripMetadata } = await import("./metadata"); + const stripped = await stripMetadata(file); + if (stripped) return { blob: stripped, keptOriginal: true, metadata: "removed", ...extra }; + } + return { blob: file, keptOriginal: true, ...extra }; } /** Each compressor is loaded on demand so its library stays out of the main bundle. */ @@ -55,10 +113,19 @@ export async function compressFile( signal?: AbortSignal, ): Promise { const kind = getFileKind(file); + if (!kind) throw new CompressionError("This file type isn't supported."); + if (file.size === 0) throw new CompressionError("This file is empty."); + + // Already within the Custom target: don't lose quality for nothing. + if (isUnderTarget(settings, file.size) && !wantsConversion(kind, file, settings, overrides)) { + return originalResult(file, kind, settings, { underTarget: true }); + } + let blob: Blob; // A conversion the user asked for (new format, size or length) is always // returned, even when it isn't smaller than the original. let converted = false; + let metadata: MetadataOutcome | undefined; switch (kind) { case "image": { const result = await (await import("./image")).compressImage( @@ -67,7 +134,7 @@ export async function compressFile( onProgress, signal, ); - ({ blob, converted } = result); + ({ blob, converted, metadata } = result); break; } case "gif": @@ -96,18 +163,29 @@ export async function compressFile( signal, ); break; - default: - throw new Error(`Unsupported file type: ${file.type || "unknown"}`); } + + const missedTarget = + settings.level === "Custom" && settings.targetBytes !== null + ? settings.targetBytes + : null; + const missedTargetHint = + kind === "pdf" && settings.pdfMode === "keep-text" + ? "Try “Smallest size” PDF mode." + : kind === "image" && settings.image.maxDimension === null + ? "Try a smaller max size." + : undefined; if (!converted && blob.size >= file.size) { - return { blob: file, keptOriginal: true, metadataRemoved: false }; + // Compression didn't help, so return the original. + return originalResult(file, kind, settings, { + ...(missedTarget !== null && file.size > missedTarget && { missedTarget, missedTargetHint }), + }); } return { blob, keptOriginal: false, - metadataRemoved: - kind === "image" && - !(settings.image.keepMetadata && file.type === "image/jpeg" && blob.type === "image/jpeg"), + metadata, + ...(missedTarget !== null && blob.size > missedTarget && { missedTarget, missedTargetHint }), }; } diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..de5a30f --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,37 @@ +import type { FileKind } from "./compress"; + +/** An error whose message is written for the user and shown as-is. */ +export class CompressionError extends Error { + constructor(message: string) { + super(message); + this.name = "CompressionError"; + } +} + +function extensionOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot).toUpperCase() : ""; +} + +/** Turns any compression failure into a short explanation for the file's row. */ +export function describeError(error: unknown, kind: FileKind | null, file: File): string { + if (error instanceof CompressionError) return error.message; + const message = error instanceof Error ? error.message : ""; + if (file.size === 0) return "This file is empty."; + switch (kind) { + case "image": + case "gif": + case "svg": { + const ext = extensionOf(file.name); + return `Couldn't read this image. It may be damaged${ext ? `, or not really a ${ext} file` : ""}.`; + } + case "pdf": + return /encrypt|password/i.test(message) + ? "Password-protected PDFs can't be compressed. Remove the password and try again." + : "Couldn't read this PDF. It may be damaged."; + case "media": + return "Couldn't process this file. It may be damaged or use a format the video engine doesn't support."; + default: + return "This file type isn't supported."; + } +} diff --git a/src/lib/image.ts b/src/lib/image.ts index 56a3c01..47ff21b 100644 --- a/src/lib/image.ts +++ b/src/lib/image.ts @@ -3,6 +3,7 @@ import imageCompression from "browser-image-compression"; // downloaded from a CDN, which the privacy promise rules out. import imageCompressionLibUrl from "browser-image-compression/dist/browser-image-compression.js?url"; import { runWorkerJob, throwIfAborted } from "./abort"; +import { readExif, resetOrientation, writeExif } from "./metadata"; import { AVIF_CUSTOM_QUALITIES, type CompressionSettings, @@ -10,10 +11,18 @@ import { getTargetRatio, } from "./settings"; +/** + * What happened to the photo's EXIF data: "removed" (stripped, the default), + * "kept" (copied into the output), "unsupported" (the user asked to keep it + * but the output format can't hold it). Undefined when the original had none. + */ +export type MetadataOutcome = "removed" | "kept" | "unsupported"; + export interface ImageResult { blob: Blob; /** True when the user asked for a different format or size, so the result must be used. */ converted: boolean; + metadata?: MetadataOutcome; } export function isHeic(file: File): boolean { @@ -53,6 +62,11 @@ export async function compressImage( const ratio = getTargetRatio(settings, input.size); const isCustom = settings.level === "Custom"; + // Read EXIF from the original (before any HEIC conversion, which drops it). + const exif = await readExif(file).catch(() => null); + throwIfAborted(signal); + + let blob: Blob; if (outputType === "image/avif") { const worker = new Worker(new URL("./avif.worker.ts", import.meta.url), { type: "module", @@ -71,27 +85,58 @@ export async function compressImage( onProgress, signal, ); - return { blob: new Blob([buffer], { type: "image/avif" }), converted: true }; + blob = new Blob([buffer], { type: "image/avif" }); + } else { + const targetMB = (input.size * ratio) / (1024 * 1024); + const baseOptions = { + fileType: outputType, + // Metadata is handled below for every format, not just JPEG to JPEG. + preserveExif: false, + useWebWorker: true, + libURL: new URL(imageCompressionLibUrl, location.href).href, + maxIteration: 30, + signal, + }; + blob = await imageCompression(input, { + ...baseOptions, + // Undershoot custom targets by 25% so the result stays under them. + maxSizeMB: Math.max(isCustom ? targetMB * 0.75 : targetMB, 0.001), + maxWidthOrHeight: maxDimension ?? undefined, + // Low/Medium keep the original dimensions; stronger levels may downscale, + // which is the only way lossless formats like PNG get meaningfully smaller. + alwaysKeepResolution: + maxDimension === null && + (settings.level === "Low" || settings.level === "Medium"), + // A generous target (e.g. only converting the format) shouldn't start + // from low quality. + initialQuality: isCustom ? (ratio >= 0.9 ? 0.92 : 0.6) : 0.8, + onProgress, + }); + + // Small targets (e.g. 20 KB) can be out of reach at full size even at the + // lowest quality, so keep shrinking the image until it fits. + const targetBytes = isCustom ? settings.targetBytes : null; + if (targetBytes && blob.size > targetBytes) { + const bitmap = await createImageBitmap(input); + let longest = Math.max(bitmap.width, bitmap.height); + bitmap.close(); + for (let attempt = 0; attempt < 8 && blob.size > targetBytes && longest > 64; attempt++) { + throwIfAborted(signal); + longest = Math.round(longest * 0.7); + blob = await imageCompression(input, { + ...baseOptions, + maxSizeMB: (targetBytes * 0.9) / (1024 * 1024), + maxWidthOrHeight: longest, + initialQuality: 0.6, + }); + } + } } - const targetMB = (input.size * ratio) / (1024 * 1024); - const blob = await imageCompression(input, { - // Undershoot custom targets by 25% so the result stays under them. - maxSizeMB: Math.max(isCustom ? targetMB * 0.75 : targetMB, 0.01), - maxWidthOrHeight: maxDimension ?? undefined, - // Low/Medium keep the original dimensions; stronger levels may downscale, - // which is the only way lossless formats like PNG get meaningfully smaller. - alwaysKeepResolution: - maxDimension === null && - (settings.level === "Low" || settings.level === "Medium"), - fileType: outputType, - preserveExif: keepMetadata, - useWebWorker: true, - libURL: new URL(imageCompressionLibUrl, location.href).href, - maxIteration: 30, - initialQuality: isCustom ? 0.6 : 0.8, - signal, - onProgress, - }); - return { blob, converted }; + if (!exif) return { blob, converted }; + if (!keepMetadata) return { blob, converted, metadata: "removed" }; + const withExif = await writeExif(blob, resetOrientation(exif)); + return withExif + ? { blob: withExif, converted, metadata: "kept" } + : { blob, converted, metadata: "unsupported" }; } diff --git a/src/lib/media.ts b/src/lib/media.ts index 09c90b8..9c2b15e 100644 --- a/src/lib/media.ts +++ b/src/lib/media.ts @@ -1,5 +1,6 @@ import type { FFmpeg } from "@ffmpeg/ffmpeg"; import { abortError, throwIfAborted } from "./abort"; +import { CompressionError } from "./errors"; import { type CompressionSettings, type FileOverrides, @@ -33,10 +34,18 @@ export function loadFFmpeg(): Promise { import("@ffmpeg/util"), ]); const ffmpeg = new FFmpeg(); - await ffmpeg.load({ - coreURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"), - wasmURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"), - }); + try { + await ffmpeg.load({ + coreURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"), + wasmURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"), + }); + } catch { + throw new CompressionError( + navigator.onLine === false + ? "Video and audio need an internet connection the first time, to download the 31 MB video engine. It works offline after that." + : "Couldn't download the video engine. Check your connection and try again.", + ); + } return ffmpeg; })(); // Allow a retry after a failed load (e.g. a network blip). @@ -60,26 +69,38 @@ function getExtension(name: string): string { return dot > 0 ? name.slice(dot + 1).toLowerCase() : "bin"; } -/** Reads the duration from the file's metadata; null if the browser can't. */ -function getMediaDuration(file: File): Promise { +interface MediaInfo { + /** Seconds, or null when the browser can't tell. */ + duration: number | null; + /** False for audio-only files, null when the browser can't tell. */ + hasVideo: boolean | null; +} + +/** Reads duration and whether there's a video track from the file's metadata. */ +function probeMedia(file: File): Promise { return new Promise((resolve) => { const url = URL.createObjectURL(file); - const el = document.createElement( - file.type.startsWith("video/") ? "video" : "audio", - ); - const done = (value: number | null) => { + const isVideoType = file.type.startsWith("video/"); + const el = document.createElement(isVideoType ? "video" : "audio"); + const done = (info: MediaInfo) => { URL.revokeObjectURL(url); el.removeAttribute("src"); - resolve(value); + resolve(info); }; el.preload = "metadata"; el.onloadedmetadata = () => - done(Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null); - el.onerror = () => done(null); + done({ + // Recordings from MediaRecorder report Infinity until fully read. + duration: Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null, + hasVideo: isVideoType ? (el as HTMLVideoElement).videoWidth > 0 : false, + }); + el.onerror = () => done({ duration: null, hasVideo: null }); el.src = url; }); } +const seconds = (value: number) => `${Math.round(value * 10) / 10} s`; + const OUTPUT = { mp4: { ext: "mp4", type: "video/mp4" }, webm: { ext: "webm", type: "video/webm" }, @@ -101,20 +122,27 @@ export async function compressMedia( onProgress: (percent: number) => void, signal?: AbortSignal, ): Promise { - const isVideo = file.type.startsWith("video/"); + const trimStart = overrides?.trimStart && overrides.trimStart > 0 ? overrides.trimStart : null; + const trimEnd = overrides?.trimEnd && overrides.trimEnd > 0 ? overrides.trimEnd : null; + if (trimEnd !== null && trimEnd <= (trimStart ?? 0)) { + throw new CompressionError("The trim end must be after the start."); + } + + const info = await probeMedia(file); + if (info.duration !== null && trimStart !== null && trimStart >= info.duration) { + throw new CompressionError( + `The trim start (${seconds(trimStart)}) is past the end of the clip (${seconds(info.duration)}).`, + ); + } + // Voice recordings often come as video/webm with no picture: treat as audio. + const isVideo = file.type.startsWith("video/") && info.hasVideo !== false; const { format, resolution, removeAudio } = settings.video; const output = isVideo ? OUTPUT[format] : OUTPUT.audio; const ratio = getTargetRatio(settings, file.size); - const trimStart = overrides?.trimStart && overrides.trimStart > 0 ? overrides.trimStart : null; - const trimEnd = - overrides?.trimEnd && overrides.trimEnd > (trimStart ?? 0) ? overrides.trimEnd : null; - const needsDuration = isVideo && settings.level === "Custom" && settings.targetBytes; - const fullDuration = needsDuration ? await getMediaDuration(file) : null; + const end = Math.min(trimEnd ?? Infinity, info.duration ?? Infinity); const duration = - fullDuration === null - ? null - : (trimEnd ?? fullDuration) - (trimStart ?? 0); + info.duration === null && trimEnd === null ? null : end - (trimStart ?? 0); const { fetchFile } = await import("@ffmpeg/util"); throwIfAborted(signal); @@ -144,7 +172,7 @@ export async function compressMedia( } else { const audioKbps = 96; const targetKbps = - duration && settings.targetBytes + duration && settings.level === "Custom" && settings.targetBytes ? getVideoBitrateForTarget( settings.targetBytes, duration, @@ -202,9 +230,16 @@ export async function compressMedia( const exitCode = await ffmpeg.exec(args); throwIfAborted(signal); if (exitCode !== 0) { - throw new Error(`FFmpeg exited with code ${exitCode}`); + throw new CompressionError( + "Couldn't process this file. It may be damaged or use a format the video engine doesn't support.", + ); } const data = (await ffmpeg.readFile(outputName)) as Uint8Array; + // A trim outside the clip (when the length couldn't be read up front) + // yields a container with no frames. + if (data.length < 1024 && file.size > 8 * 1024) { + throw new CompressionError("Nothing was left after compressing. Check the trim range."); + } return { blob: new Blob([data], { type: output.type }), converted: diff --git a/src/lib/metadata.test.ts b/src/lib/metadata.test.ts new file mode 100644 index 0000000..601e35c --- /dev/null +++ b/src/lib/metadata.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { readExif, resetOrientation, stripMetadata, writeExif } from "./metadata"; + +/** Little-endian TIFF with IFD0 = { Orientation: 6 (rotated), Make: "Cam" }. */ +function makeTiff(orientation = 6): Uint8Array { + const bytes = new Uint8Array(8 + 2 + 2 * 12 + 4); + const view = new DataView(bytes.buffer); + bytes.set([0x49, 0x49, 0x2a, 0x00]); + view.setUint32(4, 8, true); + view.setUint16(8, 2, true); + // Make (0x010f), ASCII, count 4, value inline "Cam\0" + view.setUint16(10, 0x010f, true); + view.setUint16(12, 2, true); + view.setUint32(14, 4, true); + bytes.set([0x43, 0x61, 0x6d, 0x00], 18); + // Orientation (0x0112), SHORT, count 1 + view.setUint16(22, 0x0112, true); + view.setUint16(24, 3, true); + view.setUint32(26, 1, true); + view.setUint16(30, orientation, true); + return bytes; +} + +const orientationOf = (tiff: Uint8Array) => new DataView(tiff.buffer, tiff.byteOffset).getUint16(30, true); + +/** Minimal JPEG: SOI, APP0 (JFIF), SOS with a few bytes, EOI. */ +function makeJpeg(): Blob { + const app0 = [0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 1, 1, 0, 0, 1, 0, 1, 0, 0]; + const sos = [0xff, 0xda, 0x00, 0x04, 0x01, 0x02, 0x11, 0x22, 0x33, 0xff, 0xd9]; + return new Blob([new Uint8Array([0xff, 0xd8, ...app0, ...sos])], { type: "image/jpeg" }); +} + +/** Minimal PNG: signature, IHDR, IDAT, IEND (CRCs aren't checked by the reader). */ +function makePng(): Blob { + const chunk = (type: string, data: number[]) => { + const len = data.length; + return [0, 0, 0, len, ...Array.from(type, (c) => c.charCodeAt(0)), ...data, 0, 0, 0, 0]; + }; + const bytes = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ...chunk("IHDR", [0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]), + ...chunk("IDAT", [1, 2, 3]), + ...chunk("IEND", []), + ]; + return new Blob([new Uint8Array(bytes)], { type: "image/png" }); +} + +/** Minimal lossy WebP: RIFF + VP8 chunk with a 640x480 frame header. */ +function makeWebp(): Blob { + const vp8 = [0x9d, 0x01, 0x2a, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01]; + const body = [..."WEBP"].map((c) => c.charCodeAt(0)).concat( + [..."VP8 "].map((c) => c.charCodeAt(0)), + [vp8.length, 0, 0, 0], + vp8, + ); + const riff = [..."RIFF"].map((c) => c.charCodeAt(0)).concat([body.length, 0, 0, 0], body); + return new Blob([new Uint8Array(riff)], { type: "image/webp" }); +} + +describe("metadata", () => { + it("resets the orientation tag to upright", () => { + const tiff = makeTiff(6); + expect(orientationOf(resetOrientation(tiff))).toBe(1); + expect(orientationOf(tiff)).toBe(6); // original untouched + }); + + for (const [name, make] of [ + ["JPEG", makeJpeg], + ["PNG", makePng], + ["WebP", makeWebp], + ] as const) { + it(`round-trips EXIF through ${name}`, async () => { + const image = make(); + expect(await readExif(image)).toBeNull(); + const tiff = makeTiff(); + const withExif = await writeExif(image, tiff); + expect(withExif).not.toBeNull(); + expect(withExif!.type).toBe(image.type); + expect(Array.from((await readExif(withExif!))!)).toEqual(Array.from(tiff)); + + const stripped = await stripMetadata(withExif!); + expect(stripped).not.toBeNull(); + expect(await readExif(stripped!)).toBeNull(); + // WebP keeps its (now flag-less) extended VP8X header. + expect(stripped!.size).toBeLessThan(withExif!.size); + }); + } + + it("keeps the JFIF segment first in JPEG", async () => { + const out = new Uint8Array(await (await writeExif(makeJpeg(), makeTiff()))!.arrayBuffer()); + expect(Array.from(out.subarray(0, 4))).toEqual([0xff, 0xd8, 0xff, 0xe0]); + }); + + it("marks WebP as extended with the EXIF flag", async () => { + const out = new Uint8Array(await (await writeExif(makeWebp(), makeTiff()))!.arrayBuffer()); + expect(String.fromCharCode(...out.subarray(12, 16))).toBe("VP8X"); + expect(out[20] & 0x08).toBe(0x08); + // Canvas size 640x480, stored minus one in 24 bits. + expect(out[24] | (out[25] << 8)).toBe(639); + expect(out[27] | (out[28] << 8)).toBe(479); + }); + + it("can't write EXIF into AVIF", async () => { + expect(await writeExif(new Blob([new Uint8Array(16)], { type: "image/avif" }), makeTiff())).toBeNull(); + }); + + it("returns null when there's nothing to strip", async () => { + expect(await stripMetadata(makeJpeg())).toBeNull(); + }); +}); diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts new file mode 100644 index 0000000..eb01423 --- /dev/null +++ b/src/lib/metadata.ts @@ -0,0 +1,401 @@ +// Copies EXIF metadata (camera, date, GPS) from the original image into the +// compressed one. Canvas re-encoding always drops it, so it is read from the +// source file as raw TIFF bytes and written back into the output container. +// +// Read: JPEG (APP1), PNG (eXIf), WebP (EXIF chunk), HEIC/HEIF (Exif item). +// Write: JPEG, PNG, WebP. AVIF can't carry it in a way browsers keep. + +const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0" + +const ascii = (bytes: Uint8Array, start: number, length: number) => + String.fromCharCode(...bytes.subarray(start, start + length)); + +function startsWithExifHeader(bytes: Uint8Array, offset = 0) { + return EXIF_HEADER.every((b, i) => bytes[offset + i] === b); +} + +function isTiff(bytes: Uint8Array) { + return ( + bytes.length >= 8 && + ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0) || + (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0 && bytes[3] === 0x2a)) + ); +} + +/** Strips an optional "Exif\0\0" prefix and checks for a TIFF header. */ +function toTiff(bytes: Uint8Array): Uint8Array | null { + const tiff = startsWithExifHeader(bytes) ? bytes.subarray(6) : bytes; + return isTiff(tiff) ? tiff : null; +} + +function readJpegExif(bytes: Uint8Array): Uint8Array | null { + let offset = 2; + while (offset + 4 <= bytes.length && bytes[offset] === 0xff) { + const marker = bytes[offset + 1]; + if (marker === 0xda || marker === 0xd9) break; // start of scan / end of image + const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; + if (marker === 0xe1 && startsWithExifHeader(bytes, offset + 4)) { + return toTiff(bytes.subarray(offset + 4, offset + 2 + length)); + } + offset += 2 + length; + } + return null; +} + +function readPngExif(bytes: Uint8Array): Uint8Array | null { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = 8; + while (offset + 12 <= bytes.length) { + const length = view.getUint32(offset); + const type = ascii(bytes, offset + 4, 4); + if (type === "eXIf") return toTiff(bytes.subarray(offset + 8, offset + 8 + length)); + if (type === "IDAT" || type === "IEND") break; + offset += 12 + length; + } + return null; +} + +interface RiffChunk { + type: string; + data: Uint8Array; +} + +function readRiffChunks(bytes: Uint8Array): RiffChunk[] { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const chunks: RiffChunk[] = []; + let offset = 12; + while (offset + 8 <= bytes.length) { + const type = ascii(bytes, offset, 4); + const size = view.getUint32(offset + 4, true); + chunks.push({ type, data: bytes.subarray(offset + 8, offset + 8 + size) }); + offset += 8 + size + (size % 2); + } + return chunks; +} + +function readWebpExif(bytes: Uint8Array): Uint8Array | null { + const chunk = readRiffChunks(bytes).find((c) => c.type === "EXIF"); + return chunk ? toTiff(chunk.data) : null; +} + +/** Finds the "Exif" item in a HEIF container (meta → iinf + iloc). */ +function readHeifExif(bytes: Uint8Array): Uint8Array | null { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const readUint = (offset: number, size: number) => + size === 0 ? 0 : size === 2 ? view.getUint16(offset) : size === 4 ? view.getUint32(offset) : Number(view.getBigUint64(offset)); + + const boxes = (start: number, end: number) => { + const found: { type: string; start: number; end: number }[] = []; + let offset = start; + while (offset + 8 <= end) { + let size = view.getUint32(offset); + const type = ascii(bytes, offset + 4, 4); + let header = 8; + if (size === 1) { + size = Number(view.getBigUint64(offset + 8)); + header = 16; + } else if (size === 0) { + size = end - offset; + } + if (size < header) break; + found.push({ type, start: offset + header, end: offset + size }); + offset += size; + } + return found; + }; + + const meta = boxes(0, bytes.length).find((b) => b.type === "meta"); + if (!meta) return null; + const children = boxes(meta.start + 4, meta.end); // meta is a full box + const iinf = children.find((b) => b.type === "iinf"); + const iloc = children.find((b) => b.type === "iloc"); + if (!iinf || !iloc) return null; + + // Item info: which item ID is the Exif block? + const iinfVersion = bytes[iinf.start]; + const entriesStart = iinf.start + 4 + (iinfVersion === 0 ? 2 : 4); + let exifId: number | null = null; + for (const infe of boxes(entriesStart, iinf.end)) { + if (infe.type !== "infe") continue; + const version = bytes[infe.start]; + if (version < 2) continue; + const idSize = version === 2 ? 2 : 4; + const id = readUint(infe.start + 4, idSize); + if (ascii(bytes, infe.start + 4 + idSize + 2, 4) === "Exif") { + exifId = id; + break; + } + } + if (exifId === null) return null; + + // Item location: where are its bytes in the file? + const version = bytes[iloc.start]; + let offset = iloc.start + 4; + const offsetSize = bytes[offset] >> 4; + const lengthSize = bytes[offset] & 0xf; + const baseOffsetSize = bytes[offset + 1] >> 4; + const indexSize = version === 1 || version === 2 ? bytes[offset + 1] & 0xf : 0; + offset += 2; + const itemCount = readUint(offset, version < 2 ? 2 : 4); + offset += version < 2 ? 2 : 4; + for (let i = 0; i < itemCount; i++) { + const id = readUint(offset, version < 2 ? 2 : 4); + offset += version < 2 ? 2 : 4; + let constructionMethod = 0; + if (version === 1 || version === 2) { + constructionMethod = view.getUint16(offset) & 0xf; + offset += 2; + } + offset += 2; // data_reference_index + const baseOffset = readUint(offset, baseOffsetSize); + offset += baseOffsetSize; + const extentCount = view.getUint16(offset); + offset += 2; + const extents: { start: number; length: number }[] = []; + for (let e = 0; e < extentCount; e++) { + offset += indexSize; + const extentOffset = readUint(offset, offsetSize); + offset += offsetSize; + const extentLength = readUint(offset, lengthSize); + offset += lengthSize; + extents.push({ start: baseOffset + extentOffset, length: extentLength }); + } + if (id !== exifId) continue; + if (constructionMethod !== 0 || extents.length !== 1) return null; + const { start, length } = extents[0]; + const item = bytes.subarray(start, start + length); + // The item starts with a 4-byte offset to the TIFF header. + const tiffOffset = 4 + new DataView(item.buffer, item.byteOffset).getUint32(0); + return toTiff(item.subarray(tiffOffset)); + } + return null; +} + +/** Raw EXIF (TIFF) bytes from an image file, or null if it has none. */ +export async function readExif(file: Blob): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + try { + if (bytes[0] === 0xff && bytes[1] === 0xd8) return readJpegExif(bytes); + if (bytes[0] === 0x89 && ascii(bytes, 1, 3) === "PNG") return readPngExif(bytes); + if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") return readWebpExif(bytes); + if (ascii(bytes, 4, 4) === "ftyp") return readHeifExif(bytes); + } catch { + // Malformed container: treat as no metadata. + } + return null; +} + +/** + * Sets the Orientation tag to 1 (upright). The browser already rotated the + * pixels when decoding, so keeping the old value would rotate them twice. + */ +export function resetOrientation(tiff: Uint8Array): Uint8Array { + const copy = tiff.slice(); + const view = new DataView(copy.buffer); + const little = copy[0] === 0x49; + const ifd = view.getUint32(4, little); + if (ifd + 2 > copy.length) return copy; + const count = view.getUint16(ifd, little); + for (let i = 0; i < count; i++) { + const entry = ifd + 2 + i * 12; + if (entry + 12 > copy.length) break; + if (view.getUint16(entry, little) === 0x0112 && view.getUint16(entry + 2, little) === 3) { + view.setUint16(entry + 8, 1, little); + } + } + return copy; +} + +function concat(parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function writeJpegExif(jpeg: Uint8Array, tiff: Uint8Array): Uint8Array | null { + const segmentLength = 2 + EXIF_HEADER.length + tiff.length; + if (segmentLength > 0xffff) return null; + const app1 = concat([ + new Uint8Array([0xff, 0xe1, segmentLength >> 8, segmentLength & 0xff]), + new Uint8Array(EXIF_HEADER), + tiff, + ]); + // Keep the JFIF (APP0) segment first if present; drop any existing Exif APP1. + const parts: Uint8Array[] = [jpeg.subarray(0, 2)]; + let offset = 2; + let inserted = false; + while (offset + 4 <= jpeg.length && jpeg[offset] === 0xff) { + const marker = jpeg[offset + 1]; + if (marker === 0xda) break; + const length = (jpeg[offset + 2] << 8) | jpeg[offset + 3]; + const segment = jpeg.subarray(offset, offset + 2 + length); + if (!inserted && marker !== 0xe0) { + parts.push(app1); + inserted = true; + } + if (!(marker === 0xe1 && startsWithExifHeader(jpeg, offset + 4))) parts.push(segment); + offset += 2 + length; + } + if (!inserted) parts.push(app1); + parts.push(jpeg.subarray(offset)); + return concat(parts); +} + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const b of bytes) crc = CRC_TABLE[(crc ^ b) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function writePngExif(png: Uint8Array, tiff: Uint8Array): Uint8Array | null { + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + const ihdrEnd = 8 + 12 + view.getUint32(8); + const typeAndData = concat([new TextEncoder().encode("eXIf"), tiff]); + const chunk = new Uint8Array(12 + tiff.length); + const chunkView = new DataView(chunk.buffer); + chunkView.setUint32(0, tiff.length); + chunk.set(typeAndData, 4); + chunkView.setUint32(8 + tiff.length, crc32(typeAndData)); + return concat([png.subarray(0, ihdrEnd), chunk, png.subarray(ihdrEnd)]); +} + +function riffChunk(type: string, data: Uint8Array): Uint8Array { + const header = new Uint8Array(8); + header.set(new TextEncoder().encode(type), 0); + new DataView(header.buffer).setUint32(4, data.length, true); + return concat([header, data, new Uint8Array(data.length % 2)]); +} + +function writeWebpExif(webp: Uint8Array, tiff: Uint8Array): Uint8Array | null { + const chunks = readRiffChunks(webp).filter((c) => c.type !== "EXIF"); + let vp8x = chunks.find((c) => c.type === "VP8X")?.data.slice(); + if (!vp8x) { + // Simple format (VP8 or VP8L only): an extended VP8X header is needed + // before metadata chunks are allowed. + const image = chunks[0]; + let width: number; + let height: number; + let alpha = false; + if (image?.type === "VP8 ") { + const d = new DataView(image.data.buffer, image.data.byteOffset); + width = d.getUint16(6, true) & 0x3fff; + height = d.getUint16(8, true) & 0x3fff; + } else if (image?.type === "VP8L") { + const bits = new DataView(image.data.buffer, image.data.byteOffset).getUint32(1, true); + width = (bits & 0x3fff) + 1; + height = ((bits >> 14) & 0x3fff) + 1; + alpha = ((bits >> 28) & 1) === 1; + } else { + return null; + } + vp8x = new Uint8Array(10); + if (alpha) vp8x[0] |= 0x10; + const w = width - 1; + const h = height - 1; + vp8x.set([w & 0xff, (w >> 8) & 0xff, (w >> 16) & 0xff, h & 0xff, (h >> 8) & 0xff, (h >> 16) & 0xff], 4); + } + vp8x[0] |= 0x08; // EXIF flag + const body = concat([ + riffChunk("VP8X", vp8x), + ...chunks.filter((c) => c.type !== "VP8X").map((c) => riffChunk(c.type, c.data)), + riffChunk("EXIF", tiff), + ]); + const header = new Uint8Array(12); + header.set(new TextEncoder().encode("RIFF"), 0); + new DataView(header.buffer).setUint32(4, body.length + 4, true); + header.set(new TextEncoder().encode("WEBP"), 8); + return concat([header, body]); +} + +/** Returns the image with the EXIF block inserted, or null if its format can't hold it. */ +export async function writeExif(image: Blob, tiff: Uint8Array): Promise { + const bytes = new Uint8Array(await image.arrayBuffer()); + let out: Uint8Array | null = null; + try { + if (image.type === "image/jpeg") out = writeJpegExif(bytes, tiff); + else if (image.type === "image/png") out = writePngExif(bytes, tiff); + else if (image.type === "image/webp") out = writeWebpExif(bytes, tiff); + } catch { + out = null; + } + return out ? new Blob([out], { type: image.type }) : null; +} + +const XMP_HEADER = "http://ns.adobe.com/xap/1.0/\0"; + +/** + * Removes EXIF and XMP (which can also hold GPS) without re-encoding the + * image. Returns null when there was nothing to remove or the format isn't + * handled. + */ +export async function stripMetadata(image: Blob): Promise { + const bytes = new Uint8Array(await image.arrayBuffer()); + let out: Uint8Array | null = null; + try { + if (bytes[0] === 0xff && bytes[1] === 0xd8) { + const parts: Uint8Array[] = [bytes.subarray(0, 2)]; + let offset = 2; + let removed = false; + while (offset + 4 <= bytes.length && bytes[offset] === 0xff) { + const marker = bytes[offset + 1]; + if (marker === 0xda) break; + const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; + const isMetadata = + marker === 0xe1 && + (startsWithExifHeader(bytes, offset + 4) || + ascii(bytes, offset + 4, XMP_HEADER.length) === XMP_HEADER); + if (isMetadata) removed = true; + else parts.push(bytes.subarray(offset, offset + 2 + length)); + offset += 2 + length; + } + parts.push(bytes.subarray(offset)); + out = removed ? concat(parts) : null; + } else if (bytes[0] === 0x89 && ascii(bytes, 1, 3) === "PNG") { + const view = new DataView(bytes.buffer); + const parts: Uint8Array[] = [bytes.subarray(0, 8)]; + let offset = 8; + let removed = false; + while (offset + 12 <= bytes.length) { + const length = view.getUint32(offset); + const type = ascii(bytes, offset + 4, 4); + const chunk = bytes.subarray(offset, offset + 12 + length); + const isXmp = type === "iTXt" && ascii(bytes, offset + 8, 17) === "XML:com.adobe.xmp"; + if (type === "eXIf" || isXmp) removed = true; + else parts.push(chunk); + offset += 12 + length; + } + out = removed ? concat(parts) : null; + } else if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") { + const chunks = readRiffChunks(bytes); + if (chunks.some((c) => c.type === "EXIF" || c.type === "XMP ")) { + const kept = chunks.filter((c) => c.type !== "EXIF" && c.type !== "XMP "); + const body = concat( + kept.map((c) => { + if (c.type !== "VP8X") return riffChunk(c.type, c.data); + const flags = c.data.slice(); + flags[0] &= ~(0x08 | 0x04); // clear EXIF and XMP flags + return riffChunk("VP8X", flags); + }), + ); + const header = new Uint8Array(12); + header.set(new TextEncoder().encode("RIFF"), 0); + new DataView(header.buffer).setUint32(4, body.length + 4, true); + header.set(new TextEncoder().encode("WEBP"), 8); + out = concat([header, body]); + } + } + } catch { + out = null; + } + return out ? new Blob([out], { type: image.type }) : null; +} diff --git a/src/lib/pdf-core.ts b/src/lib/pdf-core.ts index 9e884d4..29b6709 100644 --- a/src/lib/pdf-core.ts +++ b/src/lib/pdf-core.ts @@ -17,6 +17,7 @@ import { getPdfImageOptions, getPdfRenderOptions, getTargetRatio, + PDF_TARGET_STEPS, } from "./settings"; export type PdfSettings = Pick; @@ -64,9 +65,80 @@ export async function compressPdfBytes( : recompressPdfImages(data, settings, onProgress, signal); } +type PdfJsDocument = Awaited["promise"]>; +type RenderStep = { scale: number; quality: number }; + +async function renderPage(page: pdfjsLib.PDFPageProxy, scale: number): Promise { + const viewport = page.getViewport({ scale }); + const canvas = new OffscreenCanvas( + Math.max(1, Math.floor(viewport.width)), + Math.max(1, Math.floor(viewport.height)), + ); + await page.render({ canvas: null, canvasContext: canvas.getContext("2d")!, viewport } as any) + .promise; + return canvas; +} + +/** Builds a PDF whose pages are JPEGs rendered at `step`. */ +async function buildRasterPdf( + pdf: PdfJsDocument, + step: RenderStep, + onProgress: (fraction: number) => void, + signal?: AbortSignal, +): Promise { + const newPdf = await PDFDocument.create(); + for (let i = 1; i <= pdf.numPages; i++) { + throwIfAborted(signal); + const page = await pdf.getPage(i); + // Keep the page's physical size; only the embedded image resolution changes. + const pageSize = page.getViewport({ scale: 1 }); + const canvas = await renderPage(page, step.scale); + const jpeg = await canvas.convertToBlob({ type: "image/jpeg", quality: step.quality }); + canvas.width = 0; + canvas.height = 0; + page.cleanup(); + + const image = await newPdf.embedJpg(await jpeg.arrayBuffer()); + const pdfPage = newPdf.addPage([pageSize.width, pageSize.height]); + pdfPage.drawImage(image, { x: 0, y: 0, width: pageSize.width, height: pageSize.height }); + onProgress(i / pdf.numPages); + } + return newPdf.save(); +} + +/** + * Picks the best render step expected to fit `targetBytes`, by encoding the + * first page at each step and extrapolating to the whole document. + */ +async function pickStepForTarget(pdf: PdfJsDocument, targetBytes: number): Promise { + const page = await pdf.getPage(1); + const canvases = new Map(); + try { + for (const [index, step] of PDF_TARGET_STEPS.entries()) { + let canvas = canvases.get(step.scale); + if (!canvas) { + canvas = await renderPage(page, step.scale); + canvases.set(step.scale, canvas); + } + const jpeg = await canvas.convertToBlob({ type: "image/jpeg", quality: step.quality }); + // ~1.5 KB of PDF structure plus a little per page. + const estimate = jpeg.size * pdf.numPages * 1.03 + 1500 + 300 * pdf.numPages; + if (estimate <= targetBytes * 0.95) return index; + } + return PDF_TARGET_STEPS.length - 1; + } finally { + for (const canvas of canvases.values()) { + canvas.width = 0; + canvas.height = 0; + } + page.cleanup(); + } +} + /** * Rasterizes each page to a JPEG and rebuilds the PDF from those images. - * Smallest output, but text is no longer selectable. + * Smallest output, but text is no longer selectable. With a Custom target it + * uses the highest quality that fits instead of a fixed formula. */ async function rasterizePdf( data: ArrayBuffer, @@ -74,10 +146,6 @@ async function rasterizePdf( onProgress: (percent: number) => void, signal?: AbortSignal, ): Promise { - const { scale, quality } = getPdfRenderOptions( - settings.level, - getTargetRatio(settings, data.byteLength), - ); const inWorker = typeof document === "undefined"; const pdf = await pdfjsLib.getDocument({ data, @@ -88,36 +156,29 @@ async function rasterizePdf( useWorkerFetch: true, } as any).promise; try { - const newPdf = await PDFDocument.create(); - for (let i = 1; i <= pdf.numPages; i++) { + const targetBytes = settings.level === "Custom" ? settings.targetBytes : null; + if (!targetBytes) { + const step = getPdfRenderOptions(settings.level, getTargetRatio(settings, data.byteLength)); + return await buildRasterPdf(pdf, step, (f) => onProgress(Math.round(f * 100)), signal); + } + + onProgress(5); + let index = await pickStepForTarget(pdf, targetBytes); + // If the estimate was optimistic, step down (at most three more passes). + for (let attempt = 0; ; attempt++) { throwIfAborted(signal); - const page = await pdf.getPage(i); - // Keep the page's physical size; only the embedded image resolution changes. - const pageSize = page.getViewport({ scale: 1 }); - const viewport = page.getViewport({ scale }); - const canvas = new OffscreenCanvas( - Math.max(1, Math.floor(viewport.width)), - Math.max(1, Math.floor(viewport.height)), + const base = 10 + attempt * 10; + const bytes = await buildRasterPdf( + pdf, + PDF_TARGET_STEPS[index], + (f) => onProgress(Math.min(99, Math.round(base + f * (90 - base)))), + signal, ); - const context = canvas.getContext("2d")!; - await page.render({ canvas: null, canvasContext: context, viewport } as any) - .promise; - const jpeg = await canvas.convertToBlob({ type: "image/jpeg", quality }); - canvas.width = 0; - canvas.height = 0; - page.cleanup(); - - const image = await newPdf.embedJpg(await jpeg.arrayBuffer()); - const pdfPage = newPdf.addPage([pageSize.width, pageSize.height]); - pdfPage.drawImage(image, { - x: 0, - y: 0, - width: pageSize.width, - height: pageSize.height, - }); - onProgress(Math.round((i / pdf.numPages) * 100)); + if (bytes.length <= targetBytes || index === PDF_TARGET_STEPS.length - 1 || attempt === 3) { + return bytes; + } + index++; } - return await newPdf.save(); } finally { await pdf.destroy(); } diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 84cb36a..c537da2 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -26,6 +26,12 @@ describe("parseTarget", () => { expect(parseTarget(value)).toBeNull(); } }); + + it("rejects targets under 1 KB", () => { + expect(parseTarget("0.0000001", "KB")).toBeNull(); + expect(parseTarget("0.5", "KB")).toBeNull(); + expect(parseTarget("1", "KB")).toBe(1024); + }); }); describe("getScaleFilter", () => { @@ -43,10 +49,10 @@ describe("settings", () => { expect(isSettingsValid({ level: "High", targetBytes: null })).toBe(true); }); - it("computes the custom ratio per file, capped at 0.9", () => { + it("computes the custom ratio per file, capped at 1", () => { const settings = { level: "Custom" as const, targetBytes: 2 * MB }; expect(getTargetRatio(settings, 10 * MB)).toBeCloseTo(0.2); - expect(getTargetRatio(settings, 1 * MB)).toBe(0.9); + expect(getTargetRatio(settings, 1 * MB)).toBe(1); }); it("maps levels to CRF and clamps custom CRF to 18..51", () => { @@ -73,6 +79,12 @@ describe("estimateSavings", () => { expect(estimate?.percentage).toBe(80); }); + it("expects no savings when the target is above the file size", () => { + const estimate = estimateSavings([MB], { level: "Custom", targetBytes: 10 * MB }); + expect(estimate?.saved).toBe(0); + expect(estimate?.percentage).toBe(0); + }); + it("returns null without files or a valid target", () => { expect(estimateSavings([], { level: "Low", targetBytes: null })).toBeNull(); expect( diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 7c29c66..9ebd37f 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -87,11 +87,21 @@ const PRESET_RATIOS: Record, number> = { Extreme: 0.15, }; -/** Parses the target size input. Returns bytes, or null if not a positive number. */ +/** Smallest target accepted; below this no real file format fits. */ +export const MIN_TARGET_BYTES = 1024; +export const MAX_TARGET_BYTES = 100 * 1024 ** 3; + +/** Parses the target size input. Returns bytes, or null if outside 1 KB to 100 GB. */ export function parseTarget(value: string, unit: TargetUnit = "MB"): number | null { const amount = parseFloat(value); - if (!Number.isFinite(amount) || amount <= 0) return null; - return amount * 1024 * (unit === "MB" ? 1024 : 1); + if (!Number.isFinite(amount)) return null; + const bytes = amount * 1024 * (unit === "MB" ? 1024 : 1); + return bytes >= MIN_TARGET_BYTES && bytes <= MAX_TARGET_BYTES ? bytes : null; +} + +/** True when a Custom target is already met by the original file. */ +export function isUnderTarget(settings: TargetSettings, size: number): boolean { + return settings.level === "Custom" && settings.targetBytes !== null && size <= settings.targetBytes; } export function isSettingsValid(settings: TargetSettings): boolean { @@ -105,7 +115,8 @@ export function getTargetRatio( ): number { if (settings.level !== "Custom") return PRESET_RATIOS[settings.level]; if (!settings.targetBytes || originalSize <= 0) return PRESET_RATIOS.Medium; - return Math.min(settings.targetBytes / originalSize, 0.9); + // A target at or above the file size asks for no reduction at all. + return Math.min(settings.targetBytes / originalSize, 1); } export function getVideoCrf(level: CompressionLevel, ratio: number): number { @@ -241,6 +252,23 @@ export function getAvifQuality(level: PresetLevel): number { export const AVIF_CUSTOM_QUALITIES = [65, 50, 38, 28, 20, 12]; +/** + * Render scale and JPEG quality steps for "Smallest size" PDFs with a Custom + * target, best first. The compressor picks the first step that fits. + */ +export const PDF_TARGET_STEPS: { scale: number; quality: number }[] = [ + { scale: 2, quality: 0.8 }, + { scale: 1.5, quality: 0.75 }, + { scale: 1.5, quality: 0.6 }, + { scale: 1.25, quality: 0.55 }, + { scale: 1, quality: 0.5 }, + { scale: 1, quality: 0.4 }, + { scale: 0.8, quality: 0.35 }, + { scale: 0.65, quality: 0.3 }, + { scale: 0.5, quality: 0.25 }, + { scale: 0.35, quality: 0.2 }, +]; + /** JPEG quality and longest side used when re-compressing images inside a PDF. */ export function getPdfImageOptions( level: CompressionLevel, diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 6ab5ebf..11c1b62 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -49,7 +49,8 @@ test('converts images to AVIF and WebP and compares before/after', async ({ page await page.getByRole('button', { name: 'Compress', exact: true }).click(); await waitForQueue(page); expect((await download(page, 'photo.jpg')).name).toBe('compressed_photo.avif'); - await expect(rowFor(page, 'photo.jpg')).toContainText('Location & camera data removed'); + // The generated photo has no EXIF, so there is nothing to report removing. + await expect(rowFor(page, 'photo.jpg')).not.toContainText('Location & camera data removed'); await rowFor(page, 'photo.jpg').getByRole('button', { name: /^Compare/ }).click(); await expect(page.getByRole('dialog').locator('img')).toHaveCount(2); @@ -155,3 +156,100 @@ test('receives files shared from other apps', async ({ page }) => { await page.goto('/?shared=1'); await expect(rowFor(page, 'shared.svg')).toBeVisible(); }); + +test('keeps or removes photo metadata as chosen', async ({ page, files }) => { + const { readExif, writeExif } = await import('../../src/lib/metadata'); + // IFD0 with Make = "Cam" so the block is non-trivial. + const tiff = new Uint8Array([ + 0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x0f, 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x43, 0x61, 0x6d, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]); + const tagged = await writeExif(new Blob([files.jpg.buffer], { type: 'image/jpeg' }), tiff); + const photo = { name: 'tagged.jpg', mimeType: 'image/jpeg', buffer: Buffer.from(await tagged!.arrayBuffer()) }; + const exifOf = async (bytes: Buffer, type: string) => readExif(new Blob([bytes], { type })); + + await page.setInputFiles('input[type="file"]', [photo]); + await page.getByRole('button', { name: 'Compress', exact: true }).click(); + await waitForQueue(page); + await expect(rowFor(page, 'tagged.jpg')).toContainText('Location & camera data removed'); + expect(await exifOf((await download(page, 'tagged.jpg')).bytes, 'image/jpeg')).toBeNull(); + + await page.getByLabel(/Keep photo metadata/).check(); + for (const format of ['original', 'webp'] as const) { + await page.selectOption('#image-format', format); + await page.getByRole('button', { name: 'Re-compress All', exact: true }).click(); + await waitForQueue(page); + await expect(rowFor(page, 'tagged.jpg')).toContainText('Photo metadata kept'); + const out = await download(page, 'tagged.jpg'); + expect(await exifOf(out.bytes, format === 'webp' ? 'image/webp' : 'image/jpeg')).not.toBeNull(); + } +}); + +test('files added mid-run get their own section and button', async ({ page, files }) => { + await page.setInputFiles('input[type="file"]', [files.jpg, files.png].map(toFile)); + await page.selectOption('#image-format', 'avif'); + await page.getByRole('button', { name: 'Compress All', exact: true }).click(); + await page.setInputFiles('input[type="file"]', [toFile(files.svg)]); + await expect(page.getByRole('heading', { name: /^Processing \(2\)$/ })).toBeVisible(); + await expect(page.getByRole('heading', { name: /^Newly added \(1\)$/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Re-compress all' })).toBeVisible(); + await page.getByRole('button', { name: /^Compress new file/ }).click(); + await waitForQueue(page); + await expect(rowFor(page, 'icon.svg')).toContainText('New:'); + await expect(page.getByRole('heading', { name: /Newly added/ })).toHaveCount(0); +}); + +test('explains why broken files fail', async ({ page }) => { + await page.goto('/'); + await page.setInputFiles('input[type="file"]', [ + { name: 'empty.jpg', mimeType: 'image/jpeg', buffer: Buffer.alloc(0) }, + { name: 'fake.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('not an image') }, + { name: 'broken.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.7\ngarbage') }, + ]); + await expect(rowFor(page, 'empty.jpg')).toContainText('This file is empty.'); + await page.getByRole('button', { name: 'Compress All', exact: true }).click(); + await waitForQueue(page); + await expect(rowFor(page, 'fake.jpg')).toContainText("Couldn't read this image"); + await expect(rowFor(page, 'broken.pdf')).toContainText("Couldn't read this PDF"); +}); + +test('custom targets: keeps files already under, rejects tiny ones, fits PDFs', async ({ page, files }) => { + await page.setInputFiles('input[type="file"]', [toFile(files.jpg), toFile(files.pdf)]); + await page.getByRole('button', { name: 'Custom', exact: true }).click(); + + await page.fill('#target-size', '0.0001'); + await page.selectOption('select[aria-label="Target size unit"]', 'KB'); + await expect(page.getByRole('button', { name: 'Compress All', exact: true })).toBeDisabled(); + + // Above both files' sizes: nothing should be re-encoded. + await page.fill('#target-size', '50'); + await page.selectOption('select[aria-label="Target size unit"]', 'MB'); + await page.getByRole('button', { name: 'Compress All', exact: true }).click(); + await waitForQueue(page); + await expect(rowFor(page, 'photo.jpg')).toContainText('Already under your target'); + + // A PDF target should be met without collapsing far below it. + await page.getByRole('button', { name: 'Smallest size' }).click(); + await page.fill('#target-size', '150'); + await page.selectOption('select[aria-label="Target size unit"]', 'KB'); + await page.getByRole('button', { name: 'Re-compress All', exact: true }).click(); + await waitForQueue(page); + const pdf = await download(page, 'doc.pdf'); + expect(pdf.bytes.length).toBeLessThanOrEqual(150 * 1024); + expect(pdf.bytes.length).toBeGreaterThan(30 * 1024); +}); + +test('rejects trims outside the clip', async ({ page, files }) => { + await page.setInputFiles('input[type="file"]', [toFile(files.webm)]); + const row = rowFor(page, 'clip.webm'); + await row.getByRole('button', { name: /^Options/ }).click(); + await row.getByLabel('Start (seconds)').fill('2'); + await row.getByLabel('End (seconds)').fill('1'); + await expect(row.getByRole('alert')).toContainText('The end must be after the start.'); + await row.getByLabel('Start (seconds)').fill('60'); + await row.getByLabel('End (seconds)').fill(''); + await page.getByRole('button', { name: 'Compress', exact: true }).click(); + await waitForQueue(page); + await expect(row).toContainText('is past the end of the clip'); +});