tphim 2.2.2 → 2.3.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/package.json +3 -2
- package/pipeline.mjs +118 -15
- package/scripts/direct-process.mjs +150 -0
- package/scripts/upload-tebi.mjs +51 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tphim",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
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
|
@@ -133,7 +133,7 @@ function generateDualPreviewSprites(mp4Path, outputDir) {
|
|
|
133
133
|
const highResPath = path.join(outputDir, 'preview.jpg');
|
|
134
134
|
const lowResPath = path.join(outputDir, 'preview_low.jpg');
|
|
135
135
|
|
|
136
|
-
console.log(' 📸 [2.5/5] Generating Dual Preview Sprites (
|
|
136
|
+
console.log(' 📸 [2.5/5] Generating Dual Preview Sprites (Legacy format)...');
|
|
137
137
|
|
|
138
138
|
// Complex filter để tạo cả 2 phiên bản từ 1 luồng
|
|
139
139
|
const complexFilter = [
|
|
@@ -173,7 +173,7 @@ function generateDualPreviewSprites(mp4Path, outputDir) {
|
|
|
173
173
|
|
|
174
174
|
ffmpeg.on('close', (code) => {
|
|
175
175
|
if (code === 0) {
|
|
176
|
-
console.log(' ✅ Dual Preview Sprites ready
|
|
176
|
+
console.log(' ✅ Dual Preview Sprites ready.');
|
|
177
177
|
resolve({ highResPath, lowResPath });
|
|
178
178
|
} else {
|
|
179
179
|
console.error(' ⚠ Dual sprite generation failed:', stderr);
|
|
@@ -188,6 +188,60 @@ function generateDualPreviewSprites(mp4Path, outputDir) {
|
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// [TURBO MODE] Combine L1, L2, L3 into ONE single pass for 20x-50x speed
|
|
192
|
+
async function generateStoryboards(mp4Path, outputDir) {
|
|
193
|
+
console.log(` 🚀 [Turbo-Storyboard] Generating ALL levels in ONE pass from: ${path.basename(mp4Path)}`);
|
|
194
|
+
|
|
195
|
+
const args = [
|
|
196
|
+
'-skip_frame', 'nokey', // Essential for 20x speed
|
|
197
|
+
'-threads', '0',
|
|
198
|
+
'-an', '-sn', '-dn',
|
|
199
|
+
'-i', mp4Path,
|
|
200
|
+
'-filter_complex', [
|
|
201
|
+
// Split video into 3 branches for L1, L2, L3
|
|
202
|
+
'[0:v]split=3[v1][v2][v3]',
|
|
203
|
+
// L1: 160x90, 10s interval, 10x10 tile
|
|
204
|
+
'[v1]fps=1/10,scale=160:90,tile=10x10[out1]',
|
|
205
|
+
// L2: 240x135, 30s interval, 5x5 tile
|
|
206
|
+
'[v2]fps=1/30,scale=240:135,tile=5x5[out2]',
|
|
207
|
+
// L3: 320x180, 60s interval, 3x3 tile
|
|
208
|
+
'[v3]fps=1/60,scale=320:180,tile=3x3[out3]'
|
|
209
|
+
].join(';'),
|
|
210
|
+
'-map', '[out1]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L1_M%d.jpg'),
|
|
211
|
+
'-map', '[out2]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L2_M%d.jpg'),
|
|
212
|
+
'-map', '[out3]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L3_M%d.jpg')
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
await new Promise((resolve, reject) => {
|
|
216
|
+
const proc = spawn(FFMPEG_BIN, args, { shell: false });
|
|
217
|
+
// proc.stderr.on('data', d => console.log(d.toString())); // Debug speed
|
|
218
|
+
proc.on('close', code => code === 0 ? resolve() : reject(new Error(`Turbo-Storyboard failed with code ${code}`)));
|
|
219
|
+
proc.on('error', reject);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
console.log(' ✅ Turbo Storyboard ready (Pass 1/1).');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Function to find the highest resolution MP4 in a directory or a list of files
|
|
226
|
+
function selectHighestQualityVideo(files) {
|
|
227
|
+
if (!files || files.length === 0) return null;
|
|
228
|
+
|
|
229
|
+
// Sort by resolution labels in filename (YouTube style)
|
|
230
|
+
const resMap = { '4k': 2160, '1080p': 1080, '720p': 720, '480p': 480, '360p': 360, '240p': 240, '144p': 144 };
|
|
231
|
+
|
|
232
|
+
return files.sort((a, b) => {
|
|
233
|
+
const getRes = (name) => {
|
|
234
|
+
const m = name.match(/(\d{3,4}p|4[kK])/);
|
|
235
|
+
if (m) return resMap[m[1].toLowerCase()] || parseInt(m[1]);
|
|
236
|
+
return 0;
|
|
237
|
+
};
|
|
238
|
+
const resA = getRes(a);
|
|
239
|
+
const resB = getRes(b);
|
|
240
|
+
if (resA !== resB) return resB - resA;
|
|
241
|
+
return 0; // Fallback to original order
|
|
242
|
+
})[0];
|
|
243
|
+
}
|
|
244
|
+
|
|
191
245
|
function formatDuration(ms) {
|
|
192
246
|
const seconds = Math.floor((ms / 1000) % 60);
|
|
193
247
|
const minutes = Math.floor((ms / (1000 * 60)) % 60);
|
|
@@ -236,10 +290,41 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
236
290
|
|
|
237
291
|
let startTime = Date.now();
|
|
238
292
|
// ─── BƯỚC 1: Download ─────────────────────────────────────────
|
|
239
|
-
|
|
293
|
+
let mp4Path = existsSync(input) && input.toLowerCase().endsWith('.mp4')
|
|
240
294
|
? input
|
|
241
295
|
: path.join('downloads', `${slug}.mp4`);
|
|
242
296
|
|
|
297
|
+
if (!existsSync(mp4Path)) {
|
|
298
|
+
// If input is a directory, find the highest quality video
|
|
299
|
+
if (existsSync(input) && !input.toLowerCase().endsWith('.mp4')) {
|
|
300
|
+
const files = execSync(IS_WIN ? `dir /b "${input}"` : `ls "${input}"`, { encoding: 'utf8' })
|
|
301
|
+
.split(/\r?\n/)
|
|
302
|
+
.filter(f => f.toLowerCase().endsWith('.mp4'))
|
|
303
|
+
.map(f => path.join(input, f));
|
|
304
|
+
|
|
305
|
+
const bestFile = selectHighestQualityVideo(files);
|
|
306
|
+
if (bestFile) {
|
|
307
|
+
console.log(` ✨ Found multiple files in source. Selected highest quality: ${path.basename(bestFile)}`);
|
|
308
|
+
mp4Path = bestFile;
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
// Check downloads folder for any files matching slug (e.g. 1080p - slug.mp4)
|
|
312
|
+
const downloadsDir = 'downloads';
|
|
313
|
+
if (existsSync(downloadsDir)) {
|
|
314
|
+
const files = execSync(IS_WIN ? `dir /b "${downloadsDir}"` : `ls "${downloadsDir}"`, { encoding: 'utf8' })
|
|
315
|
+
.split(/\r?\n/)
|
|
316
|
+
.filter(f => f.toLowerCase().endsWith('.mp4') && f.toLowerCase().includes(slug.toLowerCase()))
|
|
317
|
+
.map(f => path.join(downloadsDir, f));
|
|
318
|
+
|
|
319
|
+
const bestFile = selectHighestQualityVideo(files);
|
|
320
|
+
if (bestFile) {
|
|
321
|
+
console.log(` ✨ Found existing files in downloads. Selected highest quality: ${path.basename(bestFile)}`);
|
|
322
|
+
mp4Path = bestFile;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
243
328
|
if (!existsSync(mp4Path)) {
|
|
244
329
|
console.log('\n🔽 [1/5] Downloading video...');
|
|
245
330
|
|
|
@@ -370,32 +455,36 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
370
455
|
|
|
371
456
|
const outDir = path.join('hls', slug);
|
|
372
457
|
|
|
373
|
-
//
|
|
374
|
-
|
|
458
|
+
// [TURBO HLS] Build dynamic filter chain for sequential scaling
|
|
459
|
+
// Scaling from previously scaled version (e.g. 1080 -> 720) is much faster
|
|
460
|
+
let filterParts = [`[0:v]split=${selectedQualities.length}`];
|
|
461
|
+
let splitLabels = selectedQualities.map((_, i) => `[v_raw${i}]`);
|
|
462
|
+
filterParts[0] += splitLabels.join('');
|
|
463
|
+
|
|
375
464
|
let outputs = '';
|
|
465
|
+
let maps = '';
|
|
376
466
|
let varStreamMapArr = [];
|
|
377
467
|
|
|
378
468
|
selectedQualities.forEach((q, i) => {
|
|
379
|
-
|
|
380
|
-
|
|
469
|
+
filterParts.push(`[v_raw${i}]scale=${q.w}:${q.h},format=yuv420p[v${i}]`);
|
|
470
|
+
maps += `-map "[v${i}]" -map 0:a `;
|
|
471
|
+
outputs += `-c:v:${i} libx264 -preset ultrafast -tune fastdecode -b:v:${i} ${q.bv} -c:a:${i} aac -b:a:${i} ${q.ba} -movflags +faststart `;
|
|
381
472
|
varStreamMapArr.push(`v:${i},a:${i}`);
|
|
382
473
|
});
|
|
383
474
|
|
|
384
|
-
// Generate encoded segment name with timestamp
|
|
385
475
|
const timestamp = Date.now();
|
|
386
476
|
const encodedPrefix = Buffer.from(`txa_${timestamp}`).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8);
|
|
387
|
-
|
|
388
|
-
// Optimize thread count based on CPU cores
|
|
389
|
-
const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores, 8) : 0; // 0 = auto, but limit on VPS
|
|
477
|
+
const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores * 2, 16) : 0; // Oversubscribe for maximum saturation
|
|
390
478
|
|
|
391
479
|
const ffmpegCmd = [
|
|
392
480
|
`"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`,
|
|
393
|
-
`-threads ${threadCount}`,
|
|
481
|
+
`-threads ${threadCount}`,
|
|
482
|
+
'-filter_complex', `"${filterParts.join(';')}"`,
|
|
394
483
|
maps,
|
|
395
484
|
outputs,
|
|
396
485
|
'-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
|
|
397
486
|
'-master_pl_name', `"${masterName}"`,
|
|
398
|
-
'-f hls -hls_time
|
|
487
|
+
'-f hls -hls_time 6 -hls_list_size 0 -hls_segment_type mpegts', // 6s segments are faster to write than 4s
|
|
399
488
|
'-hls_segment_filename', `"${outDir}/%v/${encodedPrefix}_%04d.ts"`,
|
|
400
489
|
`"${outDir}/%v/index.m3u8"`
|
|
401
490
|
].join(' ');
|
|
@@ -417,12 +506,26 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
417
506
|
console.log(`\n⏭ [2/5] HLS đã có sẵn`);
|
|
418
507
|
}
|
|
419
508
|
|
|
420
|
-
// --- STEP 2.5: PREVIEW SPRITE --- (Independent check)
|
|
509
|
+
// --- STEP 2.5: PREVIEW SPRITE & STORYBOARD --- (Independent check)
|
|
421
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
|
+
}
|
|
422
525
|
|
|
526
|
+
// 2. Legacy Dual Sprite format (for backward compatibility)
|
|
423
527
|
if (!existsSync(spritePath) || !existsSync(spriteLowPath)) {
|
|
424
528
|
try {
|
|
425
|
-
const outDir = path.join('hls', slug);
|
|
426
529
|
const { highResPath, lowResPath } = await generateDualPreviewSprites(mp4Path, outDir);
|
|
427
530
|
console.log(' ✅ Dual Preview Sprites generated successfully.');
|
|
428
531
|
} catch (e) {
|
|
@@ -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,57 +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
|
-
};
|
|
66
|
-
|
|
67
|
-
await s3.send(new PutObjectCommand({
|
|
68
|
-
Bucket: process.env.TEBI_BUCKET,
|
|
69
|
-
Key: s3Key,
|
|
70
|
-
Body: readFileSync(filePath),
|
|
71
|
-
ACL: 'public-read', // Mở khóa file công khai ngay khi upload
|
|
72
|
-
ContentType: lookup(filePath) || 'application/octet-stream',
|
|
73
|
-
CacheControl: cacheMap[ext] || 'public, max-age=3600',
|
|
74
|
-
}));
|
|
75
|
-
|
|
76
|
-
uploaded++;
|
|
77
|
-
uploadedFiles.push(s3Key);
|
|
78
|
-
|
|
79
|
-
// Save progress after each successful upload
|
|
80
|
-
writeFileSync(progressFile, JSON.stringify({
|
|
81
|
-
uploadedFiles,
|
|
82
|
-
totalFiles: files.length,
|
|
83
|
-
lastUpdate: new Date().toISOString()
|
|
84
|
-
}));
|
|
85
|
-
|
|
86
|
-
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)));
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
// Clean up progress file when complete
|