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