Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
067bb16
Add ImageDef/PngDef with format components and PNG metadata extraction
lukemelia Feb 2, 2026
dff9575
Fix prettier formatting in png-image-def-test
lukemelia Feb 3, 2026
563f299
Remove duplicate Uint8Array check in test adapter
lukemelia Feb 3, 2026
785ef6a
Fix tests to use valid PNG bytes for PngDef extraction
lukemelia Feb 3, 2026
2729ec5
Use .txt files in FileDef render tests to avoid PngDef behavior
lukemelia Feb 3, 2026
1624d91
Add test for authenticated image rendering in browser
lukemelia Feb 3, 2026
b729e27
Add PngDefPlayground implementation with image gallery and format com…
lukemelia Feb 3, 2026
5a79afb
Implement JpgImageDef with JPEG dimension extraction and tests
lukemelia Feb 4, 2026
678a665
Implement SvgImageDef with SVG dimension extraction and tests
lukemelia Feb 4, 2026
e3d7513
Implement GifImageDef with GIF dimension extraction and tests
lukemelia Feb 4, 2026
2413690
Implement WebpImageDef with WebP dimension extraction and tests
lukemelia Feb 4, 2026
846b6ae
Implement AvifImageDef with AVIF dimension extraction and tests
lukemelia Feb 10, 2026
65b0f06
fix: prettier formatting in webp-image-def-test
Feb 10, 2026
2e601c9
Avoid materializing entire image streams for dimension extraction
lukemelia Feb 10, 2026
2691d54
Walk ISOBMFF box tree instead of brute-force scanning for AVIF dimens…
lukemelia Feb 10, 2026
9e56550
Fix authenticated image display tests with test service worker relay
lukemelia Feb 10, 2026
24c6db5
fix: lint errors in test service worker and png-image-def test
lukemelia Feb 10, 2026
949c0d0
refactor: move image-def tests into subdirectory
Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/base/avif-image-def.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { readFirstBytes } from '@cardstack/runtime-common';
import { ImageDef } from './image-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import { extractAvifDimensions } from './avif-meta-extractor';

// The AVIF ispe box is typically within the first few KB, but can follow
// other ISOBMFF boxes. 64 KB covers virtually all real-world files.
const AVIF_MAX_HEADER_BYTES = 65_536;

export class AvifDef extends ImageDef {
static displayName = 'AVIF Image';

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
options: { contentHash?: string } = {},
): Promise<SerializedFile<{ width: number; height: number }>> {
let base = await super.extractAttributes(url, getStream, options);
let bytes = await readFirstBytes(await getStream(), AVIF_MAX_HEADER_BYTES);
let { width, height } = extractAvifDimensions(bytes);

return {
...base,
width,
height,
};
}
}
164 changes: 164 additions & 0 deletions packages/base/avif-meta-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { FileContentMismatchError } from './file-api';

// AVIF uses ISO Base Media File Format (ISOBMFF).
// The file starts with a "ftyp" box whose brand is "avif" or "avis".
// Image dimensions live in an "ispe" box at: meta > iprp > ipco > ispe.
const FTYP_MARKER = new Uint8Array([0x66, 0x74, 0x79, 0x70]); // "ftyp"
const AVIF_BRAND = new Uint8Array([0x61, 0x76, 0x69, 0x66]); // "avif"
const AVIS_BRAND = new Uint8Array([0x61, 0x76, 0x69, 0x73]); // "avis"

// Minimum: ftyp box header (8) + major brand (4) = 12 bytes
const MIN_BYTES = 12;

function matchBytes(
bytes: Uint8Array,
offset: number,
pattern: Uint8Array,
): boolean {
if (offset + pattern.length > bytes.length) {
return false;
}
for (let i = 0; i < pattern.length; i++) {
if (bytes[offset + i] !== pattern[i]) {
return false;
}
}
return true;
}

function readBoxType(bytes: Uint8Array, offset: number): string {
return String.fromCharCode(
bytes[offset]!,
bytes[offset + 1]!,
bytes[offset + 2]!,
bytes[offset + 3]!,
);
}

// Walk sibling boxes within a region and return the offset range of the first
// box matching `targetType`, or undefined if not found.
function findBox(
view: DataView,
bytes: Uint8Array,
start: number,
end: number,
targetType: string,
): { start: number; end: number } | undefined {
let offset = start;
while (offset + 8 <= end) {
let size = view.getUint32(offset);
// size == 0 means the box extends to end of data
let boxEnd = size === 0 ? end : offset + size;
if (size !== 0 && size < 8) {
break; // invalid box size
}
if (boxEnd > end) {
break;
}
let type = readBoxType(bytes, offset + 4);
if (type === targetType) {
return { start: offset, end: boxEnd };
}
offset = boxEnd;
}
return undefined;
}

