Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
export {
getMetadata,
getMetadataAsync,
type MetadataOptions,
} from "./metadata.js";

View File

@@ -0,0 +1,65 @@
# Metadata
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds a `MediaMetadata` bundle (type, MIME, size, matched signature
label) from raw bytes. The sync helper produces the base bundle in a
single pass; the async helper layers a user-supplied
`MediaMetadataExtractor` over the base so callers can fill in
format-specific fields (width, height, duration) without forking the
package.
## Features
- `getMetadata(input, opts?)` — synchronous, no extractor.
- `getMetadataAsync(input, opts?)` — awaits the optional extractor and
merges its return into the base bundle.
- MIME resolution prefers the explicit `filename` field; otherwise it
falls back to `inferMimeFromSignature`.
- Signature label exposed via `metadata.signatureLabel` for downstream
diagnostics.
- Extractor errors are wrapped in `MediaError(MEDIA.METADATA_EXTRACTOR_FAILED)`
with the original error chained as `.cause`.
## Usage: Basic
```ts
import { getMetadata } from "@suaveplan/media";
const meta = getMetadata(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
filename: "logo.png",
});
console.log(meta.type, meta.mimeType, meta.size, meta.signatureLabel);
```
## Usage: Advanced
```ts
import { getMetadataAsync } from "@suaveplan/media";
const meta = await getMetadataAsync(buf, {
filename: "clip.mp4",
extractor: async (_bytes, base) => {
const { width, height, duration } = await probe(buf);
return { ...base, width, height, duration };
},
});
```
## API Reference
- `MetadataOptions` — `{ filename?, registry?, observability?, extractor? }`.
- `getMetadata(input, opts?)` — synchronous `MediaMetadata`.
- `getMetadataAsync(input, opts?)` — `Promise<MediaMetadata>`.
## Implementation Notes
The base bundle is built inside a private helper so both entrypoints
share signature detection + MIME resolution. The async variant only
forks off the base when an extractor is supplied, so unused await paths
incur near-zero overhead. Latency histograms are recorded in a
`finally` block so failed extractor calls still produce telemetry.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MEDIA_CODES, MediaError } from "../errors/errors.js";
import { createMediaObservability } from "../observability/observability.js";
import { getMetadata, getMetadataAsync } from "./metadata.js";
describe("getMetadata", () => {
it("returns type, mime, size, label for PNG bytes", {
timeout: 5000,
}, () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00]);
const meta = getMetadata(png, { filename: "x.png" });
expect(meta.type).toBe("image");
expect(meta.mimeType).toBe("image/png");
expect(meta.size).toBe(6);
expect(meta.signatureLabel).toBe("PNG");
});
it("infers mime from signature when no filename", {
timeout: 5000,
}, () => {
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
const meta = getMetadata(jpeg);
expect(meta.type).toBe("image");
expect(meta.mimeType).toBe("image/jpeg");
expect(meta.size).toBe(4);
});
it("returns unknown + octet-stream for unrecognised bytes", {
timeout: 5000,
}, () => {
const meta = getMetadata(new Uint8Array([0x00]));
expect(meta.type).toBe("unknown");
expect(meta.mimeType).toBe("application/octet-stream");
expect(meta.signatureLabel).toBeUndefined();
});
it("respects injected observability", { timeout: 5000 }, () => {
const obs = createMediaObservability();
const meta = getMetadata(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
observability: obs,
});
expect(meta.type).toBe("image");
});
});
describe("getMetadataAsync", () => {
it("returns base metadata when no extractor", {
timeout: 5000,
}, async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const meta = await getMetadataAsync(png);
expect(meta.type).toBe("image");
expect(meta.size).toBe(4);
});
it("merges extractor output", { timeout: 5000 }, async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const meta = await getMetadataAsync(png, {
extractor: async (_input, base) => ({
...base,
width: 100,
height: 50,
}),
});
expect(meta.width).toBe(100);
expect(meta.height).toBe(50);
expect(meta.type).toBe("image");
});
it("wraps extractor error in MediaError", { timeout: 5000 }, async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
try {
await getMetadataAsync(png, {
extractor: () => {
throw new Error("boom");
},
});
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(
MEDIA_CODES.METADATA_EXTRACTOR_FAILED
);
expect((err as MediaError).cause).toBeInstanceOf(Error);
}
});
it("uses injected observability", { timeout: 5000 }, async () => {
const obs = createMediaObservability();
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const meta = await getMetadataAsync(png, { observability: obs });
expect(meta.type).toBe("image");
});
});

