# Files changed · PR \#10198 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10198/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(server\): report image dimensions with signed asset URLs](https://git.cafe/pingdotgg/t3code/pull/10198.md)

Version: 9

Head commit: 0c0d4ea243cbc281d399a60588a9419eb9a5a646

Base commit: f8b4c464b4760d73e0ece7e68011c738803d8b69

Comparison base: f8b4c464b4760d73e0ece7e68011c738803d8b69

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/assets/AssetAccess.test.ts

Status: modified; +24; −0

```diff
@@ -83,6 +83,30 @@
     }).pipe(Effect.provide(testLayer)),
   );
 
+  it.effect("reports pixel dimensions from an image header and nothing for other files", () =>
+    Effect.gen(function* () {
+      const fs = yield* FileSystem.FileSystem;
+      const path = yield* Path.Path;
+      const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-dimensions-" });
+      const png = Uint8Array.from([
+        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0,
+        0x06, 0x40, 0, 0, 0x03, 0x84,
+      ]);
+      yield* fs.writeFile(path.join(root, "shot.png"), png);
+      yield* fs.writeFileString(path.join(root, "clip.mp4"), "video");
+      yield* fs.writeFileString(path.join(root, "broken.png"), "not a png");
+      const issue = (name: string) =>
+        issueAssetUrl({
+          resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: name },
+          workspaceRoot: root,
+        });
+
+      expect((yield* issue("shot.png")).imageDimensions).toEqual({ width: 1600, height: 900 });
+      expect((yield* issue("clip.mp4")).imageDimensions).toBeUndefined();
+      expect((yield* issue("broken.png")).imageDimensions).toBeUndefined();
+    }).pipe(Effect.provide(testLayer)),
+  );
+
   it.effect("resolves relative media paths from the thread workspace, including outside it", () =>
     Effect.gen(function* () {
       const fs = yield* FileSystem.FileSystem;
```

## apps/server/src/assets/AssetAccess.ts

Status: modified; +61; −5

```diff
@@ -21,6 +21,11 @@
   WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
   WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
 } from "@t3tools/shared/filePreview";
+import {
+  IMAGE_DIMENSIONS_HEADER_BYTES,
+  readImageDimensions,
+  type ImageDimensions,
+} from "@t3tools/shared/imageDimensions";
 import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
 import * as Clock from "effect/Clock";
 import * as Crypto from "effect/Crypto";
@@ -44,7 +49,7 @@
 import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
 import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
 import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
-import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts";
+import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
 
 export const ASSET_ROUTE_PREFIX = "/api/assets";
 
@@ -224,6 +229,34 @@
     Effect.orElseSucceed(() => null),
   );
 
+/**
+ * Reads pixel dimensions from an image's header so clients can reserve the
+ * exact box before the bytes arrive. Best effort: an unreadable or unsupported
+ * file just leaves the field out, and the client measures after decode. Only
+ * formats the parser understands are opened; SVG and the rest are skipped.
+ */
+const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
+
+/** From the identity-checked, non-blocking handle the caller already holds. */
+const readImageDimensionsFromOpenFile = (filePath: string, file: OpenMediaFile) =>
+  readMediaFileHeader(filePath, file, IMAGE_DIMENSIONS_HEADER_BYTES).pipe(
+    Effect.map(readImageDimensions),
+    Effect.orElseSucceed((): ImageDimensions | null => null),
+  );
+
+/**
+ * Opens through `openMediaFile` so a path swapped for a FIFO cannot block the
+ * request; a regular open would wait for a writer that never comes.
+ */
+const readImageDimensionsFromHeader = (filePath: string) =>
+  openMediaFile(filePath).pipe(
+    Effect.flatMap((file) =>
+      file === null ? Effect.succeed(null) : readImageDimensionsFromOpenFile(filePath, file),
+    ),
+    Effect.scoped,
+    Effect.orElseSucceed((): ImageDimensions | null => null),
+  );
+
 export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
   readonly resource: AssetResource;
   readonly workspaceRoot?: string;
@@ -236,6 +269,7 @@
   let claims: AssetClaims;
   let fileName: string;
   let sourcePath: string | undefined;
+  let imageDimensions: ImageDimensions | null = null;
 
   switch (input.resource._tag) {
     case "media-file": {
@@ -265,18 +299,33 @@
       if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
         return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
       }
+      const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(
+        path.extname(canonicalFile).toLowerCase(),
+      );
+      const opened = yield* openMediaFile(canonicalFile).pipe(
+        Effect.flatMap((file) =>
+          file === null
+            ? Effect.succeed(null)
+            : Effect.map(
+                wantsDimensions
+                  ? readImageDimensionsFromOpenFile(canonicalFile, file)
+                  : Effect.succeed(null),
+                (dimensions) => ({
+                  identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() },
+                  dimensions,
-      const identity = yield* openMediaFile(canonicalFile).pipe(
-        Effect.map((file) =>
-          file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null,
+                }),
+              ),
         ),
         Effect.scoped,
         Effect.mapError(
           (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
         ),
       );
-      if (!identity) {
+      if (!opened) {
         return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
       }
+      const identity = opened.identity;
+      imageDimensions = opened.dimensions;
       claims = {
         version: 1,
         kind: "media-file-exact",
@@ -347,6 +396,9 @@
             }),
         ),
       );
+      if (HEADER_IMAGE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) {
+        imageDimensions = yield* readImageDimensionsFromHeader(canonicalFile);
+      }
       claims = isWorkspaceImagePreviewPath(resolved.relativePath)
         ? {
             version: 1,
@@ -390,6 +442,9 @@
         INLINE_DOCUMENT_EXTENSIONS.has(extension)
           ? INLINE_DOCUMENT_MIME_TYPES[extension]
           : undefined;
+      if (!isGenericFile) {
+        imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath);
+      }
       claims = {
         version: 1,
         kind: "attachment",
@@ -547,6 +602,7 @@
     relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
     expiresAt,
     ...(sourcePath !== undefined ? { sourcePath } : {}),
+    ...(imageDimensions !== null ? { imageDimensions } : {}),
   };
 });
 
```