function validateAvifSignature(bytes: Uint8Array): void {
if (bytes.length < MIN_BYTES) {
throw new FileContentMismatchError(
'File is too small to be a valid AVIF image',
);
}

// ftyp box: [size: 4] ["ftyp": 4] [major_brand: 4] [minor_version: 4] [compatible_brands...]
if (!matchBytes(bytes, 4, FTYP_MARKER)) {
throw new FileContentMismatchError(
'File does not have a valid AVIF signature',
);
}

let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let ftypSize = view.getUint32(0);
if (ftypSize < MIN_BYTES || ftypSize > bytes.length) {
ftypSize = Math.min(bytes.length, 64);
}

// Check major brand (offset 8) and compatible brands (offset 16+) for "avif" or "avis"
let hasAvifBrand = false;

if (matchBytes(bytes, 8, AVIF_BRAND) || matchBytes(bytes, 8, AVIS_BRAND)) {
hasAvifBrand = true;
}

if (!hasAvifBrand) {
for (let offset = 16; offset + 4 <= ftypSize; offset += 4) {
if (
matchBytes(bytes, offset, AVIF_BRAND) ||
matchBytes(bytes, offset, AVIS_BRAND)
) {
hasAvifBrand = true;
break;
}
}
}

if (!hasAvifBrand) {
throw new FileContentMismatchError('File does not have a valid AVIF brand');
}
}

export function extractAvifDimensions(bytes: Uint8Array): {
width: number;
height: number;
} {
validateAvifSignature(bytes);

let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);

// Walk the ISOBMFF box tree: top-level → meta → iprp → ipco → ispe
let meta = findBox(view, bytes, 0, bytes.length, 'meta');
if (!meta) {
throw new FileContentMismatchError(
'AVIF file does not contain a meta box',
);
}

// meta is a "full box": 8-byte header + 4-byte version/flags before children
let iprp = findBox(view, bytes, meta.start + 12, meta.end, 'iprp');
if (!iprp) {
throw new FileContentMismatchError(
'AVIF file does not contain an iprp box',
);
}

let ipco = findBox(view, bytes, iprp.start + 8, iprp.end, 'ipco');
if (!ipco) {
throw new FileContentMismatchError(
'AVIF file does not contain an ipco box',
);
}

let ispe = findBox(view, bytes, ipco.start + 8, ipco.end, 'ispe');
if (!ispe) {
throw new FileContentMismatchError(
'AVIF file does not contain image dimensions (ispe box not found)',
);
}

// ispe: [size:4] [type:4] [version+flags:4] [width:4] [height:4] = 20 bytes
if (ispe.end - ispe.start < 20) {
throw new FileContentMismatchError('AVIF ispe box is truncated');
}

let width = view.getUint32(ispe.start + 12);
let height = view.getUint32(ispe.start + 16);

if (width === 0 || height === 0) {
throw new FileContentMismatchError(
'AVIF ispe box contains zero dimensions',
);
}

return { width, height };
}
24 changes: 24 additions & 0 deletions packages/base/gif-image-def.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { readFirstBytes } from '@cardstack/runtime-common';
import { ImageDef } from './image-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import { extractGifDimensions } from './gif-meta-extractor';

export class GifDef extends ImageDef {
static displayName = 'GIF Image';

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
options: { contentHash?: string } = {},
): Promise<SerializedFile<{ width: number; height: number }>> {
let base = await super.extractAttributes(url, getStream, options);
let bytes = await readFirstBytes(await getStream(), 10);
let { width, height } = extractGifDimensions(bytes);

return {
...base,
width,
height,
};
}
}
45 changes: 45 additions & 0 deletions packages/base/gif-meta-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { FileContentMismatchError } from './file-api';

// GIF files start with either "GIF87a" or "GIF89a" (6 bytes)
const GIF87A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]);
const GIF89A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);

// Minimum bytes needed: 6 (signature) + 4 (width + height)
const MIN_BYTES = 10;

function validateGifSignature(bytes: Uint8Array): void {
if (bytes.length < 6) {
throw new FileContentMismatchError(
'File is too small to be a valid GIF image',
);
}

let isGif87a = GIF87A_SIGNATURE.every((b, i) => bytes[i] === b);
let isGif89a = GIF89A_SIGNATURE.every((b, i) => bytes[i] === b);

if (!isGif87a && !isGif89a) {
throw new FileContentMismatchError(
'File does not have a valid GIF signature',
);
}
}

export function extractGifDimensions(bytes: Uint8Array): {
width: number;
height: number;
} {
validateGifSignature(bytes);

if (bytes.length < MIN_BYTES) {
throw new FileContentMismatchError(
'GIF file is too small to contain image dimensions',
);
}

// Width is at bytes 6-7, height at 8-9 (little-endian uint16)
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let width = view.getUint16(6, true);
let height = view.getUint16(8, true);

return { width, height };
}
Loading
Loading