| author | |
| committer | |
| log | e2e6ce6a29451e9fac612ab83f7a104b6d6475a3 |
| tree | 1d273c31f8fb6e52bc8e06b752836bb6d581b084 |
| parent | f8b4c464b4760d73e0ece7e68011c738803d8b69 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 85 | 85 | ||
| 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"; |
| 24 | import { | ||
| 25 | IMAGE_DIMENSIONS_HEADER_BYTES, | ||
| 26 | readImageDimensions, | ||
| 27 | type ImageDimensions, | ||
| 28 | } from "@t3tools/shared/imageDimensions"; | ||
| 24 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; | 29 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 25 | import * as Clock from "effect/Clock"; | 30 | import * as Clock from "effect/Clock"; |
| 26 | import * as Crypto from "effect/Crypto"; | 31 | import * 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"; |
| 44 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 49 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 45 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 50 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 46 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; | 51 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; |
| 47 | import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; | 52 | import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; |
| 48 | 53 | ||
| 49 | export const ASSET_ROUTE_PREFIX = "/api/assets"; | 54 | export const ASSET_ROUTE_PREFIX = "/api/assets"; |
| 50 | 55 | ||
| ... | @@ -224,6 +229,34 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { | ... | @@ -224,6 +229,34 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { |
| 224 | Effect.orElseSucceed(() => null), | 229 | Effect.orElseSucceed(() => null), |
| 225 | ); | 230 | ); |
| 226 | 231 | ||
| 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 | */ | ||
| 238 | const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); | ||
| 239 | |||
| 240 | /** From the identity-checked, non-blocking handle the caller already holds. */ | ||
| 241 | const 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 | */ | ||
| 251 | const 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 | |||
| 227 | export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { | 260 | export 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; | ||
| 239 | 273 | ||
| 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 | }); |
| 552 | 608 |
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 | } |
| 21 | 21 | ||
| 22 | class 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 | |||
| 22 | class MediaFileStatError extends Schema.TaggedErrorClass<MediaFileStatError>()( | 34 | class 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 | }); |
| 97 | 109 | ||
| 110 | /** Reads the leading bytes of an already-validated media file, never past the end. */ | ||
| 111 | export 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 | |||
| 98 | export const statMediaFile = Effect.fn("statMediaFile")(function* ( | 121 | export 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 @@ |
| 1 | import { | 1 | import { |
| 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 | }; |
| 64 | 67 | ||
| 65 | export function assetUrlStateFromResult( | 68 | export 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 | } |
| 79 | 85 |
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 | }); |
| 53 | export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; | 53 | export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; |
| 54 | 54 | ||
| 55 | export const AssetImageDimensions = Schema.Struct({ | ||
| 56 | width: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), | ||
| 57 | height: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), | ||
| 58 | }); | ||
| 59 | export type AssetImageDimensions = typeof AssetImageDimensions.Type; | ||
| 60 | |||
| 55 | export const AssetCreateUrlResult = Schema.Struct({ | 61 | export 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 | }); |
| 62 | export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; | 70 | export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; |
| 63 | 71 |
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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { readImageDimensions } from "./imageDimensions.ts"; | ||
| 4 | |||
| 5 | function 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 | |||
| 15 | const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; | ||
| 16 | const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff]; | ||
| 17 | const u16le = (n: number) => [n & 0xff, (n >>> 8) & 0xff]; | ||
| 18 | |||
| 19 | describe("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 | */ | ||
| 7 | export 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 | */ | ||
| 17 | export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024; | ||
| 18 | |||
| 19 | export 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 | |||
| 24 | const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | ||
| 25 | |||
| 26 | function 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 | |||
| 44 | function 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 | |||
| 51 | function 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 | |||
| 94 | function 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. */ | ||
| 132 | function 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 | } | ||