Initial Commit
This commit is contained in:
5
packages/foundation/data/media/src/metadata/index.ts
Normal file
5
packages/foundation/data/media/src/metadata/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getMetadata,
|
||||
getMetadataAsync,
|
||||
type MetadataOptions,
|
||||
} from "./metadata.js";
|
||||
65
packages/foundation/data/media/src/metadata/metadata.md
Normal file
65
packages/foundation/data/media/src/metadata/metadata.md
Normal 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.
|
||||
94
packages/foundation/data/media/src/metadata/metadata.test.ts
Normal file
94
packages/foundation/data/media/src/metadata/metadata.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
111
packages/foundation/data/media/src/metadata/metadata.ts
Normal file
111
packages/foundation/data/media/src/metadata/metadata.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user