@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 +50 -0
- package/dist/index.cjs +575 -0
- package/dist/index.d.cts +195 -0
- package/dist/index.d.mts +195 -0
- package/dist/index.mjs +565 -0
- package/package.json +62 -0
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;
|