## apps/server/src/assets/MediaFile.ts

Status: modified; +23; −0

```diff
@@ -19,6 +19,18 @@
   }
 }
 
+class MediaFileReadError extends Schema.TaggedErrorClass<MediaFileReadError>()(
+  "MediaFileReadError",
+  {
+    path: Schema.String,
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Failed to read media file '${this.path}'.`;
+  }
+}
+
 class MediaFileStatError extends Schema.TaggedErrorClass<MediaFileStatError>()(
   "MediaFileStatError",
   {
@@ -95,6 +107,17 @@
   );
 });
 
+/** Reads the leading bytes of an already-validated media file, never past the end. */
+export const readMediaFileHeader = (filePath: string, file: OpenMediaFile, byteCount: number) =>
+  Effect.tryPromise({
+    try: async () => {
+      const buffer = new Uint8Array(byteCount);
+      const { bytesRead } = await file.handle.read(buffer, 0, byteCount, 0);
+      return buffer.subarray(0, bytesRead);
+    },
+    catch: (cause) => new MediaFileReadError({ path: filePath, cause }),
+  });
+
 export const statMediaFile = Effect.fn("statMediaFile")(function* (
   filePath: string,
   file: OpenMediaFile,
```

## packages/client-runtime/src/state/assets.ts

Status: modified; +6; −0

```diff
@@ -1,5 +1,6 @@
 import {
   type AssetCreateUrlResult,
+  type AssetImageDimensions,
   AssetResource,
   EnvironmentId,
   WS_METHODS,
@@ -60,6 +61,8 @@
       readonly url: string;
       /** The host path the server chose to serve, when it differs from what was asked for. */
       readonly sourcePath?: string;
+      /** Pixel size from the image header, when the server could read one. */
+      readonly imageDimensions?: AssetImageDimensions;
     };
 
 export function assetUrlStateFromResult(
@@ -74,6 +77,9 @@
     _tag: "Success",
     url,
     ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
+    ...(result.value.imageDimensions !== undefined
+      ? { imageDimensions: result.value.imageDimensions }
+      : {}),
   };
 }
 
```

## packages/contracts/src/assets.ts

Status: modified; +8; −0

```diff
@@ -52,12 +52,20 @@
 });
 export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;
 
