authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 15:51:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 15:51:07-07:00
loge2e6ce6a29451e9fac612ab83f7a104b6d6475a3
tree1d273c31f8fb6e52bc8e06b752836bb6d581b084
parentf8b4c464b4760d73e0ece7e68011c738803d8b69
signature Signed by PGP key B5690EEEBB952194

feat(server): report image dimensions with signed asset URLs (#10198)

Co-authored-by: Claude Code <noreply@anthropic.com>

8 files changed, 420 insertions(+), 5 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+24
...@@ -83,6 +83,30 @@ describe("AssetAccess", () => {...@@ -83,6 +83,30 @@ describe("AssetAccess", () => {
83 }).pipe(Effect.provide(testLayer)),83 }).pipe(Effect.provide(testLayer)),
84 );84 );
8585
86 it.effect("reports pixel dimensions from an image header and nothing for other files", () =>
87 Effect.gen(function* () {
88 const fs = yield* FileSystem.FileSystem;
89 const path = yield* Path.Path;
90 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-dimensions-" });
91 const png = Uint8Array.from([
92 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0,
93 0x06, 0x40, 0, 0, 0x03, 0x84,
94 ]);
95 yield* fs.writeFile(path.join(root, "shot.png"), png);
96 yield* fs.writeFileString(path.join(root, "clip.mp4"), "video");
97 yield* fs.writeFileString(path.join(root, "broken.png"), "not a png");
98 const issue = (name: string) =>
99 issueAssetUrl({
100 resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: name },
101 workspaceRoot: root,
102 });
103
104 expect((yield* issue("shot.png")).imageDimensions).toEqual({ width: 1600, height: 900 });
105 expect((yield* issue("clip.mp4")).imageDimensions).toBeUndefined();
106 expect((yield* issue("broken.png")).imageDimensions).toBeUndefined();
107 }).pipe(Effect.provide(testLayer)),
108 );
109
86 it.effect("resolves relative media paths from the thread workspace, including outside it", () =>110 it.effect("resolves relative media paths from the thread workspace, including outside it", () =>
87 Effect.gen(function* () {111 Effect.gen(function* () {
88 const fs = yield* FileSystem.FileSystem;112 const fs = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+61-5
...@@ -21,6 +21,11 @@ import {...@@ -21,6 +21,11 @@ import {
21 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,21 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
22 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,22 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
23} from "@t3tools/shared/filePreview";23} from "@t3tools/shared/filePreview";
24import {
25 IMAGE_DIMENSIONS_HEADER_BYTES,
26 readImageDimensions,
27 type ImageDimensions,
28} from "@t3tools/shared/imageDimensions";
24import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";29import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
25import * as Clock from "effect/Clock";30import * as Clock from "effect/Clock";
26import * as Crypto from "effect/Crypto";31import * as Crypto from "effect/Crypto";
...@@ -44,7 +49,7 @@ import * as ServerConfig from "../config.ts";...@@ -44,7 +49,7 @@ import * as ServerConfig from "../config.ts";
44import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";49import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
45import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";50import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
46import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";51import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
47import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts";52import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
4853
49export const ASSET_ROUTE_PREFIX = "/api/assets";54export const ASSET_ROUTE_PREFIX = "/api/assets";
5055
...@@ -224,6 +229,34 @@ const resolveCanonicalWorkspaceFileForRequest = (input: {...@@ -224,6 +229,34 @@ const resolveCanonicalWorkspaceFileForRequest = (input: {
224 Effect.orElseSucceed(() => null),229 Effect.orElseSucceed(() => null),
225 );230 );
226231
232/**
233 * Reads pixel dimensions from an image's header so clients can reserve the
234 * exact box before the bytes arrive. Best effort: an unreadable or unsupported
235 * file just leaves the field out, and the client measures after decode. Only
236 * formats the parser understands are opened; SVG and the rest are skipped.
237 */
238const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
239
240/** From the identity-checked, non-blocking handle the caller already holds. */
241const readImageDimensionsFromOpenFile = (filePath: string, file: OpenMediaFile) =>
242 readMediaFileHeader(filePath, file, IMAGE_DIMENSIONS_HEADER_BYTES).pipe(
243 Effect.map(readImageDimensions),
244 Effect.orElseSucceed((): ImageDimensions | null => null),
245 );
246
247/**
248 * Opens through `openMediaFile` so a path swapped for a FIFO cannot block the
249 * request; a regular open would wait for a writer that never comes.
250 */
251const readImageDimensionsFromHeader = (filePath: string) =>
252 openMediaFile(filePath).pipe(
253 Effect.flatMap((file) =>
254 file === null ? Effect.succeed(null) : readImageDimensionsFromOpenFile(filePath, file),
255 ),
256 Effect.scoped,
257 Effect.orElseSucceed((): ImageDimensions | null => null),
258 );
259
227export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {260export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
228 readonly resource: AssetResource;261 readonly resource: AssetResource;
229 readonly workspaceRoot?: string;262 readonly workspaceRoot?: string;
...@@ -236,6 +269,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -236,6 +269,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
236 let claims: AssetClaims;269 let claims: AssetClaims;
237 let fileName: string;270 let fileName: string;
238 let sourcePath: string | undefined;271 let sourcePath: string | undefined;
272 let imageDimensions: ImageDimensions | null = null;
239273
240 switch (input.resource._tag) {274 switch (input.resource._tag) {
241 case "media-file": {275 case "media-file": {
...@@ -265,18 +299,33 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -265,18 +299,33 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
265 if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {299 if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
266 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });300 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
267 }301 }
268 const identity = yield* openMediaFile(canonicalFile).pipe(302 const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(
269 Effect.map((file) =>303 path.extname(canonicalFile).toLowerCase(),
270 file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null,304 );
305 const opened = yield* openMediaFile(canonicalFile).pipe(
306 Effect.flatMap((file) =>
307 file === null
308 ? Effect.succeed(null)
309 : Effect.map(
310 wantsDimensions
311 ? readImageDimensionsFromOpenFile(canonicalFile, file)
312 : Effect.succeed(null),
313 (dimensions) => ({
314 identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() },
315 dimensions,
316 }),
317 ),
271 ),318 ),
272 Effect.scoped,319 Effect.scoped,
273 Effect.mapError(320 Effect.mapError(
274 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),321 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
275 ),322 ),
276 );323 );
277 if (!identity) {324 if (!opened) {
278 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });325 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
279 }326 }
327 const identity = opened.identity;
328 imageDimensions = opened.dimensions;
280 claims = {329 claims = {
281 version: 1,330 version: 1,
282 kind: "media-file-exact",331 kind: "media-file-exact",
...@@ -347,6 +396,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -347,6 +396,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
347 }),396 }),
348 ),397 ),
349 );398 );
399 if (HEADER_IMAGE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) {
400 imageDimensions = yield* readImageDimensionsFromHeader(canonicalFile);
401 }
350 claims = isWorkspaceImagePreviewPath(resolved.relativePath)402 claims = isWorkspaceImagePreviewPath(resolved.relativePath)
351 ? {403 ? {
352 version: 1,404 version: 1,
...@@ -390,6 +442,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -390,6 +442,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
390 INLINE_DOCUMENT_EXTENSIONS.has(extension)442 INLINE_DOCUMENT_EXTENSIONS.has(extension)
391 ? INLINE_DOCUMENT_MIME_TYPES[extension]443 ? INLINE_DOCUMENT_MIME_TYPES[extension]
392 : undefined;444 : undefined;
445 if (!isGenericFile) {
446 imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath);
447 }
393 claims = {448 claims = {
394 version: 1,449 version: 1,
395 kind: "attachment",450 kind: "attachment",
...@@ -547,6 +602,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -547,6 +602,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
547 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,602 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
548 expiresAt,603 expiresAt,
549 ...(sourcePath !== undefined ? { sourcePath } : {}),604 ...(sourcePath !== undefined ? { sourcePath } : {}),
605 ...(imageDimensions !== null ? { imageDimensions } : {}),
550 };606 };
551});607});
552608
apps/server/src/assets/MediaFile.ts+23
...@@ -19,6 +19,18 @@ class MediaFileOpenError extends Schema.TaggedErrorClass<MediaFileOpenError>()(...@@ -19,6 +19,18 @@ class MediaFileOpenError extends Schema.TaggedErrorClass<MediaFileOpenError>()(
19 }19 }
20}20}
2121
22class MediaFileReadError extends Schema.TaggedErrorClass<MediaFileReadError>()(
23 "MediaFileReadError",
24 {
25 path: Schema.String,
26 cause: Schema.Defect(),
27 },
28) {
29 override get message(): string {
30 return `Failed to read media file '${this.path}'.`;
31 }
32}
33
22class MediaFileStatError extends Schema.TaggedErrorClass<MediaFileStatError>()(34class MediaFileStatError extends Schema.TaggedErrorClass<MediaFileStatError>()(
23 "MediaFileStatError",35 "MediaFileStatError",
24 {36 {
...@@ -95,6 +107,17 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* (...@@ -95,6 +107,17 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* (
95 );107 );
96});108});
97109
110/** Reads the leading bytes of an already-validated media file, never past the end. */
111export const readMediaFileHeader = (filePath: string, file: OpenMediaFile, byteCount: number) =>
112 Effect.tryPromise({
113 try: async () => {
114 const buffer = new Uint8Array(byteCount);
115 const { bytesRead } = await file.handle.read(buffer, 0, byteCount, 0);
116 return buffer.subarray(0, bytesRead);
117 },
118 catch: (cause) => new MediaFileReadError({ path: filePath, cause }),
119 });
120
98export const statMediaFile = Effect.fn("statMediaFile")(function* (121export const statMediaFile = Effect.fn("statMediaFile")(function* (
99 filePath: string,122 filePath: string,
100 file: OpenMediaFile,123 file: OpenMediaFile,
packages/client-runtime/src/state/assets.ts+6
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 type AssetCreateUrlResult,2 type AssetCreateUrlResult,
3 type AssetImageDimensions,
3 AssetResource,4 AssetResource,
4 EnvironmentId,5 EnvironmentId,
5 WS_METHODS,6 WS_METHODS,
...@@ -60,6 +61,8 @@ export type AssetUrlState =...@@ -60,6 +61,8 @@ export type AssetUrlState =
60 readonly url: string;61 readonly url: string;
61 /** The host path the server chose to serve, when it differs from what was asked for. */62 /** The host path the server chose to serve, when it differs from what was asked for. */
62 readonly sourcePath?: string;63 readonly sourcePath?: string;
64 /** Pixel size from the image header, when the server could read one. */
65 readonly imageDimensions?: AssetImageDimensions;
63 };66 };
6467
65export function assetUrlStateFromResult(68export function assetUrlStateFromResult(
...@@ -74,6 +77,9 @@ export function assetUrlStateFromResult(...@@ -74,6 +77,9 @@ export function assetUrlStateFromResult(
74 _tag: "Success",77 _tag: "Success",
75 url,78 url,
76 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),79 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
80 ...(result.value.imageDimensions !== undefined
81 ? { imageDimensions: result.value.imageDimensions }
82 : {}),
77 };83 };
78}84}
7985
packages/contracts/src/assets.ts+8
...@@ -52,12 +52,20 @@ export const AssetCreateUrlInput = Schema.Struct({...@@ -52,12 +52,20 @@ export const AssetCreateUrlInput = Schema.Struct({
52});52});
53export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;53export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;
5454
55export const AssetImageDimensions = Schema.Struct({
56 width: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)),
57 height: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)),
58});
59export type AssetImageDimensions = typeof AssetImageDimensions.Type;
60
55export const AssetCreateUrlResult = Schema.Struct({61export const AssetCreateUrlResult = Schema.Struct({
56 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),62 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
57 expiresAt: Schema.Number,63 expiresAt: Schema.Number,
58 sourcePath: Schema.optional(64 sourcePath: Schema.optional(
59 TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),65 TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
60 ),66 ),
67 /** Pixel size read from the image header, so a client can reserve the exact box before the bytes arrive. */
68 imageDimensions: Schema.optional(AssetImageDimensions),
61});69});
62export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;70export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
6371
packages/shared/package.json+4
...@@ -211,6 +211,10 @@...@@ -211,6 +211,10 @@
211 "types": "./src/filePreview.ts",211 "types": "./src/filePreview.ts",
212 "import": "./src/filePreview.ts"212 "import": "./src/filePreview.ts"
213 },213 },
214 "./imageDimensions": {
215 "types": "./src/imageDimensions.ts",
216 "import": "./src/imageDimensions.ts"
217 },
214 "./video": {218 "./video": {
215 "types": "./src/video.ts",219 "types": "./src/video.ts",
216 "import": "./src/video.ts"220 "import": "./src/video.ts"
packages/shared/src/imageDimensions.test.ts created+140
...@@ -0,0 +1,140 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { readImageDimensions } from "./imageDimensions.ts";
4
5function bytes(...parts: ReadonlyArray<number | string | ReadonlyArray<number>>): Uint8Array {
6 const out: number[] = [];
7 for (const part of parts) {
8 if (typeof part === "string") for (const c of part) out.push(c.charCodeAt(0));
9 else if (typeof part === "number") out.push(part);
10 else out.push(...part);
11 }
12 return Uint8Array.from(out);
13}
14
15const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
16const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff];
17const u16le = (n: number) => [n & 0xff, (n >>> 8) & 0xff];
18
19describe("readImageDimensions", () => {
20 it("reads a PNG IHDR", () => {
21 const png = bytes(
22 [0x89],
23 "PNG",
24 [0x0d, 0x0a, 0x1a, 0x0a],
25 u32(13),
26 "IHDR",
27 u32(1600),
28 u32(900),
29 );
30 expect(readImageDimensions(png)).toEqual({ width: 1600, height: 900 });
31 });
32
33 it("reads a GIF logical screen", () => {
34 expect(readImageDimensions(bytes("GIF89a", u16le(320), u16le(240)))).toEqual({
35 width: 320,
36 height: 240,
37 });
38 });
39
40 it("reads a JPEG start-of-frame after an APP segment", () => {
41 const app1 = bytes([0xff, 0xe1], u16(2 + 6), "Exif\0\0");
42 const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(1400), u16(720));
43 expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({
44 width: 720,
45 height: 1400,
46 });
47 });
48
49 it("swaps the axes for a JPEG whose EXIF orientation rotates it 90 degrees", () => {
50 // Big-endian TIFF with one IFD0 entry: tag 0x0112 (orientation), SHORT, count 1, value 6.
51 const tiff = [
52 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00,
53 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
54 ];
55 const exif = ["Exif\0\0", tiff] as const;
56 const app1 = bytes([0xff, 0xe1], u16(2 + 6 + tiff.length), ...exif);
57 const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(3024), u16(4032));
58 expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({
59 width: 3024,
60 height: 4032,
61 });
62 // A later XMP APP1 segment must not clear the rotation.
63 const xmp = bytes([0xff, 0xe1], u16(2 + 29), "http://ns.adobe.com/xap/1.0/\0");
64 expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...xmp], [...sof0]))).toEqual({
65 width: 3024,
66 height: 4032,
67 });
68 // Orientation 1 leaves the frame size alone.
69 const upright = [...tiff];
70 upright[19] = 0x01;
71 const app1Upright = bytes([0xff, 0xe1], u16(2 + 6 + upright.length), "Exif\0\0", upright);
72 expect(readImageDimensions(bytes([0xff, 0xd8], [...app1Upright], [...sof0]))).toEqual({
73 width: 4032,
74 height: 3024,
75 });
76 });
77
78 it("steps over standalone TEM and restart markers", () => {
79 const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(10), u16(20));
80 expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0x01], [0xff, 0xd3], [...sof0]))).toEqual(
81 { width: 20, height: 10 },
82 );
83 });
84
85 it("does not mistake a Huffman table marker for a frame", () => {
86 const dht = bytes([0xff, 0xc4], u16(4), [0, 0]);
87 const sof2 = bytes([0xff, 0xc2], u16(17), [8], u16(10), u16(20));
88 expect(readImageDimensions(bytes([0xff, 0xd8], [...dht], [...sof2]))).toEqual({
89 width: 20,
90 height: 10,
91 });
92 });
93
94 it("reads each WebP container flavour", () => {
95 const riff = (chunk: string, body: ReadonlyArray<number>) =>
96 bytes("RIFF", u32(0), "WEBP", chunk, u32(body.length), body);
97 // VP8: frame tag (3), start code (3), then 14-bit width and height.
98 expect(
99 readImageDimensions(riff("VP8 ", [0, 0, 0, 0x9d, 0x01, 0x2a, ...u16le(800), ...u16le(600)])),
100 ).toEqual({ width: 800, height: 600 });
101 // VP8L: signature 0x2f, then width-1 (14 bits) and height-1 (14 bits) packed LE.
102 const packed = (800 - 1) | ((600 - 1) << 14);
103 expect(
104 readImageDimensions(
105 riff("VP8L", [
106 0x2f,
107 packed & 0xff,
108 (packed >>> 8) & 0xff,
109 (packed >>> 16) & 0xff,
110 (packed >>> 24) & 0xff,
111 ]),
112 ),
113 ).toEqual({ width: 800, height: 600 });
114 // VP8X: flags (4), then 24-bit width-1 and height-1.
115 expect(
116 readImageDimensions(
117 riff("VP8X", [
118 0,
119 0,
120 0,
121 0,
122 799 & 0xff,
123 (799 >> 8) & 0xff,
124 0,
125 599 & 0xff,
126 (599 >> 8) & 0xff,
127 0,
128 ]),
129 ),
130 ).toEqual({ width: 800, height: 600 });
131 });
132
133 it("returns null for unsupported, truncated, or zero-sized input", () => {
134 expect(readImageDimensions(bytes("<svg xmlns='http://www.w3.org/2000/svg'/>"))).toBeNull();
135 expect(readImageDimensions(bytes([0x89], "PNG"))).toBeNull();
136 expect(readImageDimensions(bytes("GIF89a", u16le(0), u16le(240)))).toBeNull();
137 expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0xd9]))).toBeNull();
138 expect(readImageDimensions(new Uint8Array())).toBeNull();
139 });
140});
packages/shared/src/imageDimensions.ts created+154
...@@ -0,0 +1,154 @@
1/**
2 * Reads pixel dimensions from the header bytes of a PNG, JPEG, GIF, or WebP
3 * file so a client can reserve the exact box before the bytes arrive. Any
4 * other format, a truncated header, or a malformed file yields null; callers
5 * fall back to measuring after decode.
6 */
7export interface ImageDimensions {
8 readonly width: number;
9 readonly height: number;
10}
11
12/**
13 * Enough for every supported header. A JPEG's frame header can sit behind
14 * several 64 KiB metadata segments (EXIF, an ICC profile, XMP), so allow a
15 * few of them before giving up.
16 */
17export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024;
18
19export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null {
20 const dimensions = readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes);
21 return dimensions && dimensions.width > 0 && dimensions.height > 0 ? dimensions : null;
22}
23
24const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
25
26function readPng(bytes: Uint8Array): ImageDimensions | null {
27 if (bytes.length < 24) return null;
28 if (
29 bytes[0] !== 0x89 ||
30 bytes[1] !== 0x50 ||
31 bytes[2] !== 0x4e ||
32 bytes[3] !== 0x47 ||
33 bytes[12] !== 0x49 ||
34 bytes[13] !== 0x48 ||
35 bytes[14] !== 0x44 ||
36 bytes[15] !== 0x52
37 ) {
38 return null;
39 }
40 const data = view(bytes);
41 return { width: data.getUint32(16), height: data.getUint32(20) };
42}
43
44function readGif(bytes: Uint8Array): ImageDimensions | null {
45 if (bytes.length < 10) return null;
46 if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null;
47 const data = view(bytes);
48 return { width: data.getUint16(6, true), height: data.getUint16(8, true) };
49}
50
51function readWebp(bytes: Uint8Array): ImageDimensions | null {
52 if (bytes.length < 16) return null;
53 if (
54 bytes[0] !== 0x52 ||
55 bytes[1] !== 0x49 ||
56 bytes[2] !== 0x46 ||
57 bytes[3] !== 0x46 ||
58 bytes[8] !== 0x57 ||
59 bytes[9] !== 0x45 ||
60 bytes[10] !== 0x42 ||
61 bytes[11] !== 0x50
62 ) {
63 return null;
64 }
65 const data = view(bytes);
66 const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!);
67 switch (chunk) {
68 case "VP8 ":
69 if (bytes.length < 30) return null;
70 // Lossy: 14-bit dimensions after the 3-byte frame tag and 3-byte start code.
71 return {
72 width: data.getUint16(26, true) & 0x3fff,
73 height: data.getUint16(28, true) & 0x3fff,
74 };
75 case "VP8L": {
76 if (bytes.length < 25) return null;
77 // Lossless: width-1 in bits 0-13 and height-1 in bits 14-27 of the
78 // 32 bits after the signature byte.
79 const packed = data.getUint32(21, true);
80 return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
81 }
82 case "VP8X":
83 if (bytes.length < 30) return null;
84 // Extended: 24-bit canvas dimensions minus one.
85 return {
86 width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1,
87 height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1,
88 };
89 default:
90 return null;
91 }
92}
93
94function readJpeg(bytes: Uint8Array): ImageDimensions | null {
95 if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
96 const data = view(bytes);
97 let offset = 2;
98 let rotated = false;
99 while (offset + 9 <= bytes.length) {
100 if (bytes[offset] !== 0xff) return null;
101 const marker = bytes[offset + 1]!;
102 // Padding bytes between segments.
103 if (marker === 0xff) {
104 offset += 1;
105 continue;
106 }
107 // Start-of-frame markers carry the dimensions; skip the arithmetic-coding
108 // and Huffman-table markers that share the range.
109 if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
110 const height = data.getUint16(offset + 5);
111 const width = data.getUint16(offset + 7);
112 return rotated ? { width: height, height: width } : { width, height };
113 }
114 if (marker === 0xd9 || marker === 0xda) return null;
115 // TEM and the restart markers stand alone, with no length field.
116 if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
117 offset += 2;
118 continue;
119 }
120 const length = data.getUint16(offset + 2);
121 // Viewers apply the EXIF orientation before display, so a phone photo
122 // stored on its side takes the swapped size on screen.
123 if (marker === 0xe1 && !rotated) {
124 rotated = exifOrientationSwapsAxes(bytes, offset + 4, offset + 2 + length);
125 }
126 offset += 2 + length;
127 }
128 return null;
129}
130
131/** Whether EXIF orientation 5-8 (a 90° rotation) applies. `start` is the APP1 payload. */
132function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean {
133 end = Math.min(end, bytes.length);
134 // "Exif\0\0" then a TIFF header: byte order, 0x2a, and the IFD0 offset.
135 if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") {
136 return false;
137 }
138 const tiff = start + 6;
139 const data = view(bytes);
140 const littleEndian = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;
141 if (!littleEndian && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) return false;
142 const ifd = tiff + data.getUint32(tiff + 4, littleEndian);
143 if (ifd + 2 > end) return false;
144 const entries = data.getUint16(ifd, littleEndian);
145 for (let i = 0; i < entries; i += 1) {
146 const entry = ifd + 2 + i * 12;
147 if (entry + 12 > end) return false;
148 if (data.getUint16(entry, littleEndian) === 0x0112) {
149 const orientation = data.getUint16(entry + 8, littleEndian);
150 return orientation >= 5 && orientation <= 8;
151 }
152 }
153 return false;
154}