Imaging (image processing in the browser)
A photo taken on a phone, handled on the device: resized, cropped, rotated, re-encoded and fitted into a byte budget — no server, no dependency, and the user's picture does not travel before it has to.
That is what a field PWA needs. It may be offline, the connection may be metered, and the photo may carry GPS coordinates nobody asked to upload.
import { compressToTarget } from "tempest-react-sdk/imaging";
// `file` is what an <input type="file"> hands you: event.target.files[0]
const upload = await compressToTarget(file, {
maxBytes: 1_000_000,
width: 1600,
type: "image/webp",
});
console.log(upload.bytes, upload.quality, upload.withinBudget);
192512 0.86 true
What it handles for you
Phone photos arrive sideways
A portrait picture is stored landscape plus a rotation tag. Decoding without honouring the tag shows every vertical photo on its side — the most reported bug in upload flows.
import { decodeImage } from "tempest-react-sdk/imaging";
const { width, height } = await decodeImage(file); // already upright
Measured in Chromium: a 120x60 JPEG with Orientation=6 decodes as 60x120.
An unsupported format fails silently
import { bestSupportedType, supportsImageType } from "tempest-react-sdk/imaging";
const type = await bestSupportedType(["image/avif", "image/webp", "image/jpeg"]);
Asking for AVIF where there is no encoder returns PNG, with no error
Measured in Chromium and Firefox: convertToBlob({ type: "image/avif" })
does not throw — it returns image/png. An app that trusted the request
uploads megabytes where it planned for hundreds of kilobytes.
So every result carries type — the format produced, not the one
requested — and supportsImageType answers up front.
Re-encoding drops EXIF
Resizing goes through a canvas, and a canvas carries no metadata. GPS, camera serial and timestamp do not survive. For an app handling user photos that is usually exactly what you want — and it is worth knowing it happens.
Real measurements
A synthetic 3000x2000 photo (731 KB), Chromium:
| Operation | Result |
|---|---|
| Resize to 1200 px, JPEG q0.85 | 124 KB in 66 ms |
| Same in WebP q0.85 | 75 KB (40% smaller) |
compressToTarget to 200 KB |
192 KB, q0.86, 7 encodes, 313 ms |
What was removed after measuring
The classic recipe for a steep canvas downscale is to halve
repeatedly, because a single drawImage supposedly aliases. That was
implemented here, and then measured: a 512 px checkerboard reduced to
32 px produced an identical result (standard deviation 0.0 on both)
in Chromium and Firefox, while the stepwise path cost 39.19 ms against
0.13 ms on a 4000x3000 photo — 300 times more, plus three intermediate
canvases on a device that may not have the memory.
Modern engines honour imageSmoothingQuality = "high", which is what
this module sets. The code was deleted rather than kept "just in case":
unmeasurable benefit at 300x the cost is not insurance, it is ballast.
Resizing
import { resizeImage } from "tempest-react-sdk/imaging";
const thumb = await resizeImage(file, { width: 320, height: 320, fit: "cover" });
fit |
What it does |
|---|---|
contain (default) |
Fits inside the box; may be smaller on one side |
cover |
Fills the box; the overflow is cropped, centred |
fill |
Stretches to the box, changing the aspect ratio |
pad |
Like contain, but completes the box with background |
It never enlarges by default (withoutEnlargement): scaling up adds no
detail and multiplies the bytes.
JPEG has no alpha
Encoding a transparent PNG as JPEG paints the transparent pixels black.
The module fills with white first — pass background for another colour.
Crop, rotate, flip
import { cropImage, flipImage, rotateImage } from "tempest-react-sdk/imaging";
const badge = await cropImage(file, { x: 120, y: 80, width: 400, height: 400 });
const upright = await rotateImage(file, 90);
const selfie = await flipImage(capture, { horizontal: true });
The rectangle is clamped to the image, so a crop dragged past the edge gives
a smaller result rather than transparent padding. rotateImage takes only
multiples of 90 — an arbitrary angle needs a decision about the corners, and
that is your app's design, not a utility default.
Several thumbnails from one decode
import { createThumbnails } from "tempest-react-sdk/imaging";
const [thumb, card] = await createThumbnails(file, [
{ name: "thumb", size: 96 },
{ name: "card", size: 480 },
]);
Three separate resizeImage calls would decode the same photo three times —
and on a 12-megapixel image the decode is the cost, not the scaling. size
is the longest edge, so portrait and landscape both fit the same grid
cell without a separate calculation.
One frame out of a video
The frame already on screen needs nothing special — a <video> is a source
like any other, because createImageBitmap accepts the element:
import { resizeImage } from "tempest-react-sdk/imaging";
const print = await resizeImage(videoRef.current, { width: 1280, type: "image/webp" });
A chosen instant is another story. This is the code every app writes, and it is wrong:
video.currentTime = 12.5;
await new Promise((r) => video.addEventListener("seeked", r, { once: true }));
context.drawImage(video, 0, 0); // may draw the PREVIOUS frame
seeked says the seek finished, not that the frame for the new position is
composited and readable by drawImage. The symptom is the worst kind: it works
on the machine it was written on and returns the neighbouring frame elsewhere,
with no error and no log.
captureFrame is that path, done once:
import { captureFrame } from "tempest-react-sdk/imaging";
const poster = await captureFrame(video, { atMs: 10_000, width: 640 });
setPoster(URL.createObjectURL(poster.blob));
console.log(`landed on ${poster.atMs}ms, confirmed: ${poster.confirmed}`);
Without atMs it reads the current frame; with atMs it seeks, waits for that
instant's frame to be presented, captures, and puts the player back.
| Option | What it does |
|---|---|
atMs |
Instant to capture. Left out: the frame on screen now |
restore |
Put currentTime and playback back. Default true |
timeoutMs |
Ceiling for the seek and the frame after it. Default 3000 |
signal |
AbortSignal; rejects with AbortError |
width / height / fit / type / quality |
Same as resizeImage |
The result is a ProcessedImage with two extra fields: atMs, the instant
that actually came out, and confirmed.
Why the atMs you get back differs from the one you asked for
A seek lands on a frame boundary. Asking for 12,500 ms in a 30 fps video
gives you 12,466.67 ms — the frame containing that instant. The
result's atMs is what happened; what you passed was the intent.
confirmed says how sure the capture is — and for a seek it is false
requestVideoFrameCallback is the only signal that says "a frame was
presented". Measured in Chromium, 2026-09-04: it fires while the video
plays and does not fire for a seek on a paused element.
So:
- capturing from a playing video (the screen-recording print) waits for
the next presented frame →
confirmed: true; - capturing at an
atMswaits forseekedplus two animation frames →confirmed: false, always; - capturing the current frame of a paused video waits for nothing — the frame on screen already is the frame.
false on a seek is the normal result, not a warning. Treating it as a
failure would reject the majority of correct captures. It is also why the
seek does not block on the callback: waiting for a signal that state
never emits would cost the whole timeoutMs on every capture and then
proceed anyway.
A recording may arrive with no duration
MediaRecorder does not guarantee a duration in the WebM header, so the
blob useVideoRecorder just handed you may report Infinity — and that is
exactly the video an app wants a frame from. Seeking past the end forces the
browser to demux to the last frame, after which it knows the length. That
probe runs in here, not in every caller.
Measured 2026-09-04: Chromium does write the duration for a recording
finalised in a single stop() (3.000197 s for 3 s of canvas), so the probe
does not run on that path. It exists for the paths that omit it — chunked
timeslice recording, and other browsers. useVideoRecorder keeps its own
clock for the same reason.
A cross-origin video needs crossOrigin
Without crossOrigin="anonymous" on the element before the source
loads, the video taints the canvas and reading it is not allowed.
drawImage passes and the error shows up at encode time, far from its
cause — captureFrame re-throws saying what to fix, but the attribute is on
your tag and the server has to send Access-Control-Allow-Origin.
A live stream (srcObject from getUserMedia, getDisplayMedia or
captureStream) has no timeline: atMs there raises FrameSeekError. Leave
atMs out and you capture now, which is the only instant that exists.
A print of a screen recording
It composes with useScreenCapture and share:
import { useScreenCapture, shareOrDownloadBlob } from "tempest-react-sdk";
import { captureFrame } from "tempest-react-sdk/imaging";
function ScreenPrint() {
const screen = useScreenCapture();
const video = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (video.current) video.current.srcObject = screen.stream;
}, [screen.stream]);
async function print() {
if (!video.current) return;
const shot = await captureFrame(video.current, { type: "image/webp", quality: 0.9 });
await shareOrDownloadBlob(shot.blob, "print.webp");
}
return (
<>
<button onClick={screen.start} disabled={!screen.supported}>Share screen</button>
<video ref={video} autoPlay muted playsInline />
<button onClick={print} disabled={screen.stream === null}>Take a print</button>
</>
);
}
In React
import { useImagePreview, useImageProcessing } from "tempest-react-sdk/imaging";
function PhotoUpload() {
const [file, setFile] = useState<File | null>(null);
const { url } = useImagePreview(file);
const { compress, isWorking } = useImageProcessing();
async function send() {
if (file === null) return;
const ready = await compress(file, { maxBytes: 1_000_000, width: 1600 });
await fetch("/api/photos", { method: "POST", body: ready.blob });
}
return (
<>
{url !== null && <img src={url} alt="" />}
<button disabled={isWorking || file === null} onClick={send}>Send</button>
</>
);
}
useImagePreview revokes the object URL when the source changes or the
component unmounts — without that the whole blob stays in memory for the
page's lifetime. useImageProcessing does not write state after unmount,
which is the case of a large photo finishing after the user left the screen.
Off the main thread
Every function uses OffscreenCanvas where it exists, so they run inside a
worker unchanged:
// worker.ts
import { compressToTarget } from "tempest-react-sdk/imaging";
self.onmessage = async (event: MessageEvent<File>) => {
const result = await compressToTarget(event.data, { maxBytes: 1_000_000 });
self.postMessage(result.blob);
};
Worth doing: resizing a 12-megapixel photo on the main thread blocks the UI for tens of milliseconds per image.
API
| Symbol | What it is |
|---|---|
resizeImage(source, options?) |
Resizes and re-encodes |
cropImage(source, rect, options?) |
Crops in source pixels |
rotateImage(source, degrees, options?) |
Rotates by multiples of 90 |
flipImage(source, axes, options?) |
Mirrors |
compressToTarget(source, options) |
Binary-searches quality until it fits |
captureFrame(video, options?) |
One frame of a <video>, at the instant asked for |
createThumbnails(source, specs, options?) |
Several sizes, one decode |
decodeImage(source) / readImageInfo(blob) |
Oriented pixels / dimensions and size |
encodeImage(surface, options?) |
Canvas to bytes |
supportsImageType(type) / bestSupportedType(list) |
What this browser really encodes |
createSurface / getContext / drawScaled |
Canvas primitives |
useImagePreview(blob) / useImageProcessing() |
Hooks |
Errors: ImagingError at the root, with ImageDecodeError,
ImageEncodeError, FrameSeekError, UnsupportedImageTypeError and
ImagingUnavailableError. FrameSeekError is captureFrame's: the seek never
landed, or the video has no timeline. It is its own class because the
alternative is returning a frame from the wrong instant, which is
indistinguishable from a correct one to everything downstream.
Each option's default is exported too, so a settings screen can show the value it
is about to override instead of restating it: DEFAULT_QUALITY (0.85),
DEFAULT_TYPE (image/jpeg), DEFAULT_BACKGROUND (#ffffff, the background
that replaces transparency on the way to JPEG) and, for compressToTarget's
search, DEFAULT_MIN_QUALITY (0.4), DEFAULT_MAX_QUALITY (0.92) and
DEFAULT_COMPRESS_STEPS (6 iterations). captureFrame brings
DEFAULT_FRAME_TIMEOUT_MS (3000 ms), the ceiling on waiting for a seek.
Recap
- Decode with
decodeImage— phone orientation is already handled. - Check the format with
supportsImageType; trust the returnedtype. compressToTargetfor a byte budget;withoutEnlargementstays on.- Re-encoding drops EXIF — it is the privacy step as well as the bandwidth one.
- Video frames:
captureFramefor a chosen instant —seekedalone draws the neighbouring frame. Readconfirmedbefore trusting the instant. - Run it in a worker:
OffscreenCanvasis picked up automatically.