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", () => {
8383 }).pipe(Effect.provide(testLayer)),
8484 );
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
86110 it.effect("resolves relative media paths from the thread workspace, including outside it", () =>
87111 Effect.gen(function* () {
88112 const fs = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+61-5
......@@ -21,6 +21,11 @@ import {
2121 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
2222 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
2323} from "@t3tools/shared/filePreview";
24import {
25 IMAGE_DIMENSIONS_HEADER_BYTES,
26 readImageDimensions,
27 type ImageDimensions,
28} from "@t3tools/shared/imageDimensions";
2429import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
2530import * as Clock from "effect/Clock";
2631import * as Crypto from "effect/Crypto";
......@@ -44,7 +49,7 @@ import * as ServerConfig from "../config.ts";
4449import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
4550import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
4651import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
47import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts";
52import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
4853
4954export const ASSET_ROUTE_PREFIX = "/api/assets";
5055
......@@ -224,6 +229,34 @@ const resolveCanonicalWorkspaceFileForRequest = (input: {
224229 Effect.orElseSucceed(() => null),
225230 );
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
227260export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
228261 readonly resource: AssetResource;
229262 readonly workspaceRoot?: string;
......@@ -236,6 +269,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
236269 let claims: AssetClaims;
237270 let fileName: string;
238271 let sourcePath: string | undefined;
272 let imageDimensions: ImageDimensions | null = null;
239273
240274 switch (input.resource._tag) {
241275 case "media-file": {
......@@ -265,18 +299,33 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
265299 if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
266300 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
267301 }
268 const identity = yield* openMediaFile(canonicalFile).pipe(
269 Effect.map((file) =>
270 file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null,
302 const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(
303 path.extname(canonicalFile).toLowerCase(),
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 ),
271318 ),
272319 Effect.scoped,
273320 Effect.mapError(
274321 (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
275322 ),
276323 );
277 if (!identity) {
324 if (!opened) {
278325 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
279326 }
327 const identity = opened.identity;
328 imageDimensions = opened.dimensions;
280329 claims = {
281330 version: 1,
282331 kind: "media-file-exact",
......@@ -347,6 +396,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
347396 }),
348397 ),
349398 );
399 if (HEADER_IMAGE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) {
400 imageDimensions = yield* readImageDimensionsFromHeader(canonicalFile);
401 }
350402 claims = isWorkspaceImagePreviewPath(resolved.relativePath)
351403 ? {
352404 version: 1,
......@@ -390,6 +442,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
390442 INLINE_DOCUMENT_EXTENSIONS.has(extension)
391443 ? INLINE_DOCUMENT_MIME_TYPES[extension]
392444 : undefined;
445 if (!isGenericFile) {
446 imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath);
447 }
393448 claims = {
394449 version: 1,
395450 kind: "attachment",
......@@ -547,6 +602,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
547602 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
548603 expiresAt,
549604 ...(sourcePath !== undefined ? { sourcePath } : {}),
605 ...(imageDimensions !== null ? { imageDimensions } : {}),
550606 };
551607});
552608
apps/server/src/assets/MediaFile.ts+23
......@@ -19,6 +19,18 @@ class MediaFileOpenError extends Schema.TaggedErrorClass<MediaFileOpenError>()(
1919 }
2020}
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
2234class MediaFileStatError extends Schema.TaggedErrorClass<MediaFileStatError>()(
2335 "MediaFileStatError",
2436 {
......@@ -95,6 +107,17 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* (
95107 );
96108});
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
98121export const statMediaFile = Effect.fn("statMediaFile")(function* (
99122 filePath: string,
100123 file: OpenMediaFile,
packages/client-runtime/src/state/assets.ts+6
......@@ -1,5 +1,6 @@
11import {
22 type AssetCreateUrlResult,
3 type AssetImageDimensions,
34 AssetResource,
45 EnvironmentId,
56 WS_METHODS,
......@@ -60,6 +61,8 @@ export type AssetUrlState =
6061 readonly url: string;
6162 /** The host path the server chose to serve, when it differs from what was asked for. */
6263 readonly sourcePath?: string;
64 /** Pixel size from the image header, when the server could read one. */
65 readonly imageDimensions?: AssetImageDimensions;
6366 };
6467
6568export function assetUrlStateFromResult(
......@@ -74,6 +77,9 @@ export function assetUrlStateFromResult(
7477 _tag: "Success",
7578 url,
7679 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
80 ...(result.value.imageDimensions !== undefined
81 ? { imageDimensions: result.value.imageDimensions }
82 : {}),
7783 };
7884}
7985
packages/contracts/src/assets.ts+8
......@@ -52,12 +52,20 @@ export const AssetCreateUrlInput = Schema.Struct({
5252});
5353export 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
5561export const AssetCreateUrlResult = Schema.Struct({
5662 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
5763 expiresAt: Schema.Number,
5864 sourcePath: Schema.optional(
5965 TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
6066 ),
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),
6169});
6270export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
6371
packages/shared/package.json+4
......@@ -211,6 +211,10 @@
211211 "types": "./src/filePreview.ts",
212212 "import": "./src/filePreview.ts"
213213 },
214 "./imageDimensions": {
215 "types": "./src/imageDimensions.ts",
216 "import": "./src/imageDimensions.ts"
217 },
214218 "./video": {
215219 "types": "./src/video.ts",
216220 "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}