tphim 2.3.0 → 2.3.2
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/package.json +3 -2
- package/pipeline.mjs +41 -44
- package/scripts/direct-process.mjs +150 -0
- package/scripts/upload-tebi.mjs +51 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tphim",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"description": "TPHIM - Ultimate Video Pipeline: Download, Transcode HLS, AI Subtitles (with skip option), Resume Upload, and Cloud Upload.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"homepage": "https://film.nrotxa.online",
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@aws-sdk/client-s3": "^3.999.0",
|
|
52
|
+
"@aws-sdk/lib-storage": "^3.1008.0",
|
|
52
53
|
"@clack/prompts": "^1.0.1",
|
|
53
54
|
"chalk": "^5.6.2",
|
|
54
55
|
"cli-progress": "^3.12.0",
|
|
@@ -67,4 +68,4 @@
|
|
|
67
68
|
"engines": {
|
|
68
69
|
"node": ">=18.0.0"
|
|
69
70
|
}
|
|
70
|
-
}
|
|
71
|
+
}
|
package/pipeline.mjs
CHANGED
|
@@ -419,11 +419,10 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
419
419
|
console.log(`\n⏭ [1/5] File đã có sẵn: ${mp4Path}`);
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
-
//
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
|
|
422
|
+
// --- STEP 2: PREVIEW SPRITE & STORYBOARD (Fast-Track) ---
|
|
423
|
+
const spriteLowPath = path.join('hls', slug, 'preview_low.jpg');
|
|
424
|
+
const storyboardCheckPath = path.join('hls', slug, 'L1_M0.jpg');
|
|
425
|
+
const outDir = path.join('hls', slug);
|
|
427
426
|
|
|
428
427
|
// Detect VPS environment and optimize
|
|
429
428
|
const vpsOpt = detectVPSOptimization();
|
|
@@ -432,6 +431,41 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
432
431
|
console.log(` 🚀 Ubuntu VPS Mode: Enabled (preset: ${vpsOpt.preset}, tune: ${vpsOpt.tune})`);
|
|
433
432
|
}
|
|
434
433
|
|
|
434
|
+
console.log('\n📸 [2/5] Capturing Storyboards & Sprites (Turbo Mode)...');
|
|
435
|
+
|
|
436
|
+
// 1. New Storyboard format (L1, L2, L3)
|
|
437
|
+
if (!existsSync(storyboardCheckPath)) {
|
|
438
|
+
try {
|
|
439
|
+
await generateStoryboards(mp4Path, outDir);
|
|
440
|
+
} catch (e) {
|
|
441
|
+
console.log(' ⚠ Storyboard generation failed:', e.message);
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
console.log(` ⏭ Storyboard levels already exist`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 2. Legacy Dual Sprite format
|
|
448
|
+
if (!existsSync(spritePath) || !existsSync(spriteLowPath)) {
|
|
449
|
+
try {
|
|
450
|
+
await generateDualPreviewSprites(mp4Path, outDir);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
console.log(' ⚠ Dual sprite generation failed, falling back to single sprite...');
|
|
453
|
+
const spriteCmd = `"${FFMPEG_BIN}" -i "${mp4Path}" -vf "fps=1/10,scale=160:90,tile=10x10" -q:v 3 "${spritePath}"`;
|
|
454
|
+
try {
|
|
455
|
+
execSync(spriteCmd, { stdio: 'inherit', ...SHELL_OPTS });
|
|
456
|
+
console.log(' ✅ Fallback Preview SpriteSheet ready.');
|
|
457
|
+
} catch (fallbackError) {
|
|
458
|
+
console.log(' ⚠ All sprite generation methods failed.');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
console.log(` ⏭ Dual Preview Sprites already exist`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── BƯỚC 3: Transcode HLS ────────────────────────────────────
|
|
466
|
+
const masterPath = path.join('hls', slug, masterName);
|
|
467
|
+
const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
|
|
468
|
+
|
|
435
469
|
// Danh sách toàn bộ chất lượng hỗ trợ
|
|
436
470
|
const ALL_QUALITIES = [
|
|
437
471
|
{ label: '1080p', w: 1920, h: 1080, bv: '4500k', ba: '192k' },
|
|
@@ -442,15 +476,14 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
442
476
|
{ label: '144p', w: 256, h: 144, bv: '200k', ba: '48k' }
|
|
443
477
|
];
|
|
444
478
|
|
|
445
|
-
// Lấy ngẫu nhiên từ 4 đến 6 chất lượng
|
|
446
479
|
const count = Math.floor(Math.random() * (Math.min(6, ALL_QUALITIES.length) - 4 + 1)) + 4;
|
|
447
480
|
const selectedQualities = [...ALL_QUALITIES]
|
|
448
481
|
.sort(() => Math.random() - 0.5)
|
|
449
482
|
.slice(0, count)
|
|
450
|
-
.sort((a, b) => b.h - a.h);
|
|
483
|
+
.sort((a, b) => b.h - a.h);
|
|
451
484
|
|
|
452
485
|
if (!existsSync(masterPath)) {
|
|
453
|
-
console.log(`\n📦 [
|
|
486
|
+
console.log(`\n📦 [3/5] Transcoding HLS (${selectedQualities.length} qualities randomized)...`);
|
|
454
487
|
console.log(` ↳ Selected: ${selectedQualities.map(q => q.label).join(', ')}`);
|
|
455
488
|
|
|
456
489
|
const outDir = path.join('hls', slug);
|
|
@@ -506,42 +539,6 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
506
539
|
console.log(`\n⏭ [2/5] HLS đã có sẵn`);
|
|
507
540
|
}
|
|
508
541
|
|
|
509
|
-
// --- STEP 2.5: PREVIEW SPRITE & STORYBOARD --- (Independent check)
|
|
510
|
-
const spriteLowPath = path.join('hls', slug, 'preview_low.jpg');
|
|
511
|
-
const storyboardCheckPath = path.join('hls', slug, 'L1_M0.jpg');
|
|
512
|
-
|
|
513
|
-
const outDir = path.join('hls', slug);
|
|
514
|
-
|
|
515
|
-
// 1. New Storyboard format (L1, L2, L3)
|
|
516
|
-
if (!existsSync(storyboardCheckPath)) {
|
|
517
|
-
try {
|
|
518
|
-
await generateStoryboards(mp4Path, outDir);
|
|
519
|
-
} catch (e) {
|
|
520
|
-
console.log(' ⚠ Storyboard generation failed:', e.message);
|
|
521
|
-
}
|
|
522
|
-
} else {
|
|
523
|
-
console.log(` ⏭ Storyboard levels already exist`);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// 2. Legacy Dual Sprite format (for backward compatibility)
|
|
527
|
-
if (!existsSync(spritePath) || !existsSync(spriteLowPath)) {
|
|
528
|
-
try {
|
|
529
|
-
const { highResPath, lowResPath } = await generateDualPreviewSprites(mp4Path, outDir);
|
|
530
|
-
console.log(' ✅ Dual Preview Sprites generated successfully.');
|
|
531
|
-
} catch (e) {
|
|
532
|
-
console.log(' ⚠ Dual sprite generation failed, falling back to single sprite...');
|
|
533
|
-
// Fallback to original method
|
|
534
|
-
const spriteCmd = `"${FFMPEG_BIN}" -i "${mp4Path}" -vf "fps=1/10,scale=160:90,tile=10x10" -q:v 3 "${spritePath}"`;
|
|
535
|
-
try {
|
|
536
|
-
execSync(spriteCmd, { stdio: 'ignore', ...SHELL_OPTS });
|
|
537
|
-
console.log(' ✅ Fallback Preview SpriteSheet ready.');
|
|
538
|
-
} catch (fallbackError) {
|
|
539
|
-
console.log(' ⚠ All sprite generation methods failed.');
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
} else {
|
|
543
|
-
console.log(` ⏭ Dual Preview Sprites already exist`);
|
|
544
|
-
}
|
|
545
542
|
|
|
546
543
|
// ─── BƯỚC 3: Tạo README ───────────────────────────────────────
|
|
547
544
|
console.log('\n📄 [3/5] Generating README.txt...');
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
|
|
2
|
+
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
3
|
+
import { Upload } from "@aws-sdk/lib-storage";
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import 'dotenv/config';
|
|
8
|
+
import ffmpeg from 'ffmpeg-static';
|
|
9
|
+
import { lookup } from 'mime-types';
|
|
10
|
+
|
|
11
|
+
const FFMPEG_BIN = ffmpeg || 'ffmpeg';
|
|
12
|
+
const s3 = new S3Client({
|
|
13
|
+
region: 'us-east-1',
|
|
14
|
+
endpoint: process.env.TEBI_ENDPOINT,
|
|
15
|
+
credentials: {
|
|
16
|
+
accessKeyId: process.env.TEBI_ACCESS_KEY_ID,
|
|
17
|
+
secretAccessKey: process.env.TEBI_SECRET_ACCESS_KEY,
|
|
18
|
+
},
|
|
19
|
+
forcePathStyle: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const slug = "ninh-an-nhu-mong-direct";
|
|
23
|
+
const bucket = process.env.TEBI_BUCKET;
|
|
24
|
+
const desktop = "C:\\Users\\TXA3000\\Desktop";
|
|
25
|
+
|
|
26
|
+
const filesToUpload = [
|
|
27
|
+
{ local: path.join(desktop, "360p - NINH AN NHƯ MỘNG - TM.mp4"), remote: `movies/${slug}/360p.mp4` },
|
|
28
|
+
{ local: path.join(desktop, "144p - Ninh An Như Mộng - TM.mp4"), remote: `movies/${slug}/144p.mp4` },
|
|
29
|
+
{ local: path.join(desktop, "720p Thuyết Minh_Ninh An Như Mộng.mp4"), remote: `movies/${slug}/720p.mp4` }
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
async function uploadFile(localPath, remotePath) {
|
|
33
|
+
if (!fs.existsSync(localPath)) {
|
|
34
|
+
console.log(`\n⚠️ Skip: ${path.basename(localPath)} (Not found)`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
console.log(`\n📡 Uploading: ${path.basename(localPath)} -> ${remotePath}`);
|
|
38
|
+
const stream = fs.createReadStream(localPath);
|
|
39
|
+
try {
|
|
40
|
+
const uploader = new Upload({
|
|
41
|
+
client: s3,
|
|
42
|
+
params: {
|
|
43
|
+
Bucket: bucket,
|
|
44
|
+
Key: remotePath,
|
|
45
|
+
Body: stream,
|
|
46
|
+
ContentType: lookup(localPath) || 'video/mp4',
|
|
47
|
+
ACL: 'public-read',
|
|
48
|
+
},
|
|
49
|
+
queueSize: 4,
|
|
50
|
+
partSize: 5 * 1024 * 1024,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
uploader.on("httpUploadProgress", (progress) => {
|
|
54
|
+
const pct = Math.round((progress.loaded / (progress.total || 1)) * 100);
|
|
55
|
+
process.stdout.write(`\r ⚡ Progress: ${pct}% (${(progress.loaded / 1024 / 1024).toFixed(1)} MB)`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await uploader.done();
|
|
59
|
+
console.log(`\n✅ Done: ${path.basename(localPath)}`);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.error(`\n❌ Upload failed for ${path.basename(localPath)}:`, e.message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function captureStoryboards(inputPath, outputDir) {
|
|
66
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
67
|
+
console.log(`\n🚀 Capturing Storyboards from ${path.basename(inputPath)} (Turbo Mode)...`);
|
|
68
|
+
|
|
69
|
+
const args = [
|
|
70
|
+
'-skip_frame', 'nokey', '-threads', '0', '-i', inputPath,
|
|
71
|
+
'-filter_complex', [
|
|
72
|
+
'[0:v]split=3[v1][v2][v3]',
|
|
73
|
+
'[v1]fps=1/10,scale=160:90,tile=10x10[out1]',
|
|
74
|
+
'[v2]fps=1/30,scale=240:135,tile=5x5[out2]',
|
|
75
|
+
'[v3]fps=1/60,scale=320:180,tile=3x3[out3]'
|
|
76
|
+
].join(';'),
|
|
77
|
+
'-map', '[out1]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L1_M%d.jpg'),
|
|
78
|
+
'-map', '[out2]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L2_M%d.jpg'),
|
|
79
|
+
'-map', '[out3]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L3_M%d.jpg'),
|
|
80
|
+
'-y'
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const proc = spawn(FFMPEG_BIN, args, { shell: false });
|
|
85
|
+
proc.on('close', code => code === 0 ? resolve() : reject(new Error('FFmpeg failed')));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function uploadFolder(localDir, remotePrefix) {
|
|
90
|
+
console.log(`\n📡 Batch uploading storyboards...`);
|
|
91
|
+
const files = fs.readdirSync(localDir);
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
const fullPath = path.join(localDir, file);
|
|
94
|
+
if (fs.statSync(fullPath).isFile()) {
|
|
95
|
+
await uploadFile(fullPath, `${remotePrefix}/${file}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const reportPath = path.join(desktop, "NINH_AN_NHU_MONG_LINKS.txt");
|
|
102
|
+
try {
|
|
103
|
+
// 1. Upload MP4s
|
|
104
|
+
for (const f of filesToUpload) {
|
|
105
|
+
await uploadFile(f.local, f.remote);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Capture Storyboard (from 720p)
|
|
109
|
+
const hqFile = filesToUpload[2].local;
|
|
110
|
+
const storyboardDir = path.join(process.cwd(), 'temp_storyboard');
|
|
111
|
+
if (fs.existsSync(hqFile)) {
|
|
112
|
+
await captureStoryboards(hqFile, storyboardDir);
|
|
113
|
+
|
|
114
|
+
// 3. Upload Storyboards
|
|
115
|
+
await uploadFolder(storyboardDir, `movies/${slug}/storyboard`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. Generate Report
|
|
119
|
+
const report = `
|
|
120
|
+
========================================
|
|
121
|
+
REPORT: NINH AN NHƯ MỘNG DIRECT LINKS
|
|
122
|
+
========================================
|
|
123
|
+
|
|
124
|
+
🎬 MP4 DIRECT LINKS:
|
|
125
|
+
- 720p: https://s3.tebi.io/${bucket}/movies/${slug}/720p.mp4
|
|
126
|
+
- 360p: https://s3.tebi.io/${bucket}/movies/${slug}/360p.mp4
|
|
127
|
+
- 144p: https://s3.tebi.io/${bucket}/movies/${slug}/144p.mp4
|
|
128
|
+
|
|
129
|
+
📸 STORYBOARD LINKS (L1, L2, L3):
|
|
130
|
+
- Base Path: https://s3.tebi.io/${bucket}/movies/${slug}/storyboard/
|
|
131
|
+
- Example L1: https://s3.tebi.io/${bucket}/movies/${slug}/storyboard/L1_M0.jpg
|
|
132
|
+
- Example L2: https://s3.tebi.io/${bucket}/movies/${slug}/storyboard/L2_M0.jpg
|
|
133
|
+
- Example L3: https://s3.tebi.io/${bucket}/movies/${slug}/storyboard/L3_M0.jpg
|
|
134
|
+
|
|
135
|
+
🚀 Player usage:
|
|
136
|
+
Sử dụng link MP4 ở trên. Player sẽ tự động tìm
|
|
137
|
+
thông tin storyboard nếu được cập nhật theo TXA_AI_GUIDE.
|
|
138
|
+
========================================
|
|
139
|
+
`;
|
|
140
|
+
fs.writeFileSync(reportPath, report);
|
|
141
|
+
console.log(`\n✨ ALL DONE! Report saved to: ${reportPath}`);
|
|
142
|
+
|
|
143
|
+
// Cleanup
|
|
144
|
+
if (fs.existsSync(storyboardDir)) fs.rmSync(storyboardDir, { recursive: true, force: true });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error("❌ Error:", err);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
main();
|
package/scripts/upload-tebi.mjs
CHANGED
|
@@ -16,12 +16,14 @@ const s3 = new S3Client({
|
|
|
16
16
|
forcePathStyle: true, // Tebi cần pathStyle, không dùng subdomain
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
import { Upload } from "@aws-sdk/lib-storage";
|
|
20
|
+
import { createReadStream, statSync } from 'fs';
|
|
21
|
+
|
|
19
22
|
export async function uploadHLSFolder(slug) {
|
|
20
23
|
const localDir = join('hls', slug);
|
|
21
24
|
const files = getAllFiles(localDir);
|
|
22
25
|
const progressFile = join('hls', slug, '.upload-progress.json');
|
|
23
26
|
|
|
24
|
-
// Load existing progress
|
|
25
27
|
let uploadedFiles = [];
|
|
26
28
|
if (existsSync(progressFile)) {
|
|
27
29
|
try {
|
|
@@ -33,58 +35,66 @@ export async function uploadHLSFolder(slug) {
|
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
// Filter files that haven't been uploaded yet
|
|
37
38
|
const filesToUpload = files.filter(file => !uploadedFiles.includes(relative('.', file).replace(/\\/g, '/')));
|
|
38
39
|
|
|
39
|
-
console.log(`\n☁️ Uploading ${filesToUpload.length} files to Tebi.io
|
|
40
|
+
console.log(`\n☁️ Uploading ${filesToUpload.length} files to Tebi.io (Turbo Mode)...`);
|
|
40
41
|
let uploaded = uploadedFiles.length;
|
|
42
|
+
const total = files.length;
|
|
43
|
+
|
|
44
|
+
const cacheMap = {
|
|
45
|
+
'.ts': 'public, max-age=31536000',
|
|
46
|
+
'.jpg': 'public, max-age=31536000',
|
|
47
|
+
'.m3u8': 'public, max-age=300',
|
|
48
|
+
'.vtt': 'public, max-age=86400',
|
|
49
|
+
'.txt': 'no-cache',
|
|
50
|
+
};
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
// Hàm upload từng file đơn lẻ sử dụng lib-storage
|
|
53
|
+
const uploadSingleFile = async (filePath) => {
|
|
43
54
|
const s3Key = relative('.', filePath).replace(/\\/g, '/');
|
|
44
55
|
const ext = extname(filePath).toLowerCase();
|
|
45
|
-
|
|
46
|
-
// Check if file already exists on cloud
|
|
56
|
+
|
|
47
57
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
const stream = createReadStream(filePath);
|
|
59
|
+
const size = statSync(filePath).size;
|
|
60
|
+
|
|
61
|
+
const uploader = new Upload({
|
|
62
|
+
client: s3,
|
|
63
|
+
params: {
|
|
64
|
+
Bucket: process.env.TEBI_BUCKET,
|
|
65
|
+
Key: s3Key,
|
|
66
|
+
Body: stream,
|
|
67
|
+
ACL: 'public-read',
|
|
68
|
+
ContentType: lookup(filePath) || 'application/octet-stream',
|
|
69
|
+
CacheControl: cacheMap[ext] || 'public, max-age=3600',
|
|
70
|
+
},
|
|
71
|
+
queueSize: 1, // Sẽ điều khiển concurrency ở cấp độ folder
|
|
72
|
+
partSize: 5 * 1024 * 1024,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await uploader.done();
|
|
76
|
+
|
|
53
77
|
uploaded++;
|
|
54
78
|
uploadedFiles.push(s3Key);
|
|
55
|
-
|
|
79
|
+
|
|
80
|
+
// Lưu progress
|
|
81
|
+
writeFileSync(progressFile, JSON.stringify({
|
|
82
|
+
uploadedFiles,
|
|
83
|
+
totalFiles: total,
|
|
84
|
+
lastUpdate: new Date().toISOString()
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
process.stdout.write(`\r ↳ Progress: ${uploaded}/${total} files...`);
|
|
56
88
|
} catch (e) {
|
|
57
|
-
|
|
89
|
+
console.error(`\n❌ Lỗi upload ${s3Key}:`, e.message);
|
|
58
90
|
}
|
|
91
|
+
};
|
|
59
92
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
'.txt': 'no-cache',
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
await s3.send(new PutObjectCommand({
|
|
69
|
-
Bucket: process.env.TEBI_BUCKET,
|
|
70
|
-
Key: s3Key,
|
|
71
|
-
Body: readFileSync(filePath),
|
|
72
|
-
ACL: 'public-read', // Mở khóa file công khai ngay khi upload
|
|
73
|
-
ContentType: lookup(filePath) || 'application/octet-stream',
|
|
74
|
-
CacheControl: cacheMap[ext] || 'public, max-age=3600',
|
|
75
|
-
}));
|
|
76
|
-
|
|
77
|
-
uploaded++;
|
|
78
|
-
uploadedFiles.push(s3Key);
|
|
79
|
-
|
|
80
|
-
// Save progress after each successful upload
|
|
81
|
-
writeFileSync(progressFile, JSON.stringify({
|
|
82
|
-
uploadedFiles,
|
|
83
|
-
totalFiles: files.length,
|
|
84
|
-
lastUpdate: new Date().toISOString()
|
|
85
|
-
}));
|
|
86
|
-
|
|
87
|
-
process.stdout.write(`\r ↳ ${uploaded}/${files.length} files...`);
|
|
93
|
+
// Chạy song song với concurrency limit (ví dụ: 10 file cùng lúc)
|
|
94
|
+
const CONCURRENCY = 10;
|
|
95
|
+
for (let i = 0; i < filesToUpload.length; i += CONCURRENCY) {
|
|
96
|
+
const chunk = filesToUpload.slice(i, i + CONCURRENCY);
|
|
97
|
+
await Promise.all(chunk.map(file => uploadSingleFile(file)));
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
// Clean up progress file when complete
|