m4k 0.3.5 → 0.3.6
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 +11 -0
- package/lib/images/classify-image.d.ts +6 -0
- package/lib/images/classify-image.js +71 -0
- package/lib/images/extract-embedded-preview.d.ts +3 -0
- package/lib/images/extract-embedded-preview.js +49 -0
- package/lib/images/image-processor.js +35 -4
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -1
- package/lib/util/wait-for-load.d.ts +3 -0
- package/lib/util/wait-for-load.js +23 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to `m4k` are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.6]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added pinned CPU/q8 CLIP classification with user-defined candidates and input SHA-256 results.
|
|
13
|
+
- Added bounded TIFF IFD1 JPEG extraction without RAW development or original fallback.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Released cancelled model-load waiters while reusing the shared load and retaining the slot during native inference.
|
|
18
|
+
|
|
8
19
|
## [0.3.5]
|
|
9
20
|
|
|
10
21
|
### Changed
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ClassificationOptions, type ClassificationResult } from "@m4k/common";
|
|
2
|
+
/** Bounded preview-only inference. No arbitrary models, remote image URLs or file outputs. */
|
|
3
|
+
export declare function classifyImage(input: Uint8Array, options: ClassificationOptions, { signal }?: {
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
}): Promise<ClassificationResult>;
|
|
6
|
+
//# sourceMappingURL=classify-image.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import sharp from "sharp";
|
|
3
|
+
import { imageClassifier, validateClassificationOptions, } from "@m4k/common";
|
|
4
|
+
import { waitForLoad } from "../util/wait-for-load.js";
|
|
5
|
+
let classifier;
|
|
6
|
+
let busy = false;
|
|
7
|
+
async function loadClassifier() {
|
|
8
|
+
const { env, pipeline } = await import("@huggingface/transformers");
|
|
9
|
+
env.cacheDir = process.env.M4K_MODEL_CACHE ?? `${process.env.M4K_TMP_DIR ?? "/tmp/m4k"}/models`;
|
|
10
|
+
return pipeline("zero-shot-image-classification", imageClassifier.model, {
|
|
11
|
+
revision: imageClassifier.revision,
|
|
12
|
+
dtype: "q8",
|
|
13
|
+
device: "cpu",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async function decodePreview(input) {
|
|
17
|
+
try {
|
|
18
|
+
const image = sharp(input, { limitInputPixels: imageClassifier.maxPixels, animated: false });
|
|
19
|
+
const metadata = await image.metadata();
|
|
20
|
+
if (!["jpeg", "png", "webp"].includes(metadata.format ?? "") || (metadata.pages ?? 1) !== 1)
|
|
21
|
+
throw new RangeError("Classification requires a single JPEG, PNG or WebP preview");
|
|
22
|
+
return await image
|
|
23
|
+
.rotate()
|
|
24
|
+
.removeAlpha()
|
|
25
|
+
.toColourspace("srgb")
|
|
26
|
+
.raw()
|
|
27
|
+
.toBuffer({ resolveWithObject: true });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw error instanceof RangeError ? error : new RangeError("Invalid classification preview");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Bounded preview-only inference. No arbitrary models, remote image URLs or file outputs. */
|
|
34
|
+
export async function classifyImage(input, options, { signal } = {}) {
|
|
35
|
+
if (!validateClassificationOptions(options))
|
|
36
|
+
throw new RangeError("Unsupported classifier configuration");
|
|
37
|
+
if (input.byteLength > imageClassifier.maxBytes)
|
|
38
|
+
throw new RangeError("Classification preview exceeds byte limit");
|
|
39
|
+
signal?.throwIfAborted();
|
|
40
|
+
if (busy)
|
|
41
|
+
throw new Error("Classifier is busy");
|
|
42
|
+
busy = true;
|
|
43
|
+
try {
|
|
44
|
+
const bytes = Uint8Array.from(input);
|
|
45
|
+
const candidates = [...options.labels];
|
|
46
|
+
const { data, info } = await decodePreview(bytes);
|
|
47
|
+
signal?.throwIfAborted();
|
|
48
|
+
const { RawImage } = await import("@huggingface/transformers");
|
|
49
|
+
classifier ??= loadClassifier().catch((error) => {
|
|
50
|
+
classifier = undefined;
|
|
51
|
+
throw error;
|
|
52
|
+
});
|
|
53
|
+
const run = await waitForLoad(classifier, signal);
|
|
54
|
+
signal?.throwIfAborted();
|
|
55
|
+
const labels = await run(new RawImage(new Uint8ClampedArray(data), info.width, info.height, 3), candidates, {
|
|
56
|
+
hypothesis_template: imageClassifier.template,
|
|
57
|
+
});
|
|
58
|
+
// Native inference cannot be preempted; retain the slot until it stops and discard cancelled results.
|
|
59
|
+
signal?.throwIfAborted();
|
|
60
|
+
return {
|
|
61
|
+
classifierId: imageClassifier.id,
|
|
62
|
+
classifierVersion: imageClassifier.version,
|
|
63
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
64
|
+
labels: labels,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
busy = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=classify-image.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
/** Bounded TIFF IFD0 -> IFD1 JPEG thumbnail extraction; no arbitrary TIFF traversal or RAW development. */
|
|
3
|
+
export async function extractEmbeddedPreview(input) {
|
|
4
|
+
if (input.byteLength > 128 * 1024 * 1024)
|
|
5
|
+
throw new RangeError("RAW preview input exceeds 128 MiB");
|
|
6
|
+
const little = input.subarray(0, 4).equals(Buffer.from([73, 73, 42, 0]));
|
|
7
|
+
if (input.length < 8 || (!little && !input.subarray(0, 4).equals(Buffer.from([77, 77, 0, 42]))))
|
|
8
|
+
throw new RangeError("Embedded preview requires a TIFF-based camera file");
|
|
9
|
+
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
|
10
|
+
const ifd = (offset) => {
|
|
11
|
+
if (offset < 8 || offset > input.length - 2)
|
|
12
|
+
throw new RangeError("Invalid embedded preview directory");
|
|
13
|
+
const count = view.getUint16(offset, little);
|
|
14
|
+
if (count > 256 || offset + 2 + count * 12 + 4 > input.length)
|
|
15
|
+
throw new RangeError("Invalid embedded preview directory");
|
|
16
|
+
const tags = new Map();
|
|
17
|
+
for (let i = 0; i < count; i++) {
|
|
18
|
+
const at = offset + 2 + i * 12;
|
|
19
|
+
const type = view.getUint16(at + 2, little);
|
|
20
|
+
if (view.getUint32(at + 4, little) !== 1 || (type !== 3 && type !== 4))
|
|
21
|
+
continue;
|
|
22
|
+
tags.set(view.getUint16(at, little), type === 3 ? view.getUint16(at + 8, little) : view.getUint32(at + 8, little));
|
|
23
|
+
}
|
|
24
|
+
return { tags, next: view.getUint32(offset + 2 + count * 12, little) };
|
|
25
|
+
};
|
|
26
|
+
const firstOffset = view.getUint32(4, little);
|
|
27
|
+
const first = ifd(firstOffset);
|
|
28
|
+
if (!first.next || first.next === firstOffset)
|
|
29
|
+
throw new RangeError("No supported embedded JPEG preview");
|
|
30
|
+
const { tags } = ifd(first.next);
|
|
31
|
+
const offset = tags.get(0x201) ?? 0;
|
|
32
|
+
const length = tags.get(0x202) ?? 0;
|
|
33
|
+
if ((tags.has(0x103) && tags.get(0x103) !== 6) ||
|
|
34
|
+
offset < 8 ||
|
|
35
|
+
length < 2 ||
|
|
36
|
+
length > 8 * 1024 * 1024 ||
|
|
37
|
+
offset > input.length - length)
|
|
38
|
+
throw new RangeError("No supported embedded JPEG preview");
|
|
39
|
+
const jpeg = input.subarray(offset, offset + length);
|
|
40
|
+
if (jpeg[0] !== 0xff || jpeg[1] !== 0xd8)
|
|
41
|
+
throw new RangeError("No supported embedded JPEG preview");
|
|
42
|
+
const orientation = first.tags.get(0x112) ?? 1;
|
|
43
|
+
// Attach only orientation to the private intermediate; the normal transform applies it.
|
|
44
|
+
return sharp(jpeg, { limitInputPixels: 1024 * 1024 })
|
|
45
|
+
.withMetadata({ orientation: orientation >= 1 && orientation <= 8 ? orientation : 1 })
|
|
46
|
+
.jpeg()
|
|
47
|
+
.toBuffer();
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=extract-embedded-preview.js.map
|
|
@@ -6,6 +6,7 @@ import sharp from "sharp";
|
|
|
6
6
|
import { createQueue } from "tasque";
|
|
7
7
|
import { ffmpeg, createTmp, randomId } from "../util/ffmpeg-processor.js";
|
|
8
8
|
import { exhaustAsyncIterableToWritable } from "../util/streams.js";
|
|
9
|
+
import { extractEmbeddedPreview } from "./extract-embedded-preview.js";
|
|
9
10
|
export { ffmpeg };
|
|
10
11
|
const imageTmpDir = `${process.env.M4K_TMP_DIR ?? "/tmp/m4k"}/image`;
|
|
11
12
|
/**
|
|
@@ -18,7 +19,13 @@ const imageTmpDir = `${process.env.M4K_TMP_DIR ?? "/tmp/m4k"}/image`;
|
|
|
18
19
|
export function processImage(input, opts, { signal } = {}) {
|
|
19
20
|
const iterable = imageQueue.iterate(async function* () {
|
|
20
21
|
const optsArr = Array.isArray(opts) ? opts : [opts];
|
|
21
|
-
const
|
|
22
|
+
const preview = optsArr.some((o) => o.embeddedPreview);
|
|
23
|
+
if (preview &&
|
|
24
|
+
optsArr.some((o) => !o.embeddedPreview || o.keepMetadata || o.keepExif || o.keepIcc))
|
|
25
|
+
throw new RangeError("Embedded previews require metadata stripping and cannot mix input modes");
|
|
26
|
+
const resolvedInput = preview
|
|
27
|
+
? await extractEmbeddedPreview(await boundedInput(input))
|
|
28
|
+
: await resolveInput(input);
|
|
22
29
|
const inputExt = detectExt(resolvedInput);
|
|
23
30
|
if (inputExt != null && optsArr.every((o) => isImageNoOp(o, inputExt))) {
|
|
24
31
|
yield { progress: 0 };
|
|
@@ -107,7 +114,9 @@ async function ffmpegDecodeToPng(input) {
|
|
|
107
114
|
stdio: ["pipe", "pipe", "pipe"],
|
|
108
115
|
});
|
|
109
116
|
let errStr = "";
|
|
110
|
-
child.stderr.on("data", (chunk) => {
|
|
117
|
+
child.stderr.on("data", (chunk) => {
|
|
118
|
+
errStr += chunk.toString();
|
|
119
|
+
});
|
|
111
120
|
child.on("close", (code) => {
|
|
112
121
|
if (code !== 0)
|
|
113
122
|
reject(new Error(`ffmpeg exited with code ${code}: ${errStr}`));
|
|
@@ -123,6 +132,22 @@ async function ffmpegDecodeToPng(input) {
|
|
|
123
132
|
await rm(inputPath, { force: true });
|
|
124
133
|
}
|
|
125
134
|
}
|
|
135
|
+
async function boundedInput(input) {
|
|
136
|
+
const iterable = typeof input === "string"
|
|
137
|
+
? createReadStream(input)
|
|
138
|
+
: "stream" in input
|
|
139
|
+
? input.stream()
|
|
140
|
+
: input;
|
|
141
|
+
const chunks = [];
|
|
142
|
+
let size = 0;
|
|
143
|
+
for await (const chunk of iterable) {
|
|
144
|
+
size += chunk.length;
|
|
145
|
+
if (size > 128 * 1024 * 1024)
|
|
146
|
+
throw new RangeError("RAW preview input exceeds 128 MiB");
|
|
147
|
+
chunks.push(chunk);
|
|
148
|
+
}
|
|
149
|
+
return Buffer.concat(chunks);
|
|
150
|
+
}
|
|
126
151
|
async function resolveInput(input) {
|
|
127
152
|
if (typeof input === "string" || "stream" in input)
|
|
128
153
|
return input;
|
|
@@ -169,8 +194,14 @@ function detectExt(input) {
|
|
|
169
194
|
return "png";
|
|
170
195
|
if (input[0] === 0x47 && input[1] === 0x49 && input[2] === 0x46)
|
|
171
196
|
return "gif";
|
|
172
|
-
if (input[0] === 0x52 &&
|
|
173
|
-
input[
|
|
197
|
+
if (input[0] === 0x52 &&
|
|
198
|
+
input[1] === 0x49 &&
|
|
199
|
+
input[2] === 0x46 &&
|
|
200
|
+
input[3] === 0x46 &&
|
|
201
|
+
input[8] === 0x57 &&
|
|
202
|
+
input[9] === 0x45 &&
|
|
203
|
+
input[10] === 0x42 &&
|
|
204
|
+
input[11] === 0x50)
|
|
174
205
|
return "webp";
|
|
175
206
|
// AVIF/HEIF: ftyp box at offset 4, brand at offset 8
|
|
176
207
|
if (input[4] === 0x66 && input[5] === 0x74 && input[6] === 0x79 && input[7] === 0x70) {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { ProcessedFile, type AudioOptions, type ImageOptions, type ProcessingError, type Progress, type QueuePosition, type VideoOptions } from "@m4k/common";
|
|
1
|
+
export { ProcessedFile, type AudioOptions, type ImageOptions, type ProcessingError, type Progress, type QueuePosition, type VideoOptions, } from "@m4k/common";
|
|
2
2
|
export { audioQueue, processAudio } from "./audio/audio-processor.js";
|
|
3
|
+
export { classifyImage } from "./images/classify-image.js";
|
|
3
4
|
export { imageQueue, processImage } from "./images/image-processor.js";
|
|
4
5
|
export { getExtension } from "./util/ffmpeg-processor.js";
|
|
5
6
|
export { mimeTypes } from "./util/mime.js";
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { ProcessedFile } from "@m4k/common";
|
|
1
|
+
export { ProcessedFile, } from "@m4k/common";
|
|
2
2
|
export { audioQueue, processAudio } from "./audio/audio-processor.js";
|
|
3
|
+
export { classifyImage } from "./images/classify-image.js";
|
|
3
4
|
export { imageQueue, processImage } from "./images/image-processor.js";
|
|
4
5
|
export { getExtension } from "./util/ffmpeg-processor.js";
|
|
5
6
|
export { mimeTypes } from "./util/mime.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Stop waiting on cancellation without cancelling or duplicating a shared model load. */
|
|
2
|
+
export function waitForLoad(load, signal) {
|
|
3
|
+
if (!signal)
|
|
4
|
+
return load;
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const onAbort = () => {
|
|
7
|
+
signal.removeEventListener("abort", onAbort);
|
|
8
|
+
reject(signal.reason);
|
|
9
|
+
};
|
|
10
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
11
|
+
// Always observe settlement, even after cancellation, so a failed background load is handled.
|
|
12
|
+
load.then((value) => {
|
|
13
|
+
signal.removeEventListener("abort", onAbort);
|
|
14
|
+
resolve(value);
|
|
15
|
+
}, (error) => {
|
|
16
|
+
signal.removeEventListener("abort", onAbort);
|
|
17
|
+
reject(error);
|
|
18
|
+
});
|
|
19
|
+
if (signal.aborted)
|
|
20
|
+
onAbort();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=wait-for-load.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "m4k",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Media toolkit for node, bun, and deno",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"audio",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@
|
|
36
|
+
"@huggingface/transformers": "3.8.1",
|
|
37
|
+
"@m4k/common": "^0.2.9"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|