@zerotal/media 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -1
- package/README.md +10 -6
- package/package.json +8 -6
- package/src/Media.ts +2 -3
- package/src/MediaItem.ts +1 -0
- package/src/collections/resolve.ts +5 -0
- package/src/collections/retention.ts +2 -0
- package/src/config.ts +1 -0
- package/src/conversions/BunImageDriver.ts +151 -43
- package/src/conversions/ConversionRunner.ts +5 -0
- package/src/conversions/ImageDriver.ts +46 -10
- package/src/conversions/SharpImageDriver.ts +58 -12
- package/src/conversions/dispatch.ts +3 -0
- package/src/conversions/raster.ts +510 -0
- package/src/errors.ts +27 -3
- package/src/index.ts +9 -21
- package/src/mediaSchemaConcern.ts +2 -0
- package/src/paths/PathGenerator.ts +1 -1
- package/src/sources.ts +8 -0
- package/src/support/disks.ts +4 -0
- package/src/testing.ts +35 -0
- package/src/types.ts +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,10 +4,90 @@ All notable changes to this package are documented here. The format is
|
|
|
4
4
|
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
5
|
follows the Zerotal monorepo's unified versioning.
|
|
6
6
|
|
|
7
|
-
**Maturity: `
|
|
7
|
+
**Maturity: `stable`** — the public API follows SemVer strictly: anything
|
|
8
|
+
importable without an `@internal` marker keeps its shape for the rest of the 1.x
|
|
9
|
+
line, and `api-surface.md` is diffed by CI on every change.
|
|
8
10
|
|
|
9
11
|
## [Unreleased]
|
|
10
12
|
|
|
13
|
+
## [1.5.0] — 2026-08-15
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **`@zerotal/media` is `stable`.** The blocker was never the feature set — it was
|
|
18
|
+
that the default driver could not centre-crop, so freezing `ImageDriver` would
|
|
19
|
+
have frozen the workaround with it. `fit: "cover"` on `BunImageDriver` removed
|
|
20
|
+
that, and nothing reopened the promotion afterwards, which is the only reason
|
|
21
|
+
this package was still beta.
|
|
22
|
+
|
|
23
|
+
Two things had to happen first, and both are the promotion rather than
|
|
24
|
+
paperwork around it:
|
|
25
|
+
|
|
26
|
+
- **The surface was triaged.** It listed 79 exports and carried exactly one
|
|
27
|
+
`@internal` marker — plumbing that had leaked out of a module because
|
|
28
|
+
something else in the package needed it. Collection resolution, retention,
|
|
29
|
+
conversion dispatch, source resolvers, disk resolution, schema provisioning
|
|
30
|
+
and the config defaults are now marked `@internal`. Narrowing a surface after
|
|
31
|
+
`stable` is itself a breaking change, so an export that shipped stable by
|
|
32
|
+
accident would have been stuck for the rest of 1.x.
|
|
33
|
+
- **What survived is documented.** All 47 promised exports appear in
|
|
34
|
+
`docs/media.md`, and `bun run docs:coverage` now fails CI if that stops being
|
|
35
|
+
true. A SemVer promise over a surface nobody wrote down is not a promise
|
|
36
|
+
anyone can use.
|
|
37
|
+
|
|
38
|
+
Nothing is removed and nothing is renamed: an `@internal` export still imports
|
|
39
|
+
and still works. It is a statement about what the guarantee covers.
|
|
40
|
+
|
|
41
|
+
- **`ImageDriver` is frozen, and its growth rule is written down.** It is the one
|
|
42
|
+
type in this package a third party implements, so a new _required_ member would
|
|
43
|
+
break code this repository cannot see. New members arrive optional, with the
|
|
44
|
+
package supplying the fallback; `ImageManipulation` may gain optional fields;
|
|
45
|
+
`ImageResult` and `ImageMetadata` may not gain required ones, because drivers
|
|
46
|
+
produce them. Buffers rather than streams stays: media is read from and written
|
|
47
|
+
to a storage disk, and both hand over whole objects.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **`fit: "cover"` works on the default driver.** No `sharp`, no native module. `Bun.Image`
|
|
52
|
+
still exposes no crop primitive, so the centre window is taken through a lossless PNG
|
|
53
|
+
round-trip on the already-downscaled image — scaling stays native, and the extra cost is
|
|
54
|
+
roughly 40 ms on an 8 MB photo, off the request path via the queue. This removes the one
|
|
55
|
+
blocker that sat outside the repo: `sharp` is now a throughput and codec-coverage choice
|
|
56
|
+
rather than a feature one.
|
|
57
|
+
- **`allowEnlargement` on a conversion.** Off by default, as before. Worth setting when the
|
|
58
|
+
exact box matters more than fidelity — a `cover` thumbnail for a fixed-size grid slot
|
|
59
|
+
would otherwise come back undersized for small sources.
|
|
60
|
+
- **`@zerotal/media/testing`** — the state-swapping and disk-resolver seams, moved out of the
|
|
61
|
+
main entry point so it can be frozen while these stay free to change.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- **`fit: "inside"` returned dimensions that were off, sometimes short of the box asked
|
|
66
|
+
for.** `Bun.Image` floors internally, so a 400×300 source asked for `width: 150` came back
|
|
67
|
+
**149** px wide, and derived heights sat a pixel below what `sharp` produced. Both drivers
|
|
68
|
+
now resolve exact dimensions in shared code before touching their backend. Affects the
|
|
69
|
+
responsive ladder, which records these widths into `srcset`.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- **Both shipped drivers are held to one parity suite**, over a sweep of sizes, fits and
|
|
74
|
+
enlargement settings. `BunImageDriver.supportsCrop` is now `true`; the flag stays on
|
|
75
|
+
`ImageDriver` for third-party drivers that genuinely cannot crop.
|
|
76
|
+
- **`fit: "fill"` with a single dimension now behaves as `inside`** in both drivers. `sharp`
|
|
77
|
+
would stretch that one axis and leave the other at source size, which is almost never
|
|
78
|
+
what a caller meant.
|
|
79
|
+
- `FORMAT_MIME`, `FORMAT_EXTENSION` and `CONVERTIBLE_MIME_TYPES` are frozen — they are
|
|
80
|
+
shared module state, and mutating one changed conversion behaviour process-wide.
|
|
81
|
+
- `UnsupportedManipulationError` is no longer thrown for `cover`. It remains exported as
|
|
82
|
+
the way any driver reports a manipulation it cannot express.
|
|
83
|
+
|
|
84
|
+
### Removed
|
|
85
|
+
|
|
86
|
+
- Test seams from the package root: `mediaState`, `setMediaState`, `resetMediaState`,
|
|
87
|
+
`setDiskResolver`, `setDefaultDiskName`, `diskNameFor`, `setConversionDispatcher`,
|
|
88
|
+
`performConversions`, `ownerClassFor`, `partitionConversions`. All still available from
|
|
89
|
+
`@zerotal/media/testing`; update the import path.
|
|
90
|
+
|
|
11
91
|
## [1.3.0] — 2026-08-09
|
|
12
92
|
|
|
13
93
|
### Added
|
package/README.md
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
> Attach files to models — collections, image conversions, responsive images, and ordering.
|
|
4
4
|
|
|
5
5
|
Associate uploads with any model, store them on any disk, and generate derived
|
|
6
|
-
images without installing a native module.
|
|
7
|
-
`spatie/laravel-medialibrary`.
|
|
6
|
+
images without installing a native module.
|
|
8
7
|
|
|
9
8
|
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
10
9
|
|
|
10
|
+
**Maturity: `stable`** — anything importable without an `@internal` marker keeps
|
|
11
|
+
its shape for the rest of the 1.x line.
|
|
12
|
+
|
|
11
13
|
## Installation
|
|
12
14
|
|
|
13
15
|
```bash
|
|
@@ -65,10 +67,12 @@ await product.clearMediaCollection("images");
|
|
|
65
67
|
- **Types come from bytes.** `accepts` is checked against the type sniffed from
|
|
66
68
|
the file's own contents, never the filename or the upload's `Content-Type`.
|
|
67
69
|
- **No native dependency.** Conversions run on `Bun.Image`, built into the
|
|
68
|
-
runtime. JPEG, PNG and WebP work on every host
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
runtime. JPEG, PNG and WebP work on every host — including `fit: "cover"`,
|
|
71
|
+
the centre-crop behind square thumbnails. `sharp` is optional, and a shared
|
|
72
|
+
parity suite holds both drivers to the same output dimensions.
|
|
73
|
+
- **Nothing is upscaled by default.** A source too small to fill the box comes
|
|
74
|
+
back at the largest size it can supply, rather than blurred up to fit. Set
|
|
75
|
+
`allowEnlargement: true` on a conversion to opt in.
|
|
72
76
|
- **Deleting a model deletes its files** — unless it soft-deletes, in which case
|
|
73
77
|
they wait for `forceDelete()`.
|
|
74
78
|
- **Paths are keyed on uuid,** not the numeric id, so a public URL discloses
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/media",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"maturity": "
|
|
5
|
+
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./src/index.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
|
-
"./commands": "./src/commands/index.ts"
|
|
12
|
+
"./commands": "./src/commands/index.ts",
|
|
13
|
+
"./testing": "./src/testing.ts"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"CHANGELOG.md",
|
|
@@ -30,8 +31,8 @@
|
|
|
30
31
|
"typecheck": "tsc --noEmit"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.
|
|
34
|
-
"@zerotal/orm": "1.
|
|
34
|
+
"@zerotal/core": "1.5.0",
|
|
35
|
+
"@zerotal/orm": "1.5.0"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
38
|
"@zerotal/queue": "^1.0.0",
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
}
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
|
-
"@zerotal/queue": "1.
|
|
50
|
+
"@zerotal/queue": "1.5.0",
|
|
51
|
+
"sharp": "^0.34.0",
|
|
50
52
|
"typescript": "^5.8.0"
|
|
51
53
|
},
|
|
52
54
|
"description": "Attach files to models: media collections, image conversions, responsive images, and ordering — on any Zerotal storage disk.",
|
package/src/Media.ts
CHANGED
|
@@ -34,9 +34,8 @@ function ownedQuery(self: MediaHost): ModelQueryBuilder<MediaItem> {
|
|
|
34
34
|
* Adds media handling to a model.
|
|
35
35
|
*
|
|
36
36
|
* Compose it with `Model.using(...)` and declare the collections the model
|
|
37
|
-
* owns in a static `mediaCollections` field.
|
|
38
|
-
*
|
|
39
|
-
* how it reads at the call site: `Model.using(Media)`.
|
|
37
|
+
* owns in a static `mediaCollections` field. The interface and the behaviour are
|
|
38
|
+
* one piece here, named for how it reads at the call site: `Model.using(Media)`.
|
|
40
39
|
*
|
|
41
40
|
* ## Deleting
|
|
42
41
|
*
|
package/src/MediaItem.ts
CHANGED
|
@@ -14,6 +14,7 @@ export function setPathGenerator(generator: PathGenerator): void {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/** The path generator currently in force. */
|
|
17
|
+
/** @internal — reads the active generator; `setPathGenerator` is the public seam. */
|
|
17
18
|
export function pathGenerator(): PathGenerator {
|
|
18
19
|
return _pathGenerator;
|
|
19
20
|
}
|
|
@@ -2,6 +2,7 @@ import { UnknownCollectionError } from "../errors.ts";
|
|
|
2
2
|
import type { CollectionDefinition, MediaCollections } from "../types.ts";
|
|
3
3
|
|
|
4
4
|
/** A model class that may declare media collections. */
|
|
5
|
+
/** @internal — the structural shape `resolveCollection` reads off a model. */
|
|
5
6
|
export interface CollectionHost {
|
|
6
7
|
name: string;
|
|
7
8
|
mediaCollections?: MediaCollections;
|
|
@@ -16,6 +17,8 @@ export interface CollectionHost {
|
|
|
16
17
|
*
|
|
17
18
|
* @throws {UnknownCollectionError} when the model declares no such collection —
|
|
18
19
|
* listing the ones it does declare, because the mistake is nearly always a typo.
|
|
20
|
+
*
|
|
21
|
+
* @internal — collection lookup; apps declare `mediaCollections` and never call this.
|
|
19
22
|
*/
|
|
20
23
|
export function resolveCollection(host: CollectionHost, name: string): CollectionDefinition {
|
|
21
24
|
const declared = host.mediaCollections ?? {};
|
|
@@ -29,11 +32,13 @@ export function resolveCollection(host: CollectionHost, name: string): Collectio
|
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
/** Whether a model declares a collection under `name`. */
|
|
35
|
+
/** @internal — collection lookup; apps declare `mediaCollections` and never call this. */
|
|
32
36
|
export function hasCollection(host: CollectionHost, name: string): boolean {
|
|
33
37
|
return (host.mediaCollections ?? {})[name] !== undefined;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/** Every collection name the model declares. */
|
|
41
|
+
/** @internal — collection lookup; apps declare `mediaCollections` and never call this. */
|
|
37
42
|
export function collectionNames(host: CollectionHost): string[] {
|
|
38
43
|
return Object.keys(host.mediaCollections ?? {});
|
|
39
44
|
}
|
|
@@ -13,6 +13,8 @@ import type { CollectionDefinition } from "../types.ts";
|
|
|
13
13
|
*
|
|
14
14
|
* @param justAdded - The item that triggered the trim; never removed, even if
|
|
15
15
|
* its sort position would otherwise put it outside the window.
|
|
16
|
+
*
|
|
17
|
+
* @internal — `single` / `onlyKeepLatest` enforcement, run by MediaAdder.
|
|
16
18
|
*/
|
|
17
19
|
export async function applyRetentionRules(
|
|
18
20
|
modelType: string,
|
package/src/config.ts
CHANGED
|
@@ -77,6 +77,7 @@ export function MediaConfig(options: Partial<MediaConfigShape> = {}): MediaConfi
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** The defaults, for tests and for resolving config in DB-less runtimes. */
|
|
80
|
+
/** @internal — the defaults `MediaConfig()` merges onto. */
|
|
80
81
|
export function mediaDefaults(): MediaConfigShape {
|
|
81
82
|
return { ...defaults, responsiveWidths: [...defaults.responsiveWidths] };
|
|
82
83
|
}
|
|
@@ -5,21 +5,28 @@ import {
|
|
|
5
5
|
type ImageMetadata,
|
|
6
6
|
type ImageResult,
|
|
7
7
|
} from "./ImageDriver.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { RasterFormatError, UnsupportedFormatError } from "../errors.ts";
|
|
9
|
+
import { cropCentre, decodePng, encodePng, readPngHeader, resolveGeometry } from "./raster.ts";
|
|
10
|
+
import type { ResolvedGeometry } from "./raster.ts";
|
|
9
11
|
import type { ConversionFormat } from "../types.ts";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Image processing on `Bun.Image` — no native modules, no `sharp`, nothing to
|
|
13
15
|
* install. JPEG, PNG and WebP are statically linked into Bun itself.
|
|
14
16
|
*
|
|
15
|
-
* ##
|
|
17
|
+
* ## How cropping works
|
|
16
18
|
*
|
|
17
|
-
* `Bun.Image.resize()` takes `fit: "fill" | "inside"` and the class exposes
|
|
18
|
-
* crop, extract or composite primitive
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* `Bun.Image.resize()` takes only `fit: "fill" | "inside"` and the class exposes
|
|
20
|
+
* no crop, extract or composite primitive, so `fit: "cover"` is not expressible
|
|
21
|
+
* through its API alone. It is instead assembled here: scale so the image
|
|
22
|
+
* overflows the target box in at most one axis, then take the centre window out
|
|
23
|
+
* of the result through a lossless PNG round-trip (see `raster.ts`). Scaling
|
|
24
|
+
* stays native; only the window copy is ours, and it runs on the already-scaled
|
|
25
|
+
* image rather than the original.
|
|
26
|
+
*
|
|
27
|
+
* The upshot is that this driver crops, and `sharp` is no longer required for a
|
|
28
|
+
* cover-fit thumbnail. `SharpImageDriver` remains worth choosing for throughput
|
|
29
|
+
* on large batches and for formats Bun's `system` backend lacks.
|
|
23
30
|
*
|
|
24
31
|
* ## Host-dependent formats
|
|
25
32
|
*
|
|
@@ -29,7 +36,7 @@ import type { ConversionFormat } from "../types.ts";
|
|
|
29
36
|
*/
|
|
30
37
|
export class BunImageDriver implements ImageDriver {
|
|
31
38
|
readonly name = "BunImageDriver";
|
|
32
|
-
readonly supportsCrop =
|
|
39
|
+
readonly supportsCrop = true;
|
|
33
40
|
|
|
34
41
|
/** Memoised results of {@link canEncode}, which costs a real encode. */
|
|
35
42
|
readonly #encodable = new Map<ConversionFormat, boolean>();
|
|
@@ -41,6 +48,16 @@ export class BunImageDriver implements ImageDriver {
|
|
|
41
48
|
* against the header before any pixel buffer is allocated.
|
|
42
49
|
*/
|
|
43
50
|
private readonly maxPixels: number = 0x3fff * 0x3fff,
|
|
51
|
+
/**
|
|
52
|
+
* Cap on the intermediate buffer the crop path decodes, in pixels.
|
|
53
|
+
*
|
|
54
|
+
* Separate from {@link maxPixels}, and much lower, because this one bounds an
|
|
55
|
+
* allocation *we* make rather than one Bun makes: the intermediate is
|
|
56
|
+
* uncompressed RGBA, so the default 40 MP is already ~160 MB. Conversions
|
|
57
|
+
* are thumbnail-sized in practice, so this only ever catches a conversion
|
|
58
|
+
* defined with implausible dimensions.
|
|
59
|
+
*/
|
|
60
|
+
private readonly maxCropPixels: number = 40_000_000,
|
|
44
61
|
) {}
|
|
45
62
|
|
|
46
63
|
async metadata(bytes: Uint8Array): Promise<ImageMetadata | null> {
|
|
@@ -55,54 +72,145 @@ export class BunImageDriver implements ImageDriver {
|
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
async convert(bytes: Uint8Array, manipulation: ImageManipulation): Promise<ImageResult> {
|
|
58
|
-
const {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
const { format, quality, rotate } = manipulation;
|
|
76
|
+
|
|
77
|
+
// Rotation changes which dimension is which, so the geometry has to be
|
|
78
|
+
// computed against the post-rotation shape. Rotating first — into a PNG, so
|
|
79
|
+
// nothing is lost — lets the dimensions simply be read back, rather than
|
|
80
|
+
// re-deriving Bun's canvas expansion for non-right angles.
|
|
81
|
+
const rotated = rotate !== undefined && rotate !== 0;
|
|
82
|
+
let source = bytes;
|
|
83
|
+
let sourceWidth: number;
|
|
84
|
+
let sourceHeight: number;
|
|
85
|
+
|
|
86
|
+
if (rotated) {
|
|
87
|
+
source = await new Bun.Image(bytes, { maxPixels: this.maxPixels })
|
|
88
|
+
.rotate(rotate)
|
|
89
|
+
.png()
|
|
90
|
+
.bytes();
|
|
91
|
+
const header = readPngHeader(source);
|
|
92
|
+
if (header === null) throw new RasterFormatError("Bun.Image.rotate() did not return a PNG");
|
|
93
|
+
sourceWidth = header.width;
|
|
94
|
+
sourceHeight = header.height;
|
|
95
|
+
} else {
|
|
96
|
+
const meta = await this.metadata(bytes);
|
|
97
|
+
if (meta === null) throw new RasterFormatError("the source image could not be read");
|
|
98
|
+
sourceWidth = meta.width;
|
|
99
|
+
sourceHeight = meta.height;
|
|
68
100
|
}
|
|
69
101
|
|
|
70
|
-
|
|
102
|
+
const geometry = resolveGeometry({
|
|
103
|
+
sourceWidth,
|
|
104
|
+
sourceHeight,
|
|
105
|
+
targetWidth: manipulation.width,
|
|
106
|
+
targetHeight: manipulation.height,
|
|
107
|
+
fit: manipulation.fit ?? "inside",
|
|
108
|
+
// Upscaling a 200px source to fill a 1920px slot produces a blurry file
|
|
109
|
+
// larger than the original. Never worth it by default.
|
|
110
|
+
withoutEnlargement: manipulation.withoutEnlargement ?? true,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const needsCrop =
|
|
114
|
+
geometry.cropWidth !== undefined &&
|
|
115
|
+
geometry.cropHeight !== undefined &&
|
|
116
|
+
(geometry.cropWidth !== geometry.resizeWidth ||
|
|
117
|
+
geometry.cropHeight !== geometry.resizeHeight);
|
|
71
118
|
|
|
72
|
-
|
|
119
|
+
// Without a crop the whole thing stays inside Bun: one pipeline, no
|
|
120
|
+
// round-trip. This is the overwhelmingly common path.
|
|
121
|
+
if (!needsCrop) {
|
|
122
|
+
let pipeline = new Bun.Image(source, { maxPixels: this.maxPixels });
|
|
123
|
+
if (!geometry.resizeIsNoop) {
|
|
124
|
+
pipeline = pipeline.resize(geometry.resizeWidth, geometry.resizeHeight, {
|
|
125
|
+
// Exact dimensions are already resolved, so `fill` is what we want:
|
|
126
|
+
// it asks Bun for precisely these numbers instead of letting it
|
|
127
|
+
// re-derive (and re-round) them from the caller's box.
|
|
128
|
+
fit: "fill",
|
|
129
|
+
withoutEnlargement: false,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
pipeline = _applyFormat(pipeline, format, quality);
|
|
73
133
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// larger than the original. Never worth it by default.
|
|
82
|
-
withoutEnlargement: manipulation.withoutEnlargement ?? true,
|
|
83
|
-
});
|
|
134
|
+
return {
|
|
135
|
+
bytes: await this.#encode(pipeline, format),
|
|
136
|
+
width: geometry.resizeWidth,
|
|
137
|
+
height: geometry.resizeHeight,
|
|
138
|
+
format,
|
|
139
|
+
mimeType: FORMAT_MIME[format],
|
|
140
|
+
};
|
|
84
141
|
}
|
|
85
142
|
|
|
86
|
-
|
|
143
|
+
return await this.#convertCropped(source, geometry, rotated, format, quality);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The `fit: "cover"` path: scale so the image overflows the box in at most one
|
|
148
|
+
* axis, then keep the centre.
|
|
149
|
+
*
|
|
150
|
+
* `Bun.Image` has no crop primitive, so the centre window is taken here —
|
|
151
|
+
* through a lossless PNG round-trip, on the already-scaled image. The scaling
|
|
152
|
+
* itself never leaves Bun.
|
|
153
|
+
*/
|
|
154
|
+
async #convertCropped(
|
|
155
|
+
source: Uint8Array,
|
|
156
|
+
geometry: ResolvedGeometry,
|
|
157
|
+
sourceIsPng: boolean,
|
|
158
|
+
format: ConversionFormat,
|
|
159
|
+
quality: number | undefined,
|
|
160
|
+
): Promise<ImageResult> {
|
|
161
|
+
// A no-op resize on a source that is already PNG has nothing to do.
|
|
162
|
+
const scaled =
|
|
163
|
+
geometry.resizeIsNoop && sourceIsPng
|
|
164
|
+
? source
|
|
165
|
+
: await this.#toPng(source, geometry.resizeIsNoop ? null : geometry);
|
|
166
|
+
|
|
167
|
+
const cropped = cropCentre(
|
|
168
|
+
decodePng(scaled, this.maxCropPixels),
|
|
169
|
+
geometry.cropWidth!,
|
|
170
|
+
geometry.cropHeight!,
|
|
171
|
+
);
|
|
87
172
|
|
|
88
|
-
|
|
173
|
+
const pipeline = _applyFormat(
|
|
174
|
+
new Bun.Image(encodePng(cropped), { maxPixels: this.maxPixels }),
|
|
175
|
+
format,
|
|
176
|
+
quality,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
bytes: await this.#encode(pipeline, format),
|
|
181
|
+
// Known exactly from the crop — no need to ask the pipeline.
|
|
182
|
+
width: cropped.width,
|
|
183
|
+
height: cropped.height,
|
|
184
|
+
format,
|
|
185
|
+
mimeType: FORMAT_MIME[format],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Re-encode as PNG, optionally resizing on the way through. */
|
|
190
|
+
async #toPng(bytes: Uint8Array, geometry: ResolvedGeometry | null): Promise<Uint8Array> {
|
|
191
|
+
const image = new Bun.Image(bytes, { maxPixels: this.maxPixels });
|
|
192
|
+
const pipeline =
|
|
193
|
+
geometry === null
|
|
194
|
+
? image
|
|
195
|
+
: image.resize(geometry.resizeWidth, geometry.resizeHeight, {
|
|
196
|
+
// Safe despite the name: both dimensions were scaled by one factor,
|
|
197
|
+
// so this fills a box that already has the source's aspect ratio.
|
|
198
|
+
fit: "fill",
|
|
199
|
+
withoutEnlargement: false,
|
|
200
|
+
});
|
|
201
|
+
return await pipeline.png().bytes();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Await a pipeline, translating a missing codec into a named error. */
|
|
205
|
+
async #encode(pipeline: Bun.Image, format: ConversionFormat): Promise<Uint8Array> {
|
|
89
206
|
try {
|
|
90
|
-
|
|
207
|
+
return await pipeline.bytes();
|
|
91
208
|
} catch (error) {
|
|
92
209
|
if (_codeOf(error) === "ERR_IMAGE_FORMAT_UNSUPPORTED") {
|
|
93
210
|
throw new UnsupportedFormatError(format, await this.encodableFormats());
|
|
94
211
|
}
|
|
95
212
|
throw error;
|
|
96
213
|
}
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
bytes: out,
|
|
100
|
-
// Populated once a terminal has been awaited; -1 before that.
|
|
101
|
-
width: pipeline.width,
|
|
102
|
-
height: pipeline.height,
|
|
103
|
-
format,
|
|
104
|
-
mimeType: FORMAT_MIME[format],
|
|
105
|
-
};
|
|
106
214
|
}
|
|
107
215
|
|
|
108
216
|
async placeholder(bytes: Uint8Array): Promise<string | null> {
|
|
@@ -47,6 +47,8 @@ export function partitionConversions(
|
|
|
47
47
|
* and its row already exists, so a codec that chokes on one file should cost
|
|
48
48
|
* that file its thumbnail, not the upload. Failures are collected and returned
|
|
49
49
|
* rather than thrown.
|
|
50
|
+
*
|
|
51
|
+
* @internal — the conversion engine, constructed by MediaProvider.
|
|
50
52
|
*/
|
|
51
53
|
export class ConversionRunner {
|
|
52
54
|
constructor(
|
|
@@ -214,6 +216,9 @@ export class ConversionRunner {
|
|
|
214
216
|
fit: definition.fit ?? "inside",
|
|
215
217
|
format,
|
|
216
218
|
quality: definition.quality ?? this.config.quality,
|
|
219
|
+
// Inverted at the boundary: the collection API reads better as an opt-in
|
|
220
|
+
// ("allow this"), the driver contract as a constraint ("do not do this").
|
|
221
|
+
withoutEnlargement: definition.allowEnlargement !== true,
|
|
217
222
|
};
|
|
218
223
|
}
|
|
219
224
|
}
|
|
@@ -37,15 +37,46 @@ export interface ImageMetadata {
|
|
|
37
37
|
/**
|
|
38
38
|
* The seam between this package and whatever actually manipulates pixels.
|
|
39
39
|
*
|
|
40
|
-
* Two implementations ship:
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* Two implementations ship: `BunImageDriver` (the default — no dependencies)
|
|
41
|
+
* and `SharpImageDriver` (opt-in, a native module). Both support every
|
|
42
|
+
* manipulation in {@link ImageManipulation}, and a shared parity suite holds
|
|
43
|
+
* them to the same output dimensions, so `media.driver` is a throughput and
|
|
44
|
+
* codec-coverage choice rather than a feature one.
|
|
45
|
+
*
|
|
46
|
+
* Keeping both behind one interface is also what makes `Bun.Image` — which is
|
|
47
|
+
* young — a safe thing to depend on: if its API moves, one file changes.
|
|
48
|
+
*
|
|
49
|
+
* ## How this interface may grow
|
|
50
|
+
*
|
|
51
|
+
* This is the one type in the package a third-party implements, so a new
|
|
52
|
+
* **required** member is a breaking change for code this repository cannot see.
|
|
53
|
+
* The policy, decided when the package was promoted to `stable`:
|
|
54
|
+
*
|
|
55
|
+
* - **New members arrive optional**, and the package supplies the fallback —
|
|
56
|
+
* the same way `supportsCrop` lets a driver decline `fit: "cover"` instead of
|
|
57
|
+
* failing at conversion time. A capability nobody implements must degrade to
|
|
58
|
+
* the behaviour drivers already have.
|
|
59
|
+
* - **{@link ImageManipulation} may gain optional fields** (a background colour,
|
|
60
|
+
* a device-pixel-ratio multiplier, EXIF stripping). Drivers ignore what they
|
|
61
|
+
* do not understand, so this stays additive — but a field that *changes*
|
|
62
|
+
* existing output is a new field, never a new default.
|
|
63
|
+
* - **{@link ImageResult} and {@link ImageMetadata} may not gain required
|
|
64
|
+
* fields**, because drivers produce them. Optional only.
|
|
65
|
+
*
|
|
66
|
+
* Buffers rather than streams is deliberate and does not change: media is read
|
|
67
|
+
* from and written to a storage disk, both of which hand over whole objects, so
|
|
68
|
+
* a streaming seam here would only re-buffer at each end.
|
|
44
69
|
*/
|
|
45
70
|
export interface ImageDriver {
|
|
46
71
|
/** Name used in error messages. */
|
|
47
72
|
readonly name: string;
|
|
48
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Whether `fit: "cover"` is available.
|
|
75
|
+
*
|
|
76
|
+
* Both shipped drivers report `true`. It stays part of the interface for
|
|
77
|
+
* third-party drivers wrapping a backend that genuinely cannot crop, which
|
|
78
|
+
* need a way to say so other than failing at conversion time.
|
|
79
|
+
*/
|
|
49
80
|
readonly supportsCrop: boolean;
|
|
50
81
|
|
|
51
82
|
/** Read dimensions and format without decoding the whole image. */
|
|
@@ -64,26 +95,31 @@ export interface ImageDriver {
|
|
|
64
95
|
canEncode(format: ConversionFormat): Promise<boolean>;
|
|
65
96
|
}
|
|
66
97
|
|
|
98
|
+
// These three are exported for reading — to label a download, or to check a type
|
|
99
|
+
// before offering an upload. They are frozen because they are shared module
|
|
100
|
+
// state: an app that mutated one would change how conversions behave for every
|
|
101
|
+
// other caller in the process, including ones it does not own.
|
|
102
|
+
|
|
67
103
|
/** MIME type for each format a driver may emit. */
|
|
68
|
-
export const FORMAT_MIME: Record<ConversionFormat, string
|
|
104
|
+
export const FORMAT_MIME: Readonly<Record<ConversionFormat, string>> = Object.freeze({
|
|
69
105
|
jpeg: "image/jpeg",
|
|
70
106
|
png: "image/png",
|
|
71
107
|
webp: "image/webp",
|
|
72
108
|
avif: "image/avif",
|
|
73
109
|
heic: "image/heic",
|
|
74
|
-
};
|
|
110
|
+
});
|
|
75
111
|
|
|
76
112
|
/** File extension for each format. */
|
|
77
|
-
export const FORMAT_EXTENSION: Record<ConversionFormat, string
|
|
113
|
+
export const FORMAT_EXTENSION: Readonly<Record<ConversionFormat, string>> = Object.freeze({
|
|
78
114
|
jpeg: "jpg",
|
|
79
115
|
png: "png",
|
|
80
116
|
webp: "webp",
|
|
81
117
|
avif: "avif",
|
|
82
118
|
heic: "heic",
|
|
83
|
-
};
|
|
119
|
+
});
|
|
84
120
|
|
|
85
121
|
/** MIME types this package will attempt to convert. */
|
|
86
|
-
export const CONVERTIBLE_MIME_TYPES = new Set([
|
|
122
|
+
export const CONVERTIBLE_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
87
123
|
"image/jpeg",
|
|
88
124
|
"image/png",
|
|
89
125
|
"image/webp",
|