View File

@@ -0,0 +1,111 @@
import { getMimeType } from "@suaveplan/mime";
import {
detectMediaType,
detectSignature,
inferMimeFromSignature,
} from "../detection/detection.js";
import { asMediaError, MEDIA_CODES } from "../errors/errors.js";
import type { MediaObservability } from "../observability/observability.js";
import { createMediaObservability } from "../observability/observability.js";
import type { SignatureRegistry } from "../signatures/signatures.js";
import type {
MediaMetadata,
MediaMetadataExtractor,
} from "../types/media.types.js";
/** Options for the metadata helpers. */
export interface MetadataOptions {
/** Original filename, used to refine MIME-type detection. */
readonly filename?: string | undefined;
/** Custom signature registry. */
readonly registry?: SignatureRegistry | undefined;
/** Observability bundle. */
readonly observability?: MediaObservability | undefined;
/** Optional async hook to extend metadata with format-specific fields. */
readonly extractor?: MediaMetadataExtractor | undefined;
}
function buildBase(
input: Uint8Array,
opts: MetadataOptions | undefined
): MediaMetadata {
const sig = detectSignature(input, {
registry: opts?.registry,
observability: opts?.observability,
});
const type = sig?.type ?? "unknown";
const mimeType = opts?.filename
? getMimeType(opts.filename)
: inferMimeFromSignature(sig);
if (sig !== null) {
return {
type,
mimeType,
size: input.byteLength,
signatureLabel: sig.label,
};
}
return { type, mimeType, size: input.byteLength };
}
/**
* Extract synchronous metadata from media bytes. Detects the media type via
* the signature registry and resolves a MIME type either from the supplied
* filename or from the matched signature.
*/
export function getMetadata(
input: Uint8Array,
opts?: MetadataOptions
): MediaMetadata {
const obs = opts?.observability ?? createMediaObservability();
const start = performance.now();
try {
// Touch detectMediaType for shape consistency with prior surface.
detectMediaType(input, {
registry: opts?.registry,
observability: obs,
});
return buildBase(input, opts);
} finally {
obs.counters.metadataTotal.add(1, { mode: "sync" });
obs.histograms.opLatencyMs.record(performance.now() - start, {
op: "getMetadata",
});
}
}
/**
* Async variant of {@link getMetadata} that runs the optional
* `MediaMetadataExtractor` hook so callers can layer format-specific
* fields (width, height, duration, codec, …) onto the base metadata.
*
* If the extractor throws, the error is wrapped in a `MediaError` with
* code `MEDIA.METADATA_EXTRACTOR_FAILED` and the original error chained
* as `.cause`.
*/
export async function getMetadataAsync(
input: Uint8Array,
opts?: MetadataOptions
): Promise<MediaMetadata> {
const obs = opts?.observability ?? createMediaObservability();
const start = performance.now();
try {
const base = buildBase(input, opts);
if (!opts?.extractor) return base;
try {
const extra = await opts.extractor(input, base);
return { ...base, ...extra };
} catch (cause) {
throw asMediaError(
MEDIA_CODES.METADATA_EXTRACTOR_FAILED,
cause,
"metadata extractor threw"
);
}
} finally {
obs.counters.metadataTotal.add(1, { mode: "async" });
obs.histograms.opLatencyMs.record(performance.now() - start, {
op: "getMetadataAsync",
});
}
}