+export const AssetImageDimensions = Schema.Struct({
+  width: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)),
+  height: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)),
+});
+export type AssetImageDimensions = typeof AssetImageDimensions.Type;
+
 export const AssetCreateUrlResult = Schema.Struct({
   relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
   expiresAt: Schema.Number,
   sourcePath: Schema.optional(
     TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
   ),
+  /** Pixel size read from the image header, so a client can reserve the exact box before the bytes arrive. */
+  imageDimensions: Schema.optional(AssetImageDimensions),
 });
 export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
 
```

## packages/shared/package.json

Status: modified; +4; −0

```diff
@@ -211,6 +211,10 @@
       "types": "./src/filePreview.ts",
       "import": "./src/filePreview.ts"
     },
+    "./imageDimensions": {
+      "types": "./src/imageDimensions.ts",
+      "import": "./src/imageDimensions.ts"
+    },
     "./video": {
       "types": "./src/video.ts",
       "import": "./src/video.ts"
```

## packages/shared/src/imageDimensions.test.ts

Status: added; +140; −0

```diff
@@ -1,0 +1,140 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { readImageDimensions } from "./imageDimensions.ts";
+
+function bytes(...parts: ReadonlyArray<number | string | ReadonlyArray<number>>): Uint8Array {
+  const out: number[] = [];
+  for (const part of parts) {
+    if (typeof part === "string") for (const c of part) out.push(c.charCodeAt(0));
+    else if (typeof part === "number") out.push(part);
+    else out.push(...part);
+  }
+  return Uint8Array.from(out);
+}
+
+const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
+const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff];
+const u16le = (n: number) => [n & 0xff, (n >>> 8) & 0xff];
+
+describe("readImageDimensions", () => {
+  it("reads a PNG IHDR", () => {
+    const png = bytes(
+      [0x89],
+      "PNG",
+      [0x0d, 0x0a, 0x1a, 0x0a],
+      u32(13),
+      "IHDR",
+      u32(1600),
+      u32(900),
+    );
+    expect(readImageDimensions(png)).toEqual({ width: 1600, height: 900 });
+  });
+
+  it("reads a GIF logical screen", () => {
+    expect(readImageDimensions(bytes("GIF89a", u16le(320), u16le(240)))).toEqual({
+      width: 320,
+      height: 240,
+    });
+  });
+
+  it("reads a JPEG start-of-frame after an APP segment", () => {
+    const app1 = bytes([0xff, 0xe1], u16(2 + 6), "Exif\0\0");
+    const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(1400), u16(720));
+    expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({
+      width: 720,
+      height: 1400,
+    });
+  });
+
+  it("swaps the axes for a JPEG whose EXIF orientation rotates it 90 degrees", () => {
+    // Big-endian TIFF with one IFD0 entry: tag 0x0112 (orientation), SHORT, count 1, value 6.
+    const tiff = [
+      0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00,
+      0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    ];
+    const exif = ["Exif\0\0", tiff] as const;
+    const app1 = bytes([0xff, 0xe1], u16(2 + 6 + tiff.length), ...exif);
+    const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(3024), u16(4032));
+    expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({
+      width: 3024,
+      height: 4032,
+    });
+    // A later XMP APP1 segment must not clear the rotation.
+    const xmp = bytes([0xff, 0xe1], u16(2 + 29), "http://ns.adobe.com/xap/1.0/\0");
+    expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...xmp], [...sof0]))).toEqual({
+      width: 3024,
+      height: 4032,
+    });
+    // Orientation 1 leaves the frame size alone.
+    const upright = [...tiff];
+    upright[19] = 0x01;
+    const app1Upright = bytes([0xff, 0xe1], u16(2 + 6 + upright.length), "Exif\0\0", upright);
+    expect(readImageDimensions(bytes([0xff, 0xd8], [...app1Upright], [...sof0]))).toEqual({
+      width: 4032,
+      height: 3024,
+    });
+  });
+
+  it("steps over standalone TEM and restart markers", () => {
+    const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(10), u16(20));
+    expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0x01], [0xff, 0xd3], [...sof0]))).toEqual(
+      { width: 20, height: 10 },
+    );
+  });
+
+  it("does not mistake a Huffman table marker for a frame", () => {
+    const dht = bytes([0xff, 0xc4], u16(4), [0, 0]);
+    const sof2 = bytes([0xff, 0xc2], u16(17), [8], u16(10), u16(20));
+    expect(readImageDimensions(bytes([0xff, 0xd8], [...dht], [...sof2]))).toEqual({
+      width: 20,
+      height: 10,
+    });
+  });
+
+  it("reads each WebP container flavour", () => {
+    const riff = (chunk: string, body: ReadonlyArray<number>) =>
+      bytes("RIFF", u32(0), "WEBP", chunk, u32(body.length), body);
+    // VP8: frame tag (3), start code (3), then 14-bit width and height.
+    expect(
+      readImageDimensions(riff("VP8 ", [0, 0, 0, 0x9d, 0x01, 0x2a, ...u16le(800), ...u16le(600)])),
+    ).toEqual({ width: 800, height: 600 });
+    // VP8L: signature 0x2f, then width-1 (14 bits) and height-1 (14 bits) packed LE.
+    const packed = (800 - 1) | ((600 - 1) << 14);
+    expect(
+      readImageDimensions(
+        riff("VP8L", [
+          0x2f,
+          packed & 0xff,
+          (packed >>> 8) & 0xff,
+          (packed >>> 16) & 0xff,
+          (packed >>> 24) & 0xff,
+        ]),
+      ),
+    ).toEqual({ width: 800, height: 600 });
+    // VP8X: flags (4), then 24-bit width-1 and height-1.
+    expect(
+      readImageDimensions(
+        riff("VP8X", [
+          0,
+          0,
+          0,
+          0,
+          799 & 0xff,
+          (799 >> 8) & 0xff,
+          0,
+          599 & 0xff,
+          (599 >> 8) & 0xff,
+          0,
+        ]),
+      ),
+    ).toEqual({ width: 800, height: 600 });
+  });
+
+  it("returns null for unsupported, truncated, or zero-sized input", () => {
+    expect(readImageDimensions(bytes("<svg xmlns='http://www.w3.org/2000/svg'/>"))).toBeNull();
+    expect(readImageDimensions(bytes([0x89], "PNG"))).toBeNull();
+    expect(readImageDimensions(bytes("GIF89a", u16le(0), u16le(240)))).toBeNull();
+    expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0xd9]))).toBeNull();
+    expect(readImageDimensions(new Uint8Array())).toBeNull();
+  });
+});
```

## packages/shared/src/imageDimensions.ts

Status: added; +154; −0

```diff
@@ -1,0 +1,154 @@
+/**
+ * Reads pixel dimensions from the header bytes of a PNG, JPEG, GIF, or WebP
+ * file so a client can reserve the exact box before the bytes arrive. Any
+ * other format, a truncated header, or a malformed file yields null; callers
+ * fall back to measuring after decode.
+ */
+export interface ImageDimensions {
+  readonly width: number;
+  readonly height: number;
+}
+
+/**
+ * Enough for every supported header. A JPEG's frame header can sit behind
+ * several 64 KiB metadata segments (EXIF, an ICC profile, XMP), so allow a
+ * few of them before giving up.
+ */
+export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024;
+
+export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null {
+  const dimensions = readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes);
+  return dimensions && dimensions.width > 0 && dimensions.height > 0 ? dimensions : null;
+}
+
+const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+
+function readPng(bytes: Uint8Array): ImageDimensions | null {
+  if (bytes.length < 24) return null;
+  if (
+    bytes[0] !== 0x89 ||
+    bytes[1] !== 0x50 ||
+    bytes[2] !== 0x4e ||
+    bytes[3] !== 0x47 ||
+    bytes[12] !== 0x49 ||
+    bytes[13] !== 0x48 ||
+    bytes[14] !== 0x44 ||
+    bytes[15] !== 0x52
+  ) {
+    return null;
+  }
+  const data = view(bytes);
+  return { width: data.getUint32(16), height: data.getUint32(20) };
+}
+
+function readGif(bytes: Uint8Array): ImageDimensions | null {
+  if (bytes.length < 10) return null;
+  if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null;
+  const data = view(bytes);
+  return { width: data.getUint16(6, true), height: data.getUint16(8, true) };
+}
+
+function readWebp(bytes: Uint8Array): ImageDimensions | null {
+  if (bytes.length < 16) return null;
+  if (
+    bytes[0] !== 0x52 ||
+    bytes[1] !== 0x49 ||
+    bytes[2] !== 0x46 ||
+    bytes[3] !== 0x46 ||
+    bytes[8] !== 0x57 ||
+    bytes[9] !== 0x45 ||
+    bytes[10] !== 0x42 ||
+    bytes[11] !== 0x50
+  ) {
+    return null;
+  }
+  const data = view(bytes);
+  const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!);
+  switch (chunk) {
+    case "VP8 ":
+      if (bytes.length < 30) return null;
+      // Lossy: 14-bit dimensions after the 3-byte frame tag and 3-byte start code.
+      return {
+        width: data.getUint16(26, true) & 0x3fff,
+        height: data.getUint16(28, true) & 0x3fff,
+      };
+    case "VP8L": {
+      if (bytes.length < 25) return null;
+      // Lossless: width-1 in bits 0-13 and height-1 in bits 14-27 of the
+      // 32 bits after the signature byte.
+      const packed = data.getUint32(21, true);
+      return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
+    }
+    case "VP8X":
+      if (bytes.length < 30) return null;
+      // Extended: 24-bit canvas dimensions minus one.
+      return {
+        width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1,
+        height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1,
+      };
+    default:
+      return null;
+  }
+}
+
+function readJpeg(bytes: Uint8Array): ImageDimensions | null {
+  if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
+  const data = view(bytes);
+  let offset = 2;
+  let rotated = false;
+  while (offset + 9 <= bytes.length) {
+    if (bytes[offset] !== 0xff) return null;
+    const marker = bytes[offset + 1]!;
+    // Padding bytes between segments.
+    if (marker === 0xff) {
+      offset += 1;
+      continue;
+    }
+    // Start-of-frame markers carry the dimensions; skip the arithmetic-coding
+    // and Huffman-table markers that share the range.
+    if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
+      const height = data.getUint16(offset + 5);
+      const width = data.getUint16(offset + 7);
+      return rotated ? { width: height, height: width } : { width, height };
+    }
+    if (marker === 0xd9 || marker === 0xda) return null;
+    // TEM and the restart markers stand alone, with no length field.
+    if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
+      offset += 2;
+      continue;
+    }
+    const length = data.getUint16(offset + 2);
+    // Viewers apply the EXIF orientation before display, so a phone photo
+    // stored on its side takes the swapped size on screen.
+    if (marker === 0xe1 && !rotated) {
+      rotated = exifOrientationSwapsAxes(bytes, offset + 4, offset + 2 + length);
+    }
+    offset += 2 + length;
+  }
+  return null;
+}
+
+/** Whether EXIF orientation 5-8 (a 90° rotation) applies. `start` is the APP1 payload. */
+function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean {
+  end = Math.min(end, bytes.length);
+  // "Exif\0\0" then a TIFF header: byte order, 0x2a, and the IFD0 offset.
+  if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") {
+    return false;
+  }
+  const tiff = start + 6;
+  const data = view(bytes);
+  const littleEndian = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;
+  if (!littleEndian && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) return false;
+  const ifd = tiff + data.getUint32(tiff + 4, littleEndian);
+  if (ifd + 2 > end) return false;
+  const entries = data.getUint16(ifd, littleEndian);
+  for (let i = 0; i < entries; i += 1) {
+    const entry = ifd + 2 + i * 12;
+    if (entry + 12 > end) return false;
+    if (data.getUint16(entry, littleEndian) === 0x0112) {
+      const orientation = data.getUint16(entry + 8, littleEndian);
+      return orientation >= 5 && orientation <= 8;
+    }
+  }
+  return false;
+}
```
