@life-palette/uploader 0.2.1

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/dist/index.mjs ADDED
@@ -0,0 +1,565 @@
1
+ import { analyzeMedia, hashBlob } from "@life-palette/media";
2
+ //#region src/file-url.ts
3
+ /**
4
+ * OSS URL 处理:图片缩略、实况照片配对、文件名解析等。
5
+ *
6
+ * 这些工具属于 OSS 契约的一部分——既被上传器消费,也被前端展示层消费。
7
+ */
8
+ const NEED_FORMAT_EXTS = [".heic", ".heif"];
9
+ const VIDEO_EXTS = [
10
+ "mov",
11
+ "mp4",
12
+ "avi",
13
+ "mkv",
14
+ "webm",
15
+ "m4v"
16
+ ];
17
+ /**
18
+ * 从 live_photo_video.video_variants 中取 1080p,兜底取原始 url
19
+ */
20
+ function getLivePhotoVideoUrl(file) {
21
+ const lv = file?.live_photo_video;
22
+ if (!lv) return "";
23
+ const variants = lv.video_variants;
24
+ if (Array.isArray(variants) && variants.length) {
25
+ const v1080 = variants.find((v) => v.quality === "1080p");
26
+ if (v1080?.url) return v1080.url;
27
+ }
28
+ return lv.url || "";
29
+ }
30
+ /**
31
+ * 解析文件数据,生成展示所需的 URL
32
+ * - IMAGE: thumbnailUrl(缩略图)、baseSrc(原图/转格式)、videoSrc(实况视频)
33
+ * - VIDEO: cover(封面截图)
34
+ */
35
+ function fileParse(data, options) {
36
+ const { url = "", type = "" } = data || {};
37
+ const fileType = type.toUpperCase().includes("VIDEO") ? "VIDEO" : "IMAGE";
38
+ const { format = "jpg", resize = 400 } = options || {};
39
+ let baseSrc = "";
40
+ let thumbnailUrl = "";
41
+ let cover = "";
42
+ let videoSrc = "";
43
+ if (fileType === "IMAGE") {
44
+ const ext = (data?.extension || url.slice(Math.max(0, url.lastIndexOf(".")))).toLowerCase();
45
+ if (NEED_FORMAT_EXTS.some((e) => ext.includes(e))) {
46
+ baseSrc = `${url}?x-oss-process=image/format,${format}`;
47
+ thumbnailUrl = `${url}?x-oss-process=image/resize,l_${resize}/format,${format}`;
48
+ } else {
49
+ baseSrc = url;
50
+ thumbnailUrl = `${url}?x-oss-process=image/resize,l_${resize}`;
51
+ }
52
+ videoSrc = getLivePhotoVideoUrl(data);
53
+ } else cover = data?.cover || `${url}?x-oss-process=video/snapshot,t_7000,f_${format},w_0,h_0,m_fast`;
54
+ return {
55
+ ...data,
56
+ baseSrc,
57
+ cover,
58
+ fileType,
59
+ thumbnailUrl,
60
+ videoSrc
61
+ };
62
+ }
63
+ /**
64
+ * 判断文件是否为视频
65
+ */
66
+ function isVideo(file) {
67
+ return !!file.type?.startsWith("video/");
68
+ }
69
+ /**
70
+ * 判断文件是否为实况照片(有 videoSrc 但本身不是视频)
71
+ */
72
+ function isLivePhoto(file) {
73
+ return !!(file.videoSrc && !isVideo(file));
74
+ }
75
+ /**
76
+ * 生成 OSS 视频截帧 URL
77
+ */
78
+ function getVideoThumbnailUrl(videoUrl) {
79
+ return `${videoUrl}?x-oss-process=video/snapshot,t_1000,f_jpg,w_0,h_0,m_fast`;
80
+ }
81
+ /**
82
+ * 生成 OSS 图片处理参数(resize + quality + webp)
83
+ */
84
+ function generateOssImageParams(originalWidth, originalHeight, targetWidth, quality = 10) {
85
+ if (!(originalWidth && originalHeight)) return `?x-oss-process=image/resize,w_${targetWidth},m_lfit/quality,q_${quality}/format,webp`;
86
+ return `?x-oss-process=image/resize,w_${targetWidth},h_${Math.round(originalHeight / originalWidth * targetWidth)},m_lfit/quality,q_${quality}/format,webp`;
87
+ }
88
+ /**
89
+ * 解析文件名 → baseName + ext + isVideo
90
+ */
91
+ function parseFileName(fileName) {
92
+ const parts = fileName.split(".");
93
+ const ext = (parts.pop() || "").toLowerCase();
94
+ return {
95
+ baseName: parts.join("."),
96
+ ext,
97
+ isVideo: VIDEO_EXTS.includes(ext)
98
+ };
99
+ }
100
+ //#endregion
101
+ //#region src/live-photo.ts
102
+ /**
103
+ * Live Photo pairing utilities.
104
+ */
105
+ /**
106
+ * 检测文件列表中的 Live Photo 配对(同名 image + video)。
107
+ */
108
+ function detectLivePhotoPairs(files) {
109
+ const pairs = [];
110
+ const usedIndices = /* @__PURE__ */ new Set();
111
+ files.forEach((file, idx) => {
112
+ const { baseName, isVideo: isVid } = parseFileName(file.name);
113
+ if (!isVid) return;
114
+ const imageIdx = files.findIndex((f, i) => {
115
+ if (i === idx || usedIndices.has(i)) return false;
116
+ const info = parseFileName(f.name);
117
+ return info.baseName === baseName && !info.isVideo;
118
+ });
119
+ if (imageIdx !== -1) {
120
+ const image = files[imageIdx];
121
+ if (!image) return;
122
+ pairs.push({
123
+ image,
124
+ video: file
125
+ });
126
+ usedIndices.add(imageIdx);
127
+ usedIndices.add(idx);
128
+ }
129
+ });
130
+ return pairs;
131
+ }
132
+ //#endregion
133
+ //#region src/pool.ts
134
+ /**
135
+ * Concurrency-limited async pool.
136
+ *
137
+ * Lightweight replacement for `p-limit` (≈ 30 lines, no dependency).
138
+ */
139
+ var PromisePool = class {
140
+ limit;
141
+ active = 0;
142
+ queue = [];
143
+ constructor(limit) {
144
+ this.limit = limit;
145
+ }
146
+ run(task) {
147
+ return new Promise((resolve, reject) => {
148
+ const execute = () => {
149
+ this.active += 1;
150
+ task().then(resolve, reject).finally(() => {
151
+ this.active -= 1;
152
+ this.next();
153
+ });
154
+ };
155
+ this.queue.push(execute);
156
+ this.next();
157
+ });
158
+ }
159
+ next() {
160
+ while (this.active < this.limit && this.queue.length > 0) this.queue.shift()?.();
161
+ }
162
+ };
163
+ /**
164
+ * Retry an async operation with exponential backoff.
165
+ */
166
+ async function withRetry(task, attempts, baseDelayMs = 500) {
167
+ let lastError;
168
+ for (let index = 0; index < attempts; index += 1) try {
169
+ return await task();
170
+ } catch (error) {
171
+ lastError = error;
172
+ if (index === attempts - 1) break;
173
+ await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** index));
174
+ }
175
+ throw lastError;
176
+ }
177
+ //#endregion
178
+ //#region src/uploader.ts
179
+ /**
180
+ * OSS Uploader — pure factory with no framework dependency.
181
+ *
182
+ * Pipeline: compress(optional) → analyze → init(秒传检查) → upload parts → complete.
183
+ * Concurrent multipart upload with per-part retry; live-photo association runs after batch.
184
+ */
185
+ const RE_HOST_PREFIX = /^https?:\/\/[^/]+\//;
186
+ function createOssUploader(config) {
187
+ const { apiBaseUrl, getToken, multipartThreshold = 5242880, chunkSize = 5242880, partConcurrency = 4, partRetries = 3, completeEndpoint = "/file/upload/complete" } = config;
188
+ const uploadPath = "/file/upload";
189
+ async function request(endpoint, method, body) {
190
+ const token = getToken();
191
+ const res = await fetch(`${apiBaseUrl}${endpoint}`, {
192
+ body: JSON.stringify(body),
193
+ headers: {
194
+ "Content-Type": "application/json",
195
+ ...token ? { Authorization: `Bearer ${token}` } : {}
196
+ },
197
+ method
198
+ });
199
+ const data = await res.json();
200
+ if (!res.ok || data.code && data.code >= 400) throw new Error(data.message || data.msg || "请求失败");
201
+ return data.data ?? data.result;
202
+ }
203
+ function initUpload(fileName, fileSize, md5, chunk) {
204
+ const body = {
205
+ file_name: fileName,
206
+ file_size: fileSize,
207
+ md5
208
+ };
209
+ if (chunk) body.chunk_size = chunk;
210
+ return request(`${uploadPath}/init`, "POST", body);
211
+ }
212
+ function completeUpload(data) {
213
+ return request(completeEndpoint, "POST", data);
214
+ }
215
+ function getPartUrls(key, uploadId, partNumbers) {
216
+ return request(`${uploadPath}/urls`, "POST", {
217
+ key,
218
+ part_numbers: partNumbers,
219
+ upload_id: uploadId
220
+ });
221
+ }
222
+ async function compress(file, maxSizeMB = 1) {
223
+ if (!file.type.startsWith("image/")) return file;
224
+ try {
225
+ const { default: imageCompression } = await import("browser-image-compression");
226
+ const compressed = await imageCompression(file, {
227
+ maxSizeMB,
228
+ preserveExif: true,
229
+ useWebWorker: true
230
+ });
231
+ return compressed.name ? compressed : new File([compressed], file.name, { type: compressed.type || file.type });
232
+ } catch {
233
+ return file;
234
+ }
235
+ }
236
+ function simpleUpload(token, file, onProgress) {
237
+ const fd = new FormData();
238
+ fd.append("key", token.key);
239
+ fd.append("policy", token.policy);
240
+ fd.append("OSSAccessKeyId", token.accessid);
241
+ fd.append("signature", token.signature);
242
+ fd.append("success_action_status", "200");
243
+ fd.append("file", file);
244
+ return new Promise((resolve, reject) => {
245
+ const xhr = new XMLHttpRequest();
246
+ xhr.upload.onprogress = (e) => {
247
+ if (e.lengthComputable) onProgress?.(Math.round(e.loaded / e.total * 100));
248
+ };
249
+ xhr.onload = () => xhr.status === 200 ? resolve() : reject(/* @__PURE__ */ new Error(`OSS 上传失败: ${xhr.status}`));
250
+ xhr.onerror = () => reject(/* @__PURE__ */ new Error("OSS 上传失败"));
251
+ xhr.open("POST", token.host);
252
+ xhr.send(fd);
253
+ });
254
+ }
255
+ async function uploadPart(part) {
256
+ return withRetry(async () => {
257
+ const res = await fetch(part.url, {
258
+ body: part.blob,
259
+ headers: { "Content-Type": "application/octet-stream" },
260
+ method: "PUT"
261
+ });
262
+ if (!res.ok) throw new Error(`Failed to upload part ${part.partNumber}: ${res.status}`);
263
+ return {
264
+ etag: (res.headers.get("ETag") || "").replace(/"/g, ""),
265
+ part_number: part.partNumber
266
+ };
267
+ }, partRetries);
268
+ }
269
+ async function multipartUpload(file, md5, metadata, isPrivate, location, onProgress) {
270
+ const init = await initUpload(file.name, file.size, md5, chunkSize);
271
+ if (init.exists) {
272
+ onProgress?.(100);
273
+ return init.file;
274
+ }
275
+ if (init.mode !== "multipart") throw new Error("Unexpected upload mode");
276
+ const { upload_id: uid, key, uploaded_parts: done = [], uploaded_part_etags: existingParts = [], total_parts: total } = init;
277
+ const doneSet = new Set(done);
278
+ const pending = Array.from({ length: total }, (_, i) => i + 1).filter((n) => !doneSet.has(n));
279
+ const parts = [...existingParts];
280
+ let uploaded = 0;
281
+ if (pending.length > 0) {
282
+ const { urls } = await getPartUrls(key, uid, pending);
283
+ const urlMap = new Map(urls.map((u) => [u.part_number, u.url]));
284
+ const pool = new PromisePool(partConcurrency);
285
+ const tasks = pending.map((partNumber) => {
286
+ const url = urlMap.get(partNumber);
287
+ if (!url) throw new Error(`No URL for part ${partNumber}`);
288
+ const start = (partNumber - 1) * chunkSize;
289
+ const blob = file.slice(start, Math.min(start + chunkSize, file.size));
290
+ return () => uploadPart({
291
+ blob,
292
+ partNumber,
293
+ url
294
+ }).then((part) => {
295
+ parts.push(part);
296
+ uploaded += 1;
297
+ onProgress?.(Math.round(uploaded / pending.length * 100));
298
+ });
299
+ });
300
+ await Promise.all(tasks.map((t) => pool.run(t)));
301
+ }
302
+ parts.sort((a, b) => a.part_number - b.part_number);
303
+ onProgress?.(100);
304
+ return completeUpload({
305
+ file_name: file.name,
306
+ file_size: file.size,
307
+ is_private: isPrivate,
308
+ key,
309
+ md5,
310
+ parts,
311
+ upload_id: uid,
312
+ ...location ? {
313
+ lat: location.lat,
314
+ lng: location.lng
315
+ } : {},
316
+ metadata
317
+ });
318
+ }
319
+ /**
320
+ * 仅上传文件到 OSS,不调用 completeUpload(不创建 DB 记录)
321
+ */
322
+ async function uploadToOSS(file, options = {}) {
323
+ const { compress: shouldCompress = false, maxSizeMB, onProgress } = options;
324
+ let processed = file;
325
+ if (shouldCompress && file.type.startsWith("image/")) {
326
+ onProgress?.({
327
+ percent: 0,
328
+ stage: "compress"
329
+ });
330
+ processed = await compress(file, maxSizeMB);
331
+ onProgress?.({
332
+ percent: 100,
333
+ stage: "compress"
334
+ });
335
+ }
336
+ onProgress?.({
337
+ percent: 0,
338
+ stage: "md5"
339
+ });
340
+ const md5 = await hashBlob(processed);
341
+ onProgress?.({
342
+ percent: 100,
343
+ stage: "md5"
344
+ });
345
+ onProgress?.({
346
+ percent: 0,
347
+ stage: "upload"
348
+ });
349
+ const init = await initUpload(processed.name, processed.size, md5, processed.size >= multipartThreshold ? chunkSize : void 0);
350
+ if (init.exists) {
351
+ onProgress?.({
352
+ percent: 100,
353
+ stage: "complete"
354
+ });
355
+ return {
356
+ file_name: processed.name,
357
+ file_size: processed.size,
358
+ key: init.file.url.replace(RE_HOST_PREFIX, ""),
359
+ md5: init.file.file_md5
360
+ };
361
+ }
362
+ if (init.mode === "simple") {
363
+ await simpleUpload(init.token, processed, (pct) => onProgress?.({
364
+ percent: pct,
365
+ stage: "upload"
366
+ }));
367
+ onProgress?.({
368
+ percent: 100,
369
+ stage: "complete"
370
+ });
371
+ return {
372
+ file_name: processed.name,
373
+ file_size: processed.size,
374
+ key: init.token.key,
375
+ md5
376
+ };
377
+ }
378
+ const { upload_id: uid, key, uploaded_parts: done = [] } = init;
379
+ const doneSet = new Set(done);
380
+ const pending = Array.from({ length: init.total_parts }, (_, i) => i + 1).filter((n) => !doneSet.has(n));
381
+ const parts = [];
382
+ if (pending.length > 0) {
383
+ const { urls } = await getPartUrls(key, uid, pending);
384
+ const urlMap = new Map(urls.map((u) => [u.part_number, u.url]));
385
+ const pool = new PromisePool(partConcurrency);
386
+ const tasks = pending.map((partNumber) => {
387
+ const url = urlMap.get(partNumber);
388
+ if (!url) throw new Error(`No URL for part ${partNumber}`);
389
+ const start = (partNumber - 1) * chunkSize;
390
+ const blob = processed.slice(start, Math.min(start + chunkSize, processed.size));
391
+ return () => uploadPart({
392
+ blob,
393
+ partNumber,
394
+ url
395
+ });
396
+ });
397
+ const collected = await Promise.all(tasks.map((t) => pool.run(t)));
398
+ collected.sort((a, b) => a.part_number - b.part_number);
399
+ parts.push(...collected);
400
+ }
401
+ onProgress?.({
402
+ percent: 100,
403
+ stage: "complete"
404
+ });
405
+ return {
406
+ file_name: processed.name,
407
+ file_size: processed.size,
408
+ key,
409
+ md5,
410
+ parts,
411
+ upload_id: uid
412
+ };
413
+ }
414
+ /**
415
+ * 上传单个文件(完整流程:压缩 → 分析 → MD5 → OSS → complete)。
416
+ */
417
+ async function upload(file, options = {}) {
418
+ const { compress: shouldCompress = false, maxSizeMB, isPrivate, location, onProgress, analyze: shouldAnalyze = true, analysis: analysisOptions, precomputedAnalysis } = options;
419
+ let processed = file;
420
+ if (shouldCompress && file.type.startsWith("image/")) {
421
+ onProgress?.({
422
+ percent: 0,
423
+ stage: "compress"
424
+ });
425
+ processed = await compress(file, maxSizeMB);
426
+ onProgress?.({
427
+ percent: 100,
428
+ stage: "compress"
429
+ });
430
+ }
431
+ let metadata;
432
+ let md5;
433
+ if (precomputedAnalysis) {
434
+ metadata = precomputedAnalysis;
435
+ md5 = metadata.basic.md5;
436
+ if (!md5) throw new Error("预计算媒体分析结果缺少 MD5");
437
+ } else if (shouldAnalyze) {
438
+ onProgress?.({
439
+ percent: 0,
440
+ stage: "analyze"
441
+ });
442
+ metadata = await analyzeMedia(processed, {
443
+ ...analysisOptions,
444
+ onProgress: ({ stage, percent }) => {
445
+ if (stage === "md5") onProgress?.({
446
+ percent: Math.round(percent * .35),
447
+ stage: "analyze"
448
+ });
449
+ else if (stage === "decode") onProgress?.({
450
+ percent: 35 + Math.round(percent * .35),
451
+ stage: "analyze"
452
+ });
453
+ else onProgress?.({
454
+ percent: 70 + Math.round(percent * .3),
455
+ stage: "analyze"
456
+ });
457
+ }
458
+ });
459
+ md5 = metadata.basic.md5;
460
+ } else {
461
+ onProgress?.({
462
+ percent: 0,
463
+ stage: "md5"
464
+ });
465
+ md5 = await hashBlob(processed);
466
+ metadata = {
467
+ basic: {
468
+ extension: processed.name.includes(".") ? processed.name.slice(processed.name.lastIndexOf(".")) : "",
469
+ md5,
470
+ name: processed.name,
471
+ size: processed.size,
472
+ type: processed.type
473
+ },
474
+ schema_version: 1
475
+ };
476
+ }
477
+ if (!metadata) throw new Error("媒体 metadata 生成失败");
478
+ onProgress?.({
479
+ percent: 0,
480
+ stage: "upload"
481
+ });
482
+ const result = processed.size >= multipartThreshold ? await multipartUpload(processed, md5, metadata, isPrivate, location, (pct) => onProgress?.({
483
+ percent: pct,
484
+ stage: "upload"
485
+ })) : await simpleUploadWithComplete(processed, md5, metadata, isPrivate, location, (pct) => onProgress?.({
486
+ percent: pct,
487
+ stage: "upload"
488
+ }));
489
+ onProgress?.({
490
+ percent: 100,
491
+ stage: "complete"
492
+ });
493
+ return result;
494
+ }
495
+ async function simpleUploadWithComplete(file, md5, metadata, isPrivate, location, onProgress) {
496
+ onProgress?.(10);
497
+ const init = await initUpload(file.name, file.size, md5);
498
+ if (init.exists) {
499
+ onProgress?.(100);
500
+ return init.file;
501
+ }
502
+ if (init.mode !== "simple") throw new Error("Unexpected upload mode");
503
+ onProgress?.(30);
504
+ await simpleUpload(init.token, file, (pct) => onProgress?.(30 + Math.round(pct * .55)));
505
+ onProgress?.(90);
506
+ return completeUpload({
507
+ file_name: file.name,
508
+ file_size: file.size,
509
+ is_private: isPrivate,
510
+ key: init.token.key,
511
+ md5,
512
+ ...location ? {
513
+ lat: location.lat,
514
+ lng: location.lng
515
+ } : {},
516
+ metadata
517
+ });
518
+ }
519
+ async function associateLivePhotos(results) {
520
+ const associatedResults = [...results];
521
+ for (const { image, video } of detectLivePhotoPairs(results)) {
522
+ if (!(image && video)) continue;
523
+ try {
524
+ const updatedImage = await request(`/file/${image.sec_uid}`, "PUT", { live_photo_video_sec_uid: video.sec_uid });
525
+ const imageIndex = associatedResults.findIndex((item) => item.sec_uid === image.sec_uid);
526
+ if (imageIndex >= 0) associatedResults[imageIndex] = {
527
+ ...associatedResults[imageIndex],
528
+ ...updatedImage,
529
+ live_photo_video_sec_uid: video.sec_uid
530
+ };
531
+ } catch (e) {
532
+ console.error("实况照片关联失败:", e);
533
+ }
534
+ }
535
+ return associatedResults;
536
+ }
537
+ /**
538
+ * 批量上传(并发,带重试),上传完成后自动关联实况照片
539
+ */
540
+ async function uploadBatch(files, options = {}, concurrency = 2, maxRetries = 3) {
541
+ const pool = new PromisePool(concurrency);
542
+ const settled = await Promise.allSettled(files.map((file) => pool.run(async () => {
543
+ let lastErr = null;
544
+ for (let attempt = 0; attempt < maxRetries; attempt += 1) try {
545
+ return await upload(file, options);
546
+ } catch (e) {
547
+ lastErr = e;
548
+ if (attempt < maxRetries - 1) await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
549
+ }
550
+ throw lastErr;
551
+ })));
552
+ const successes = [];
553
+ for (const result of settled) if (result.status === "fulfilled") successes.push(result.value);
554
+ else console.error("文件上传失败:", result.reason);
555
+ return associateLivePhotos(successes);
556
+ }
557
+ return {
558
+ associateLivePhotos,
559
+ upload,
560
+ uploadBatch,
561
+ uploadToOSS
562
+ };
563
+ }
564
+ //#endregion
565
+ export { PromisePool, createOssUploader, detectLivePhotoPairs, fileParse, generateOssImageParams, getVideoThumbnailUrl, isLivePhoto, isVideo, parseFileName, withRetry };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@life-palette/uploader",
3
+ "version": "0.2.1",
4
+ "description": "OSS uploader for Life Palette: compress → analyze → multipart upload (concurrent, retryable) → complete. Browser-only, framework-agnostic.",
5
+ "homepage": "https://github.com/Life-Palette/packages/tree/main/packages/uploader#readme",
6
+ "bugs": "https://github.com/Life-Palette/packages/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Life-Palette/packages",
10
+ "directory": "packages/uploader"
11
+ },
12
+ "license": "MIT",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "import": {
18
+ "types": "./dist/index.d.mts",
19
+ "default": "./dist/index.mjs"
20
+ },
21
+ "require": {
22
+ "types": "./dist/index.d.cts",
23
+ "default": "./dist/index.cjs"
24
+ }
25
+ }
26
+ },
27
+ "main": "./dist/index.cjs",
28
+ "module": "./dist/index.mjs",
29
+ "types": "./dist/index.d.mts",
30
+ "files": [
31
+ "dist",
32
+ "README.md"
33
+ ],
34
+ "dependencies": {
35
+ "@life-palette/media": "0.2.1"
36
+ },
37
+ "devDependencies": {
38
+ "@vitest/coverage-v8": "^5.0.0",
39
+ "attw": "^1.0.0",
40
+ "browser-image-compression": "^2.0.2",
41
+ "publint": "^0.3.24",
42
+ "tsdown": "^0.23.0",
43
+ "vitest": "^5.0.0"
44
+ },
45
+ "peerDependencies": {
46
+ "browser-image-compression": "^2.0.2"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "browser-image-compression": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "publishConfig": {
54
+ "access": "public",
55
+ "registry": "https://registry.npmjs.org/"
56
+ },
57
+ "scripts": {
58
+ "build": "tsdown",
59
+ "dev": "vitest dev",
60
+ "test": "vitest run"
61
+ }
62
+ }