cencori 1.2.0 → 1.3.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/README.md +3 -51
- package/dist/ai/index.d.mts +32 -1
- package/dist/ai/index.d.ts +32 -1
- package/dist/ai/index.js +131 -0
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/index.mjs +131 -0
- package/dist/ai/index.mjs.map +1 -1
- package/dist/compute/index.d.mts +1 -1
- package/dist/compute/index.d.ts +1 -1
- package/dist/index.d.mts +208 -4
- package/dist/index.d.ts +208 -4
- package/dist/index.js +348 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +345 -8
- package/dist/index.mjs.map +1 -1
- package/dist/memory/index.d.mts +1 -1
- package/dist/memory/index.d.ts +1 -1
- package/dist/react/index.d.mts +52 -0
- package/dist/react/index.d.ts +52 -0
- package/dist/react/index.js +395 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/index.mjs +367 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/storage/index.d.mts +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +7 -7
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/index.mjs +7 -7
- package/dist/storage/index.mjs.map +1 -1
- package/dist/tanstack/index.d.mts +1 -1
- package/dist/tanstack/index.d.ts +1 -1
- package/dist/tanstack/index.js +3 -0
- package/dist/tanstack/index.js.map +1 -1
- package/dist/tanstack/index.mjs +3 -0
- package/dist/tanstack/index.mjs.map +1 -1
- package/dist/telemetry/index.d.mts +1 -1
- package/dist/telemetry/index.d.ts +1 -1
- package/dist/{types-Cr0muEt3.d.mts → types-kh1whvNH.d.mts} +135 -1
- package/dist/{types-Cr0muEt3.d.ts → types-kh1whvNH.d.ts} +135 -1
- package/dist/vision/index.d.mts +99 -0
- package/dist/vision/index.d.ts +99 -0
- package/dist/vision/index.js +93 -0
- package/dist/vision/index.js.map +1 -0
- package/dist/vision/index.mjs +68 -0
- package/dist/vision/index.mjs.map +1 -0
- package/dist/workflow/index.d.mts +1 -1
- package/dist/workflow/index.d.ts +1 -1
- package/package.json +20 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
type VisionProvider = 'openai' | 'anthropic' | 'google';
|
|
5
|
+
interface VisionFormatBannerProps {
|
|
6
|
+
/** Restrict displayed limits to a specific provider. Omit to show universal (all-provider) limits. */
|
|
7
|
+
provider?: VisionProvider;
|
|
8
|
+
/** Tone. */
|
|
9
|
+
variant?: 'info' | 'muted';
|
|
10
|
+
/** Extra classes. */
|
|
11
|
+
className?: string;
|
|
12
|
+
/** Optional title override. */
|
|
13
|
+
title?: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
declare function VisionFormatBanner({ provider, variant, className, title, }: VisionFormatBannerProps): react_jsx_runtime.JSX.Element;
|
|
16
|
+
|
|
17
|
+
type VisionTask = 'analyze' | 'describe' | 'ocr' | 'classify';
|
|
18
|
+
interface VisionUploaderProps {
|
|
19
|
+
/** Base Vision endpoint. Defaults to same-origin `/api/ai/vision`. */
|
|
20
|
+
endpoint?: string;
|
|
21
|
+
/** Cencori API key. Sent as `Authorization: Bearer`. */
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
/** Which task to run: analyze (default), describe, ocr, or classify. */
|
|
24
|
+
task?: VisionTask;
|
|
25
|
+
/** Provider to target — informs client-side validation. Defaults to universal (all providers). */
|
|
26
|
+
provider?: VisionProvider;
|
|
27
|
+
/** Optional model override. */
|
|
28
|
+
model?: string;
|
|
29
|
+
/** Custom prompt (only meaningful for task="analyze"). */
|
|
30
|
+
prompt?: string;
|
|
31
|
+
/** Called on success. */
|
|
32
|
+
onResult?: (result: unknown) => void;
|
|
33
|
+
/** Called on error (before the built-in banner renders it). */
|
|
34
|
+
onError?: (error: {
|
|
35
|
+
code: string;
|
|
36
|
+
message: string;
|
|
37
|
+
}) => void;
|
|
38
|
+
/** Hide the supported-formats banner. Default: shown. */
|
|
39
|
+
hideBanner?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Auto-compress images that exceed the provider's size limit. JPEG/PNG/WEBP only.
|
|
42
|
+
* Default: true.
|
|
43
|
+
*/
|
|
44
|
+
autoCompress?: boolean;
|
|
45
|
+
/** Additional request headers to forward on upload (in addition to Authorization). */
|
|
46
|
+
headers?: Record<string, string>;
|
|
47
|
+
/** Extra classes. */
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
declare function VisionUploader({ endpoint, apiKey, task, provider, model, prompt, onResult, onError, hideBanner, autoCompress, headers, className, }: VisionUploaderProps): react_jsx_runtime.JSX.Element;
|
|
51
|
+
|
|
52
|
+
export { VisionFormatBanner, type VisionFormatBannerProps, type VisionProvider, type VisionTask, VisionUploader, type VisionUploaderProps };
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/react/index.ts
|
|
21
|
+
var react_exports = {};
|
|
22
|
+
__export(react_exports, {
|
|
23
|
+
VisionFormatBanner: () => VisionFormatBanner,
|
|
24
|
+
VisionUploader: () => VisionUploader
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(react_exports);
|
|
27
|
+
|
|
28
|
+
// src/react/vision/vision-format-banner.tsx
|
|
29
|
+
var import_lucide_react = require("lucide-react");
|
|
30
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
31
|
+
var UNIVERSAL_FORMATS = ["JPEG", "PNG", "WEBP", "GIF"];
|
|
32
|
+
var PROVIDER_LIMITS = {
|
|
33
|
+
openai: { formats: ["JPEG", "PNG", "WEBP", "GIF"], maxMB: 20, notes: "Non-animated GIFs only." },
|
|
34
|
+
anthropic: { formats: ["JPEG", "PNG", "WEBP", "GIF"], maxMB: 5, notes: "Max 8000\xD78000 dimensions." },
|
|
35
|
+
google: { formats: ["JPEG", "PNG", "WEBP", "GIF", "HEIC", "HEIF"], maxMB: 20, notes: "HEIC/HEIF supported." }
|
|
36
|
+
};
|
|
37
|
+
function joinClasses(...parts) {
|
|
38
|
+
return parts.filter(Boolean).join(" ");
|
|
39
|
+
}
|
|
40
|
+
function VisionFormatBanner({
|
|
41
|
+
provider,
|
|
42
|
+
variant = "info",
|
|
43
|
+
className,
|
|
44
|
+
title
|
|
45
|
+
}) {
|
|
46
|
+
const info = provider ? PROVIDER_LIMITS[provider] : { formats: UNIVERSAL_FORMATS, maxMB: 5, notes: "These formats work across every supported provider." };
|
|
47
|
+
const heading = title ?? (provider ? `Supported for ${providerLabel(provider)}` : "Supported image formats");
|
|
48
|
+
const base = variant === "info" ? "bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-950/40 dark:border-blue-900 dark:text-blue-100" : "bg-neutral-50 border-neutral-200 text-neutral-800 dark:bg-neutral-900 dark:border-neutral-800 dark:text-neutral-100";
|
|
49
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
50
|
+
"div",
|
|
51
|
+
{
|
|
52
|
+
role: "note",
|
|
53
|
+
className: joinClasses(
|
|
54
|
+
"flex gap-3 rounded-lg border p-4 text-sm",
|
|
55
|
+
base,
|
|
56
|
+
className
|
|
57
|
+
),
|
|
58
|
+
children: [
|
|
59
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "flex-shrink-0 pt-0.5", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_lucide_react.Info, { className: "h-5 w-5 opacity-70", "aria-hidden": true }) }),
|
|
60
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "flex flex-col gap-2", children: [
|
|
61
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "font-medium", children: heading }),
|
|
62
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "flex flex-wrap gap-1.5", children: info.formats.map((f) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
63
|
+
"span",
|
|
64
|
+
{
|
|
65
|
+
className: "inline-flex items-center gap-1 rounded-md border border-current/20 bg-white/50 px-2 py-0.5 text-xs font-medium dark:bg-black/20",
|
|
66
|
+
children: [
|
|
67
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_lucide_react.ImageIcon, { className: "h-3 w-3 opacity-70", "aria-hidden": true }),
|
|
68
|
+
f
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
f
|
|
72
|
+
)) }),
|
|
73
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { className: "text-xs opacity-80", children: [
|
|
74
|
+
"Up to ",
|
|
75
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("strong", { children: [
|
|
76
|
+
info.maxMB,
|
|
77
|
+
"MB"
|
|
78
|
+
] }),
|
|
79
|
+
" per image. ",
|
|
80
|
+
info.notes
|
|
81
|
+
] })
|
|
82
|
+
] })
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
function providerLabel(p) {
|
|
88
|
+
if (p === "openai") return "OpenAI";
|
|
89
|
+
if (p === "anthropic") return "Anthropic";
|
|
90
|
+
return "Google";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/react/vision/vision-uploader.tsx
|
|
94
|
+
var import_react = require("react");
|
|
95
|
+
var import_lucide_react2 = require("lucide-react");
|
|
96
|
+
|
|
97
|
+
// src/react/vision/downscale.ts
|
|
98
|
+
var DOWNSCALABLE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
99
|
+
function canDownscale(mimeType) {
|
|
100
|
+
return DOWNSCALABLE_TYPES.has(mimeType.toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
async function downscaleImage(file, maxBytes, options = {}) {
|
|
103
|
+
const originalBytes = file.size;
|
|
104
|
+
if (originalBytes <= maxBytes) {
|
|
105
|
+
return { file, originalBytes, finalBytes: originalBytes, steps: 0 };
|
|
106
|
+
}
|
|
107
|
+
if (!canDownscale(file.type)) {
|
|
108
|
+
throw new Error(`Cannot auto-compress ${file.type}. Please upload a smaller image.`);
|
|
109
|
+
}
|
|
110
|
+
const bitmap = await createImageBitmap(file);
|
|
111
|
+
let { width, height } = bitmap;
|
|
112
|
+
const minDim = options.minDimension ?? 320;
|
|
113
|
+
let quality = 0.9;
|
|
114
|
+
const qFloor = options.qualityFloor ?? 0.5;
|
|
115
|
+
const outputType = "image/jpeg";
|
|
116
|
+
let steps = 0;
|
|
117
|
+
let bytes = originalBytes;
|
|
118
|
+
let outFile = file;
|
|
119
|
+
while (bytes > maxBytes && steps < 8) {
|
|
120
|
+
steps += 1;
|
|
121
|
+
if (Math.min(width, height) > minDim && steps <= 4) {
|
|
122
|
+
width = Math.floor(width * 0.75);
|
|
123
|
+
height = Math.floor(height * 0.75);
|
|
124
|
+
} else if (quality > qFloor) {
|
|
125
|
+
quality = Math.max(qFloor, quality - 0.15);
|
|
126
|
+
} else {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(width, height) : Object.assign(document.createElement("canvas"), { width, height });
|
|
130
|
+
const ctx = canvas.getContext("2d");
|
|
131
|
+
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
|
132
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
133
|
+
const blob = canvas instanceof OffscreenCanvas ? await canvas.convertToBlob({ type: outputType, quality }) : await new Promise((resolve, reject) => {
|
|
134
|
+
canvas.toBlob(
|
|
135
|
+
(b) => b ? resolve(b) : reject(new Error("toBlob returned null")),
|
|
136
|
+
outputType,
|
|
137
|
+
quality
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
bytes = blob.size;
|
|
141
|
+
outFile = new File([blob], renameToJpeg(file.name), { type: outputType, lastModified: Date.now() });
|
|
142
|
+
}
|
|
143
|
+
bitmap.close?.();
|
|
144
|
+
if (bytes > maxBytes) {
|
|
145
|
+
throw new Error(`Could not compress image below the ${(maxBytes / (1024 * 1024)).toFixed(1)}MB limit.`);
|
|
146
|
+
}
|
|
147
|
+
return { file: outFile, originalBytes, finalBytes: bytes, steps };
|
|
148
|
+
}
|
|
149
|
+
function renameToJpeg(name) {
|
|
150
|
+
const dot = name.lastIndexOf(".");
|
|
151
|
+
if (dot === -1) return `${name}.jpg`;
|
|
152
|
+
return `${name.slice(0, dot)}.jpg`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/react/vision/vision-uploader.tsx
|
|
156
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
157
|
+
var UNIVERSAL_FORMATS2 = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
|
158
|
+
var PROVIDER_RULES = {
|
|
159
|
+
openai: { formats: UNIVERSAL_FORMATS2, maxBytes: 20 * 1024 * 1024 },
|
|
160
|
+
anthropic: { formats: UNIVERSAL_FORMATS2, maxBytes: 5 * 1024 * 1024 },
|
|
161
|
+
google: { formats: [...UNIVERSAL_FORMATS2, "image/heic", "image/heif"], maxBytes: 20 * 1024 * 1024 }
|
|
162
|
+
};
|
|
163
|
+
function rulesFor(provider) {
|
|
164
|
+
if (provider) return PROVIDER_RULES[provider];
|
|
165
|
+
return { formats: UNIVERSAL_FORMATS2, maxBytes: 5 * 1024 * 1024 };
|
|
166
|
+
}
|
|
167
|
+
function formatBytes(bytes) {
|
|
168
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
169
|
+
return `${(bytes / 1024).toFixed(0)}KB`;
|
|
170
|
+
}
|
|
171
|
+
function friendlyFormats(mimes) {
|
|
172
|
+
return mimes.map((m) => m.replace("image/", "").toUpperCase()).join(", ");
|
|
173
|
+
}
|
|
174
|
+
function joinClasses2(...parts) {
|
|
175
|
+
return parts.filter(Boolean).join(" ");
|
|
176
|
+
}
|
|
177
|
+
function VisionUploader({
|
|
178
|
+
endpoint = "/api/ai/vision",
|
|
179
|
+
apiKey,
|
|
180
|
+
task = "analyze",
|
|
181
|
+
provider,
|
|
182
|
+
model,
|
|
183
|
+
prompt,
|
|
184
|
+
onResult,
|
|
185
|
+
onError,
|
|
186
|
+
hideBanner = false,
|
|
187
|
+
autoCompress = true,
|
|
188
|
+
headers,
|
|
189
|
+
className
|
|
190
|
+
}) {
|
|
191
|
+
const [file, setFile] = (0, import_react.useState)(null);
|
|
192
|
+
const [preview, setPreview] = (0, import_react.useState)(null);
|
|
193
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
194
|
+
const [loading, setLoading] = (0, import_react.useState)(false);
|
|
195
|
+
const [result, setResult] = (0, import_react.useState)(null);
|
|
196
|
+
const [dragging, setDragging] = (0, import_react.useState)(false);
|
|
197
|
+
const [notice, setNotice] = (0, import_react.useState)(null);
|
|
198
|
+
const inputRef = (0, import_react.useRef)(null);
|
|
199
|
+
const rules = (0, import_react.useMemo)(() => rulesFor(provider), [provider]);
|
|
200
|
+
const targetUrl = task === "analyze" ? endpoint : `${endpoint.replace(/\/$/, "")}/${task}`;
|
|
201
|
+
const acceptFile = (0, import_react.useCallback)(async (f) => {
|
|
202
|
+
setResult(null);
|
|
203
|
+
setNotice(null);
|
|
204
|
+
let effective = f;
|
|
205
|
+
const mime = (f.type || "").toLowerCase();
|
|
206
|
+
if (!rules.formats.includes(mime)) {
|
|
207
|
+
const err = {
|
|
208
|
+
code: "unsupported_format",
|
|
209
|
+
message: `"${f.name}" is ${mime || "an unknown type"}. Please upload one of: ${friendlyFormats(rules.formats)}.`
|
|
210
|
+
};
|
|
211
|
+
setError(err);
|
|
212
|
+
setFile(null);
|
|
213
|
+
setPreview(null);
|
|
214
|
+
onError?.(err);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (f.size > rules.maxBytes) {
|
|
218
|
+
if (autoCompress && canDownscale(mime)) {
|
|
219
|
+
try {
|
|
220
|
+
const result2 = await downscaleImage(f, rules.maxBytes);
|
|
221
|
+
effective = result2.file;
|
|
222
|
+
setNotice(
|
|
223
|
+
`Compressed from ${formatBytes(result2.originalBytes)} to ${formatBytes(result2.finalBytes)} to fit the ${provider ?? "universal"} limit.`
|
|
224
|
+
);
|
|
225
|
+
} catch (compressErr) {
|
|
226
|
+
const err = {
|
|
227
|
+
code: "image_too_large",
|
|
228
|
+
message: compressErr instanceof Error ? compressErr.message : `"${f.name}" is ${formatBytes(f.size)} \u2014 the limit is ${formatBytes(rules.maxBytes)}${provider ? ` for ${provider}` : ""}.`
|
|
229
|
+
};
|
|
230
|
+
setError(err);
|
|
231
|
+
setFile(null);
|
|
232
|
+
setPreview(null);
|
|
233
|
+
onError?.(err);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
const err = {
|
|
238
|
+
code: "image_too_large",
|
|
239
|
+
message: `"${f.name}" is ${formatBytes(f.size)} \u2014 the limit is ${formatBytes(rules.maxBytes)}${provider ? ` for ${provider}` : ""}.`
|
|
240
|
+
};
|
|
241
|
+
setError(err);
|
|
242
|
+
setFile(null);
|
|
243
|
+
setPreview(null);
|
|
244
|
+
onError?.(err);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
setError(null);
|
|
249
|
+
setFile(effective);
|
|
250
|
+
setPreview(URL.createObjectURL(effective));
|
|
251
|
+
}, [rules, provider, autoCompress, onError]);
|
|
252
|
+
const handleDrop = (0, import_react.useCallback)((e) => {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
setDragging(false);
|
|
255
|
+
const f = e.dataTransfer.files?.[0];
|
|
256
|
+
if (f) acceptFile(f);
|
|
257
|
+
}, [acceptFile]);
|
|
258
|
+
const handleSubmit = (0, import_react.useCallback)(async () => {
|
|
259
|
+
if (!file) return;
|
|
260
|
+
setLoading(true);
|
|
261
|
+
setError(null);
|
|
262
|
+
setResult(null);
|
|
263
|
+
try {
|
|
264
|
+
const form = new FormData();
|
|
265
|
+
form.append("file", file);
|
|
266
|
+
if (prompt && task === "analyze") form.append("prompt", prompt);
|
|
267
|
+
if (model) form.append("model", model);
|
|
268
|
+
const requestHeaders = { ...headers ?? {} };
|
|
269
|
+
if (apiKey) requestHeaders.Authorization = `Bearer ${apiKey}`;
|
|
270
|
+
const res = await fetch(targetUrl, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: Object.keys(requestHeaders).length ? requestHeaders : void 0,
|
|
273
|
+
body: form
|
|
274
|
+
});
|
|
275
|
+
const data = await res.json();
|
|
276
|
+
if (!res.ok) {
|
|
277
|
+
const err = {
|
|
278
|
+
code: data && typeof data === "object" && "error" in data ? String(data.error) : "request_failed",
|
|
279
|
+
message: data && typeof data === "object" && "message" in data ? String(data.message) : `Request failed with status ${res.status}`
|
|
280
|
+
};
|
|
281
|
+
setError(err);
|
|
282
|
+
onError?.(err);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
setResult(data);
|
|
286
|
+
onResult?.(data);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
const e = { code: "network_error", message: err instanceof Error ? err.message : "Network error" };
|
|
289
|
+
setError(e);
|
|
290
|
+
onError?.(e);
|
|
291
|
+
} finally {
|
|
292
|
+
setLoading(false);
|
|
293
|
+
}
|
|
294
|
+
}, [file, prompt, model, task, targetUrl, apiKey, headers, onResult, onError]);
|
|
295
|
+
const clear = (0, import_react.useCallback)(() => {
|
|
296
|
+
setFile(null);
|
|
297
|
+
setPreview(null);
|
|
298
|
+
setResult(null);
|
|
299
|
+
setError(null);
|
|
300
|
+
setNotice(null);
|
|
301
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
302
|
+
}, []);
|
|
303
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: joinClasses2("flex flex-col gap-3", className), children: [
|
|
304
|
+
!hideBanner && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(VisionFormatBanner, { provider }),
|
|
305
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
306
|
+
"div",
|
|
307
|
+
{
|
|
308
|
+
onDragOver: (e) => {
|
|
309
|
+
e.preventDefault();
|
|
310
|
+
setDragging(true);
|
|
311
|
+
},
|
|
312
|
+
onDragLeave: () => setDragging(false),
|
|
313
|
+
onDrop: handleDrop,
|
|
314
|
+
onClick: () => inputRef.current?.click(),
|
|
315
|
+
role: "button",
|
|
316
|
+
tabIndex: 0,
|
|
317
|
+
"aria-label": "Upload image",
|
|
318
|
+
className: joinClasses2(
|
|
319
|
+
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-8 text-center transition",
|
|
320
|
+
dragging ? "border-blue-500 bg-blue-50 dark:bg-blue-950/30" : "border-neutral-300 hover:border-neutral-400 dark:border-neutral-700 dark:hover:border-neutral-600"
|
|
321
|
+
),
|
|
322
|
+
children: [
|
|
323
|
+
preview ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "relative", children: [
|
|
324
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("img", { src: preview, alt: "Preview", className: "max-h-64 rounded-lg object-contain" }),
|
|
325
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
326
|
+
"button",
|
|
327
|
+
{
|
|
328
|
+
type: "button",
|
|
329
|
+
onClick: (e) => {
|
|
330
|
+
e.stopPropagation();
|
|
331
|
+
clear();
|
|
332
|
+
},
|
|
333
|
+
className: "absolute -right-2 -top-2 rounded-full bg-white p-1 shadow ring-1 ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-900 dark:ring-neutral-700",
|
|
334
|
+
"aria-label": "Remove image",
|
|
335
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.X, { className: "h-4 w-4" })
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
|
|
339
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Upload, { className: "h-8 w-8 text-neutral-400", "aria-hidden": true }),
|
|
340
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-sm font-medium", children: "Drop an image or click to upload" }),
|
|
341
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { className: "text-xs text-neutral-500", children: [
|
|
342
|
+
friendlyFormats(rules.formats),
|
|
343
|
+
" \xB7 up to ",
|
|
344
|
+
formatBytes(rules.maxBytes)
|
|
345
|
+
] })
|
|
346
|
+
] }),
|
|
347
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
348
|
+
"input",
|
|
349
|
+
{
|
|
350
|
+
ref: inputRef,
|
|
351
|
+
type: "file",
|
|
352
|
+
accept: rules.formats.join(","),
|
|
353
|
+
className: "hidden",
|
|
354
|
+
onChange: (e) => {
|
|
355
|
+
const f = e.target.files?.[0];
|
|
356
|
+
if (f) acceptFile(f);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
]
|
|
361
|
+
}
|
|
362
|
+
),
|
|
363
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { role: "alert", className: "flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100", children: [
|
|
364
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.AlertCircle, { className: "mt-0.5 h-4 w-4 flex-shrink-0", "aria-hidden": true }),
|
|
365
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
|
|
366
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "font-medium", children: "Can't process this image" }),
|
|
367
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-xs opacity-90", children: error.message })
|
|
368
|
+
] })
|
|
369
|
+
] }),
|
|
370
|
+
notice && !error && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-xs text-neutral-500 dark:text-neutral-400", children: notice }),
|
|
371
|
+
file && !loading && !result && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
372
|
+
"button",
|
|
373
|
+
{
|
|
374
|
+
type: "button",
|
|
375
|
+
onClick: handleSubmit,
|
|
376
|
+
className: "inline-flex items-center justify-center rounded-lg bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-neutral-800 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-100",
|
|
377
|
+
children: "Analyze image"
|
|
378
|
+
}
|
|
379
|
+
),
|
|
380
|
+
loading && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400", children: [
|
|
381
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Loader2, { className: "h-4 w-4 animate-spin", "aria-hidden": true }),
|
|
382
|
+
"Analyzing\u2026"
|
|
383
|
+
] }),
|
|
384
|
+
result !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-sm dark:border-neutral-800 dark:bg-neutral-900", children: [
|
|
385
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mb-1 text-xs font-medium uppercase text-neutral-500", children: "Result" }),
|
|
386
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("pre", { className: "overflow-auto whitespace-pre-wrap break-words text-xs", children: typeof result === "string" ? result : JSON.stringify(result, null, 2) })
|
|
387
|
+
] })
|
|
388
|
+
] });
|
|
389
|
+
}
|
|
390
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
391
|
+
0 && (module.exports = {
|
|
392
|
+
VisionFormatBanner,
|
|
393
|
+
VisionUploader
|
|
394
|
+
});
|
|
395
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/index.ts","../../src/react/vision/vision-format-banner.tsx","../../src/react/vision/vision-uploader.tsx","../../src/react/vision/downscale.ts"],"sourcesContent":["/**\n * cencori/react — importable UI components for AI apps\n *\n * @example\n * import { VisionUploader } from 'cencori/react';\n *\n * <VisionUploader\n * endpoint=\"https://api.cencori.com/api/ai/vision\"\n * apiKey={process.env.NEXT_PUBLIC_CENCORI_KEY}\n * task=\"describe\"\n * onResult={(result) => console.log(result)}\n * />\n */\n\nexport { VisionFormatBanner } from './vision/vision-format-banner';\nexport type { VisionFormatBannerProps, VisionProvider } from './vision/vision-format-banner';\n\nexport { VisionUploader } from './vision/vision-uploader';\nexport type { VisionUploaderProps, VisionTask } from './vision/vision-uploader';\n","/**\n * VisionFormatBanner\n *\n * Drop-in banner that documents the supported image formats and size limits\n * for the Cencori Vision API. Zero external state — safe to embed anywhere,\n * including inside a customer's own product.\n *\n * Deps: react, lucide-react, tailwindcss.\n */\n\n'use client';\n\nimport { ImageIcon, Info } from 'lucide-react';\nimport type { ReactNode } from 'react';\n\nexport type VisionProvider = 'openai' | 'anthropic' | 'google';\n\nexport interface VisionFormatBannerProps {\n /** Restrict displayed limits to a specific provider. Omit to show universal (all-provider) limits. */\n provider?: VisionProvider;\n /** Tone. */\n variant?: 'info' | 'muted';\n /** Extra classes. */\n className?: string;\n /** Optional title override. */\n title?: ReactNode;\n}\n\nconst UNIVERSAL_FORMATS = ['JPEG', 'PNG', 'WEBP', 'GIF'];\n\nconst PROVIDER_LIMITS: Record<VisionProvider, { formats: string[]; maxMB: number; notes: string }> = {\n openai: { formats: ['JPEG', 'PNG', 'WEBP', 'GIF'], maxMB: 20, notes: 'Non-animated GIFs only.' },\n anthropic: { formats: ['JPEG', 'PNG', 'WEBP', 'GIF'], maxMB: 5, notes: 'Max 8000×8000 dimensions.' },\n google: { formats: ['JPEG', 'PNG', 'WEBP', 'GIF', 'HEIC', 'HEIF'], maxMB: 20, notes: 'HEIC/HEIF supported.' },\n};\n\nfunction joinClasses(...parts: Array<string | undefined | false>): string {\n return parts.filter(Boolean).join(' ');\n}\n\nexport function VisionFormatBanner({\n provider,\n variant = 'info',\n className,\n title,\n}: VisionFormatBannerProps) {\n const info = provider ? PROVIDER_LIMITS[provider] : { formats: UNIVERSAL_FORMATS, maxMB: 5, notes: 'These formats work across every supported provider.' };\n const heading = title ?? (provider ? `Supported for ${providerLabel(provider)}` : 'Supported image formats');\n\n const base = variant === 'info'\n ? 'bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-950/40 dark:border-blue-900 dark:text-blue-100'\n : 'bg-neutral-50 border-neutral-200 text-neutral-800 dark:bg-neutral-900 dark:border-neutral-800 dark:text-neutral-100';\n\n return (\n <div\n role=\"note\"\n className={joinClasses(\n 'flex gap-3 rounded-lg border p-4 text-sm',\n base,\n className\n )}\n >\n <div className=\"flex-shrink-0 pt-0.5\">\n <Info className=\"h-5 w-5 opacity-70\" aria-hidden />\n </div>\n <div className=\"flex flex-col gap-2\">\n <p className=\"font-medium\">{heading}</p>\n <div className=\"flex flex-wrap gap-1.5\">\n {info.formats.map(f => (\n <span\n key={f}\n className=\"inline-flex items-center gap-1 rounded-md border border-current/20 bg-white/50 px-2 py-0.5 text-xs font-medium dark:bg-black/20\"\n >\n <ImageIcon className=\"h-3 w-3 opacity-70\" aria-hidden />\n {f}\n </span>\n ))}\n </div>\n <p className=\"text-xs opacity-80\">\n Up to <strong>{info.maxMB}MB</strong> per image. {info.notes}\n </p>\n </div>\n </div>\n );\n}\n\nfunction providerLabel(p: VisionProvider): string {\n if (p === 'openai') return 'OpenAI';\n if (p === 'anthropic') return 'Anthropic';\n return 'Google';\n}\n","/**\n * VisionUploader\n *\n * Drop-in image uploader for the Cencori Vision API. Handles drag-drop,\n * client-side format + size validation, and calls the endpoint via\n * multipart/form-data. Renders clear error messages and a supported-formats\n * banner. Zero external state — safe to embed in a customer's own product.\n *\n * Usage:\n * <VisionUploader\n * endpoint=\"https://api.cencori.com/api/ai/vision\"\n * apiKey={process.env.NEXT_PUBLIC_CENCORI_KEY}\n * task=\"describe\"\n * onResult={(result) => console.log(result)}\n * />\n *\n * Deps: react, lucide-react, tailwindcss.\n */\n\n'use client';\n\nimport { useCallback, useMemo, useRef, useState } from 'react';\nimport { AlertCircle, Loader2, Upload, X } from 'lucide-react';\nimport { VisionFormatBanner, type VisionProvider } from './vision-format-banner';\nimport { canDownscale, downscaleImage } from './downscale';\n\n// ── Public types ────────────────────────────────────────────────\n\nexport type VisionTask = 'analyze' | 'describe' | 'ocr' | 'classify';\n\nexport interface VisionUploaderProps {\n /** Base Vision endpoint. Defaults to same-origin `/api/ai/vision`. */\n endpoint?: string;\n /** Cencori API key. Sent as `Authorization: Bearer`. */\n apiKey?: string;\n /** Which task to run: analyze (default), describe, ocr, or classify. */\n task?: VisionTask;\n /** Provider to target — informs client-side validation. Defaults to universal (all providers). */\n provider?: VisionProvider;\n /** Optional model override. */\n model?: string;\n /** Custom prompt (only meaningful for task=\"analyze\"). */\n prompt?: string;\n /** Called on success. */\n onResult?: (result: unknown) => void;\n /** Called on error (before the built-in banner renders it). */\n onError?: (error: { code: string; message: string }) => void;\n /** Hide the supported-formats banner. Default: shown. */\n hideBanner?: boolean;\n /**\n * Auto-compress images that exceed the provider's size limit. JPEG/PNG/WEBP only.\n * Default: true.\n */\n autoCompress?: boolean;\n /** Additional request headers to forward on upload (in addition to Authorization). */\n headers?: Record<string, string>;\n /** Extra classes. */\n className?: string;\n}\n\n// ── Client-side validation rules (mirror the server) ────────────\n\nconst UNIVERSAL_FORMATS = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];\n\nconst PROVIDER_RULES: Record<VisionProvider, { formats: string[]; maxBytes: number }> = {\n openai: { formats: UNIVERSAL_FORMATS, maxBytes: 20 * 1024 * 1024 },\n anthropic: { formats: UNIVERSAL_FORMATS, maxBytes: 5 * 1024 * 1024 },\n google: { formats: [...UNIVERSAL_FORMATS, 'image/heic', 'image/heif'], maxBytes: 20 * 1024 * 1024 },\n};\n\nfunction rulesFor(provider?: VisionProvider) {\n if (provider) return PROVIDER_RULES[provider];\n return { formats: UNIVERSAL_FORMATS, maxBytes: 5 * 1024 * 1024 };\n}\n\nfunction formatBytes(bytes: number): string {\n if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;\n return `${(bytes / 1024).toFixed(0)}KB`;\n}\n\nfunction friendlyFormats(mimes: string[]): string {\n return mimes.map(m => m.replace('image/', '').toUpperCase()).join(', ');\n}\n\nfunction joinClasses(...parts: Array<string | undefined | false>): string {\n return parts.filter(Boolean).join(' ');\n}\n\n// ── Component ───────────────────────────────────────────────────\n\nexport function VisionUploader({\n endpoint = '/api/ai/vision',\n apiKey,\n task = 'analyze',\n provider,\n model,\n prompt,\n onResult,\n onError,\n hideBanner = false,\n autoCompress = true,\n headers,\n className,\n}: VisionUploaderProps) {\n const [file, setFile] = useState<File | null>(null);\n const [preview, setPreview] = useState<string | null>(null);\n const [error, setError] = useState<{ code: string; message: string } | null>(null);\n const [loading, setLoading] = useState(false);\n const [result, setResult] = useState<unknown>(null);\n const [dragging, setDragging] = useState(false);\n const [notice, setNotice] = useState<string | null>(null);\n const inputRef = useRef<HTMLInputElement | null>(null);\n\n const rules = useMemo(() => rulesFor(provider), [provider]);\n const targetUrl = task === 'analyze' ? endpoint : `${endpoint.replace(/\\/$/, '')}/${task}`;\n\n const acceptFile = useCallback(async (f: File) => {\n setResult(null);\n setNotice(null);\n let effective = f;\n\n // Wrong format: fail fast, no attempt at compression\n const mime = (f.type || '').toLowerCase();\n if (!rules.formats.includes(mime)) {\n const err = {\n code: 'unsupported_format',\n message: `\"${f.name}\" is ${mime || 'an unknown type'}. Please upload one of: ${friendlyFormats(rules.formats)}.`,\n };\n setError(err);\n setFile(null);\n setPreview(null);\n onError?.(err);\n return;\n }\n\n // Too big: try auto-compression if allowed and format is downscalable\n if (f.size > rules.maxBytes) {\n if (autoCompress && canDownscale(mime)) {\n try {\n const result = await downscaleImage(f, rules.maxBytes);\n effective = result.file;\n setNotice(\n `Compressed from ${formatBytes(result.originalBytes)} to ${formatBytes(result.finalBytes)} to fit the ${provider ?? 'universal'} limit.`\n );\n } catch (compressErr) {\n const err = {\n code: 'image_too_large',\n message: compressErr instanceof Error\n ? compressErr.message\n : `\"${f.name}\" is ${formatBytes(f.size)} — the limit is ${formatBytes(rules.maxBytes)}${provider ? ` for ${provider}` : ''}.`,\n };\n setError(err);\n setFile(null);\n setPreview(null);\n onError?.(err);\n return;\n }\n } else {\n const err = {\n code: 'image_too_large',\n message: `\"${f.name}\" is ${formatBytes(f.size)} — the limit is ${formatBytes(rules.maxBytes)}${provider ? ` for ${provider}` : ''}.`,\n };\n setError(err);\n setFile(null);\n setPreview(null);\n onError?.(err);\n return;\n }\n }\n\n setError(null);\n setFile(effective);\n setPreview(URL.createObjectURL(effective));\n }, [rules, provider, autoCompress, onError]);\n\n const handleDrop = useCallback((e: React.DragEvent) => {\n e.preventDefault();\n setDragging(false);\n const f = e.dataTransfer.files?.[0];\n if (f) acceptFile(f);\n }, [acceptFile]);\n\n const handleSubmit = useCallback(async () => {\n if (!file) return;\n setLoading(true);\n setError(null);\n setResult(null);\n try {\n const form = new FormData();\n form.append('file', file);\n if (prompt && task === 'analyze') form.append('prompt', prompt);\n if (model) form.append('model', model);\n\n const requestHeaders: Record<string, string> = { ...(headers ?? {}) };\n if (apiKey) requestHeaders.Authorization = `Bearer ${apiKey}`;\n\n const res = await fetch(targetUrl, {\n method: 'POST',\n headers: Object.keys(requestHeaders).length ? requestHeaders : undefined,\n body: form,\n });\n const data = await res.json();\n if (!res.ok) {\n const err = {\n code: (data && typeof data === 'object' && 'error' in data ? String(data.error) : 'request_failed'),\n message: (data && typeof data === 'object' && 'message' in data ? String(data.message) : `Request failed with status ${res.status}`),\n };\n setError(err);\n onError?.(err);\n return;\n }\n setResult(data);\n onResult?.(data);\n } catch (err) {\n const e = { code: 'network_error', message: err instanceof Error ? err.message : 'Network error' };\n setError(e);\n onError?.(e);\n } finally {\n setLoading(false);\n }\n }, [file, prompt, model, task, targetUrl, apiKey, headers, onResult, onError]);\n\n const clear = useCallback(() => {\n setFile(null);\n setPreview(null);\n setResult(null);\n setError(null);\n setNotice(null);\n if (inputRef.current) inputRef.current.value = '';\n }, []);\n\n return (\n <div className={joinClasses('flex flex-col gap-3', className)}>\n {!hideBanner && <VisionFormatBanner provider={provider} />}\n\n <div\n onDragOver={e => { e.preventDefault(); setDragging(true); }}\n onDragLeave={() => setDragging(false)}\n onDrop={handleDrop}\n onClick={() => inputRef.current?.click()}\n role=\"button\"\n tabIndex={0}\n aria-label=\"Upload image\"\n className={joinClasses(\n 'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-8 text-center transition',\n dragging\n ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30'\n : 'border-neutral-300 hover:border-neutral-400 dark:border-neutral-700 dark:hover:border-neutral-600',\n )}\n >\n {preview ? (\n <div className=\"relative\">\n <img src={preview} alt=\"Preview\" className=\"max-h-64 rounded-lg object-contain\" />\n <button\n type=\"button\"\n onClick={e => { e.stopPropagation(); clear(); }}\n className=\"absolute -right-2 -top-2 rounded-full bg-white p-1 shadow ring-1 ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-900 dark:ring-neutral-700\"\n aria-label=\"Remove image\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n </div>\n ) : (\n <>\n <Upload className=\"h-8 w-8 text-neutral-400\" aria-hidden />\n <p className=\"text-sm font-medium\">Drop an image or click to upload</p>\n <p className=\"text-xs text-neutral-500\">{friendlyFormats(rules.formats)} · up to {formatBytes(rules.maxBytes)}</p>\n </>\n )}\n <input\n ref={inputRef}\n type=\"file\"\n accept={rules.formats.join(',')}\n className=\"hidden\"\n onChange={e => {\n const f = e.target.files?.[0];\n if (f) acceptFile(f);\n }}\n />\n </div>\n\n {error && (\n <div role=\"alert\" className=\"flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100\">\n <AlertCircle className=\"mt-0.5 h-4 w-4 flex-shrink-0\" aria-hidden />\n <div>\n <p className=\"font-medium\">Can't process this image</p>\n <p className=\"text-xs opacity-90\">{error.message}</p>\n </div>\n </div>\n )}\n\n {notice && !error && (\n <p className=\"text-xs text-neutral-500 dark:text-neutral-400\">{notice}</p>\n )}\n\n {file && !loading && !result && (\n <button\n type=\"button\"\n onClick={handleSubmit}\n className=\"inline-flex items-center justify-center rounded-lg bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-neutral-800 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-100\"\n >\n Analyze image\n </button>\n )}\n\n {loading && (\n <div className=\"inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400\">\n <Loader2 className=\"h-4 w-4 animate-spin\" aria-hidden />\n Analyzing…\n </div>\n )}\n\n {result !== null && (\n <div className=\"rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-sm dark:border-neutral-800 dark:bg-neutral-900\">\n <p className=\"mb-1 text-xs font-medium uppercase text-neutral-500\">Result</p>\n <pre className=\"overflow-auto whitespace-pre-wrap break-words text-xs\">\n {typeof result === 'string' ? result : JSON.stringify(result, null, 2)}\n </pre>\n </div>\n )}\n </div>\n );\n}\n","/**\n * Client-side image downscaling.\n *\n * Iteratively reduces JPEG/PNG/WEBP dimensions until the image fits under\n * `maxBytes`. Uses <canvas>, so it must run in the browser. GIF, HEIC, and\n * HEIF are not downscaled — the caller should reject those with a format error.\n */\n\nconst DOWNSCALABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);\n\nexport function canDownscale(mimeType: string): boolean {\n return DOWNSCALABLE_TYPES.has(mimeType.toLowerCase());\n}\n\nexport interface DownscaleResult {\n file: File;\n originalBytes: number;\n finalBytes: number;\n steps: number;\n}\n\nexport async function downscaleImage(\n file: File,\n maxBytes: number,\n options: { minDimension?: number; qualityFloor?: number } = {}\n): Promise<DownscaleResult> {\n const originalBytes = file.size;\n if (originalBytes <= maxBytes) {\n return { file, originalBytes, finalBytes: originalBytes, steps: 0 };\n }\n if (!canDownscale(file.type)) {\n throw new Error(`Cannot auto-compress ${file.type}. Please upload a smaller image.`);\n }\n\n const bitmap = await createImageBitmap(file);\n let { width, height } = bitmap;\n const minDim = options.minDimension ?? 320;\n let quality = 0.9;\n const qFloor = options.qualityFloor ?? 0.5;\n // Re-encode to JPEG unless PNG has transparency we should preserve. Simpler: always JPEG for compression.\n const outputType = 'image/jpeg';\n\n let steps = 0;\n let bytes = originalBytes;\n let outFile = file;\n\n while (bytes > maxBytes && steps < 8) {\n steps += 1;\n // Prefer reducing dimensions first (bigger win), then quality\n if (Math.min(width, height) > minDim && steps <= 4) {\n width = Math.floor(width * 0.75);\n height = Math.floor(height * 0.75);\n } else if (quality > qFloor) {\n quality = Math.max(qFloor, quality - 0.15);\n } else {\n break;\n }\n\n const canvas = typeof OffscreenCanvas !== 'undefined'\n ? new OffscreenCanvas(width, height)\n : Object.assign(document.createElement('canvas'), { width, height });\n\n const ctx = (canvas as HTMLCanvasElement | OffscreenCanvas).getContext('2d');\n if (!ctx) throw new Error('Canvas 2D context unavailable');\n (ctx as CanvasRenderingContext2D).drawImage(bitmap, 0, 0, width, height);\n\n const blob = canvas instanceof OffscreenCanvas\n ? await canvas.convertToBlob({ type: outputType, quality })\n : await new Promise<Blob>((resolve, reject) => {\n (canvas as HTMLCanvasElement).toBlob(\n (b) => b ? resolve(b) : reject(new Error('toBlob returned null')),\n outputType,\n quality,\n );\n });\n\n bytes = blob.size;\n outFile = new File([blob], renameToJpeg(file.name), { type: outputType, lastModified: Date.now() });\n }\n\n bitmap.close?.();\n\n if (bytes > maxBytes) {\n throw new Error(`Could not compress image below the ${(maxBytes / (1024 * 1024)).toFixed(1)}MB limit.`);\n }\n\n return { file: outFile, originalBytes, finalBytes: bytes, steps };\n}\n\nfunction renameToJpeg(name: string): string {\n const dot = name.lastIndexOf('.');\n if (dot === -1) return `${name}.jpg`;\n return `${name.slice(0, dot)}.jpg`;\n}\n"],"mappings":"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","names":["import_lucide_react","import_jsx_runtime","UNIVERSAL_FORMATS","joinClasses","result"]}
|