tphim 2.3.1 → 2.5.0
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 +1 -1
- package/pipeline.mjs +98 -137
package/package.json
CHANGED
package/pipeline.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// pipeline.mjs v2.
|
|
2
|
+
// pipeline.mjs v2.4.0
|
|
3
3
|
// Usage: node pipeline.mjs "URL_hoac_duong_dan_mp4" "slug" "Tên Phim" [vi|en|both|skip]
|
|
4
4
|
//
|
|
5
|
-
// v2.
|
|
6
|
-
// -
|
|
7
|
-
// -
|
|
8
|
-
// -
|
|
9
|
-
// -
|
|
5
|
+
// v2.4.0 FEATURES:
|
|
6
|
+
// - Batch HLS Transcoding (2 qualities at a time)
|
|
7
|
+
// - Incremental Uploads (Upload immediately after each batch)
|
|
8
|
+
// - Fast-Track Storyboards (Capture & upload after first batch)
|
|
9
|
+
// - Native HLS Upload Module integration
|
|
10
10
|
|
|
11
11
|
import { execSync, spawn } from 'child_process';
|
|
12
|
-
import { existsSync, mkdirSync, writeFileSync, rmSync, unlinkSync, readFileSync } from 'fs';
|
|
12
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, unlinkSync, readFileSync, appendFileSync } from 'fs';
|
|
13
13
|
import os from 'os';
|
|
14
14
|
import path, { dirname } from 'path';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
@@ -257,15 +257,26 @@ function formatDuration(ms) {
|
|
|
257
257
|
return parts.join('');
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
function updateMasterPlaylist(masterPath, processedQualities) {
|
|
261
|
+
let content = "#EXTM3U\n#EXT-X-VERSION:3\n";
|
|
262
|
+
// Sort high to low for better player compatibility
|
|
263
|
+
[...processedQualities].sort((a, b) => b.h - a.h).forEach(q => {
|
|
264
|
+
const bw = parseInt(q.bv) * 1000 + (parseInt(q.ba) || 128) * 1000;
|
|
265
|
+
content += `#EXT-X-STREAM-INF:BANDWIDTH=${bw},RESOLUTION=${q.w}x${q.h}\n`;
|
|
266
|
+
content += `${q.label}/index.m3u8\n`;
|
|
267
|
+
});
|
|
268
|
+
writeFileSync(masterPath, content);
|
|
269
|
+
}
|
|
270
|
+
|
|
260
271
|
export async function run() {
|
|
261
272
|
// Nếu chạy trực tiếp từ CLI
|
|
262
|
-
let [, , input, slug, title = slug, langArg = 'vi'] = process.argv;
|
|
273
|
+
let [, , input, slug, title = slug, langArg = 'vi', skip3gp = 'false'] = process.argv;
|
|
263
274
|
|
|
264
275
|
// Hỗ trợ truyền args vào function trong tương lai nếu cần
|
|
265
|
-
return await executePipeline({ input, slug, title, langArg });
|
|
276
|
+
return await executePipeline({ input, slug, title, langArg, skip3gp: skip3gp === 'true' });
|
|
266
277
|
}
|
|
267
278
|
|
|
268
|
-
export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
279
|
+
export async function executePipeline({ input, slug, title, langArg = 'vi', skip3gp = false }) {
|
|
269
280
|
// --- ENV SHIELD ---
|
|
270
281
|
const required = ['TEBI_ENDPOINT', 'TEBI_ACCESS_KEY_ID', 'TEBI_SECRET_ACCESS_KEY', 'TEBI_BUCKET'];
|
|
271
282
|
const missing = required.filter(k => !process.env[k]);
|
|
@@ -274,11 +285,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
if (!input || !slug) {
|
|
277
|
-
console.log('
|
|
278
|
-
console.log(' node pipeline.mjs "URL_video" "slug" "Tên Phim" vi');
|
|
279
|
-
console.log(' node pipeline.mjs "D:\\film.mp4" "slug" "Tên Phim" vi');
|
|
280
|
-
console.log(' node pipeline.mjs "URL_video" "slug" "Tên Phim" both');
|
|
281
|
-
console.log(' node pipeline.mjs "URL_video" "slug" "Tên Phim" skip');
|
|
288
|
+
console.log(' node pipeline.mjs "URL_video" "slug" "Tên Phim" vi true (skip 3GP)');
|
|
282
289
|
return;
|
|
283
290
|
}
|
|
284
291
|
console.log('\n╔══════════════════════════════════════════════════════════════════╗');
|
|
@@ -408,10 +415,6 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
408
415
|
throw new Error(
|
|
409
416
|
`❌ Download thất bại: ${input}\n` +
|
|
410
417
|
` Đã thử: direct + ${Math.min(5, PROXY_LIST.length)} proxies\n` +
|
|
411
|
-
` Nguyên nhân phổ biến:\n` +
|
|
412
|
-
` • Link đã hết hạn hoặc bị xóa (HTTP 404)\n` +
|
|
413
|
-
` • Server CDN chặn tất cả IP\n` +
|
|
414
|
-
` • Link cần cookie/token đặc biệt\n` +
|
|
415
418
|
` → Hãy thử mở link trong trình duyệt để kiểm tra.`
|
|
416
419
|
);
|
|
417
420
|
}
|
|
@@ -419,63 +422,64 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
419
422
|
console.log(`\n⏭ [1/5] File đã có sẵn: ${mp4Path}`);
|
|
420
423
|
}
|
|
421
424
|
|
|
422
|
-
//
|
|
425
|
+
// --- BATCH PROCESSING LOGIC ---
|
|
426
|
+
const vpsOpt = detectVPSOptimization();
|
|
423
427
|
const masterName = `txa-${slug}.m3u8`;
|
|
424
428
|
const masterPath = path.join('hls', slug, masterName);
|
|
425
|
-
const
|
|
426
|
-
const
|
|
429
|
+
const logPath = path.join('hls', slug, 'pipeline.log');
|
|
430
|
+
const outDir = path.join('hls', slug);
|
|
427
431
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
432
|
+
const log = (msg) => {
|
|
433
|
+
console.log(msg);
|
|
434
|
+
appendFileSync(logPath, msg + '\n');
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
writeFileSync(logPath, `🎬 Log for ${title} - ${new Date().toLocaleString()}\n\n`);
|
|
434
438
|
|
|
435
|
-
// Danh sách toàn bộ chất lượng hỗ trợ
|
|
436
439
|
const ALL_QUALITIES = [
|
|
437
440
|
{ label: '1080p', w: 1920, h: 1080, bv: '4500k', ba: '192k' },
|
|
438
441
|
{ label: '720p', w: 1280, h: 720, bv: '1500k', ba: '128k' },
|
|
439
|
-
{ label: '480p', w: 854, h: 480, bv: '1000k', ba: '128k' },
|
|
442
|
+
{ label: '480p', w: 854, h: 480, bv: '1000k', ba: '128k' },
|
|
440
443
|
{ label: '360p', w: 640, h: 360, bv: '600k', ba: '96k' },
|
|
441
444
|
{ label: '240p', w: 426, h: 240, bv: '400k', ba: '64k' },
|
|
442
445
|
{ label: '144p', w: 256, h: 144, bv: '200k', ba: '48k' }
|
|
443
446
|
];
|
|
444
447
|
|
|
445
|
-
// Lấy ngẫu nhiên từ 4 đến 6 chất lượng
|
|
446
448
|
const count = Math.floor(Math.random() * (Math.min(6, ALL_QUALITIES.length) - 4 + 1)) + 4;
|
|
447
449
|
const selectedQualities = [...ALL_QUALITIES]
|
|
448
450
|
.sort(() => Math.random() - 0.5)
|
|
449
451
|
.slice(0, count)
|
|
450
|
-
.sort((a, b) => b.h - a.h);
|
|
452
|
+
.sort((a, b) => b.h - a.h);
|
|
453
|
+
|
|
454
|
+
const batches = [];
|
|
455
|
+
for (let i = 0; i < selectedQualities.length; i += 2) {
|
|
456
|
+
batches.push(selectedQualities.slice(i, i + 2));
|
|
457
|
+
}
|
|
451
458
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
459
|
+
let processedQualities = [];
|
|
460
|
+
const timestamp = Date.now();
|
|
461
|
+
const encodedPrefix = Buffer.from(`txa_${timestamp}`).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8);
|
|
455
462
|
|
|
456
|
-
|
|
463
|
+
for (let bIndex = 0; bIndex < batches.length; bIndex++) {
|
|
464
|
+
const batch = batches[bIndex];
|
|
465
|
+
log(`\n📦 [Batch ${bIndex + 1}/${batches.length}] Processing: ${batch.map(q => q.label).join(', ')}`);
|
|
457
466
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
let filterParts = [`[0:v]split=${selectedQualities.length}`];
|
|
461
|
-
let splitLabels = selectedQualities.map((_, i) => `[v_raw${i}]`);
|
|
467
|
+
let filterParts = [`[0:v]split=${batch.length}`];
|
|
468
|
+
let splitLabels = batch.map((_, i) => `[v_raw${i}]`);
|
|
462
469
|
filterParts[0] += splitLabels.join('');
|
|
463
470
|
|
|
464
471
|
let outputs = '';
|
|
465
472
|
let maps = '';
|
|
466
473
|
let varStreamMapArr = [];
|
|
467
474
|
|
|
468
|
-
|
|
475
|
+
batch.forEach((q, i) => {
|
|
469
476
|
filterParts.push(`[v_raw${i}]scale=${q.w}:${q.h},format=yuv420p[v${i}]`);
|
|
470
477
|
maps += `-map "[v${i}]" -map 0:a `;
|
|
471
|
-
outputs += `-c:v:${i} libx264 -preset
|
|
478
|
+
outputs += `-c:v:${i} libx264 -preset ${vpsOpt.preset} -tune ${vpsOpt.tune} -b:v:${i} ${q.bv} -c:a:${i} aac -b:a:${i} ${q.ba} -movflags +faststart `;
|
|
472
479
|
varStreamMapArr.push(`v:${i},a:${i}`);
|
|
473
480
|
});
|
|
474
481
|
|
|
475
|
-
const
|
|
476
|
-
const encodedPrefix = Buffer.from(`txa_${timestamp}`).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8);
|
|
477
|
-
const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores * 2, 16) : 0; // Oversubscribe for maximum saturation
|
|
478
|
-
|
|
482
|
+
const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores * 2, 16) : 0;
|
|
479
483
|
const ffmpegCmd = [
|
|
480
484
|
`"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`,
|
|
481
485
|
`-threads ${threadCount}`,
|
|
@@ -483,126 +487,78 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
483
487
|
maps,
|
|
484
488
|
outputs,
|
|
485
489
|
'-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
|
|
486
|
-
'-
|
|
487
|
-
'-f hls -hls_time 6 -hls_list_size 0 -hls_segment_type mpegts', // 6s segments are faster to write than 4s
|
|
490
|
+
'-f hls -hls_time 6 -hls_list_size 0 -hls_segment_type mpegts',
|
|
488
491
|
'-hls_segment_filename', `"${outDir}/%v/${encodedPrefix}_%04d.ts"`,
|
|
489
492
|
`"${outDir}/%v/index.m3u8"`
|
|
490
493
|
].join(' ');
|
|
491
494
|
|
|
495
|
+
log(` 🛠 Running FFmpeg Transcoding...`);
|
|
492
496
|
execSync(ffmpegCmd, { stdio: 'inherit', ...SHELL_OPTS });
|
|
493
|
-
console.log(' HLS Master generated.');
|
|
494
497
|
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
} else {
|
|
506
|
-
console.log(`\n⏭ [2/5] HLS đã có sẵn`);
|
|
507
|
-
}
|
|
498
|
+
// Rename folders from index (0, 1) to labels
|
|
499
|
+
batch.forEach((q, i) => {
|
|
500
|
+
const oldFolder = path.join(outDir, i.toString());
|
|
501
|
+
const newFolder = path.join(outDir, q.label);
|
|
502
|
+
if (existsSync(oldFolder)) {
|
|
503
|
+
if (existsSync(newFolder)) rmSync(newFolder, { recursive: true, force: true });
|
|
504
|
+
execSync(IS_WIN ? `move "${oldFolder}" "${newFolder}"` : `mv "${oldFolder}" "${newFolder}"`, SHELL_OPTS);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
508
507
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const storyboardCheckPath = path.join('hls', slug, 'L1_M0.jpg');
|
|
508
|
+
processedQualities.push(...batch);
|
|
509
|
+
updateMasterPlaylist(masterPath, processedQualities);
|
|
512
510
|
|
|
513
|
-
|
|
511
|
+
// Upload Batch
|
|
512
|
+
log(` 🚀 Uploading Batch ${bIndex + 1} to Tebi...`);
|
|
513
|
+
await uploadHLSFolder(slug);
|
|
514
514
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
515
|
+
// After Batch 1, capture and upload storyboards
|
|
516
|
+
if (bIndex === 0) {
|
|
517
|
+
log(`\n📸 [Fast-Track] First Batch Uploaded. Generating Storyboards...`);
|
|
518
518
|
await generateStoryboards(mp4Path, outDir);
|
|
519
|
-
|
|
520
|
-
|
|
519
|
+
await generateDualPreviewSprites(mp4Path, outDir);
|
|
520
|
+
log(` 🚀 Uploading Preview Assets...`);
|
|
521
|
+
await uploadHLSFolder(slug);
|
|
521
522
|
}
|
|
522
|
-
} else {
|
|
523
|
-
console.log(` ⏭ Storyboard levels already exist`);
|
|
524
523
|
}
|
|
525
524
|
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
} catch (e) {
|
|
532
|
-
|
|
533
|
-
|
|
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`);
|
|
525
|
+
// --- STEP 3: LEGACY & DOCS ---
|
|
526
|
+
const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
|
|
527
|
+
if (!skip3gp && !existsSync(nokia3gpPath)) {
|
|
528
|
+
log('\n📱 Exporting legacy 3GP format...');
|
|
529
|
+
const nokiaCmd = `"${FFMPEG_BIN}" -i "${mp4Path}" -s 320x240 -vcodec mpeg4 -acodec aac -ar 16000 -ac 1 -b:v 250k -b:a 32k -preset ${vpsOpt.preset} -f 3gp "${nokia3gpPath}"`;
|
|
530
|
+
try { execSync(nokiaCmd, { stdio: 'ignore', ...SHELL_OPTS }); } catch (e) { log(' ⚠ 3GP failed.'); }
|
|
531
|
+
} else if (skip3gp) {
|
|
532
|
+
log('\n⏭ Skipping legacy 3GP export (skip3gp=true)');
|
|
544
533
|
}
|
|
545
534
|
|
|
546
|
-
|
|
547
|
-
console.log('\n📄 [3/5] Generating README.txt...');
|
|
535
|
+
log('\n📄 Generating README and Subtitles...');
|
|
548
536
|
generateReadme(slug, mp4Path, { title });
|
|
549
537
|
|
|
550
|
-
// ─── BƯỚC 4: Tạo Subtitle (Python faster-whisper) ─────────────
|
|
551
538
|
if (langArg !== 'skip') {
|
|
552
|
-
console.log('\n📝 [4/5] Generating subtitles (faster-whisper offline)...');
|
|
553
539
|
const langs = langArg === 'both' ? ['vi', 'en'] : [langArg];
|
|
554
|
-
|
|
555
540
|
for (const lang of langs) {
|
|
556
541
|
const vttPath = path.join('hls', slug, `${lang}.vtt`);
|
|
557
542
|
if (!existsSync(vttPath)) {
|
|
558
|
-
// Detection logic for python command (python vs python3)
|
|
559
543
|
let pythonCmd = 'python';
|
|
544
|
+
try { execSync('python --version', { stdio: 'ignore' }); } catch (e) { pythonCmd = 'python3'; }
|
|
560
545
|
try {
|
|
561
|
-
execSync(
|
|
562
|
-
} catch (e) {
|
|
563
|
-
pythonCmd = 'python3';
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
execSync(
|
|
567
|
-
`${pythonCmd} ${path.join(__dirname, 'scripts', 'gen_subtitle.py')} "${mp4Path}" "${slug}" ${lang} "${FFMPEG_BIN}"`,
|
|
568
|
-
{ stdio: 'inherit', cwd: process.cwd() }
|
|
569
|
-
);
|
|
570
|
-
} else {
|
|
571
|
-
console.log(` ⏭ Subtitle [${lang}] đã có sẵn`);
|
|
546
|
+
execSync(`${pythonCmd} ${path.join(__dirname, 'scripts', 'gen_subtitle.py')} "${mp4Path}" "${slug}" ${lang} "${FFMPEG_BIN}"`, { stdio: 'inherit', cwd: process.cwd() });
|
|
547
|
+
} catch (e) { log(` ⚠ Subtitle [${lang}] failed.`); }
|
|
572
548
|
}
|
|
573
549
|
}
|
|
574
|
-
} else {
|
|
575
|
-
console.log('\n⏭ [4/5] Skipping subtitle generation (selected "skip" option)');
|
|
576
550
|
}
|
|
577
551
|
|
|
578
|
-
//
|
|
579
|
-
|
|
552
|
+
// Final Sync Upload
|
|
553
|
+
log('\n🚀 Final sync upload...');
|
|
554
|
+
await uploadHLSFolder(slug);
|
|
580
555
|
|
|
581
|
-
// Optimize upload with parallel processing
|
|
582
|
-
const uploadCmd = `node ${path.join(__dirname, 'scripts', 'upload-tebi.mjs')} "${slug}"`;
|
|
583
|
-
|
|
584
|
-
// Run upload with higher process priority and optimized settings
|
|
585
|
-
try {
|
|
586
|
-
execSync(uploadCmd, {
|
|
587
|
-
stdio: 'inherit',
|
|
588
|
-
cwd: process.cwd(),
|
|
589
|
-
maxBuffer: 1024 * 1024, // Increase buffer size
|
|
590
|
-
env: {
|
|
591
|
-
...process.env,
|
|
592
|
-
NODE_OPTIONS: '--max-old-space-size=4096', // Optimize Node.js memory
|
|
593
|
-
UV_THREADPOOL_SIZE: '16' // Increase libuv thread pool
|
|
594
|
-
}
|
|
595
|
-
});
|
|
596
|
-
} catch (uploadError) {
|
|
597
|
-
console.error(' ⚠ Upload failed:', uploadError.message);
|
|
598
|
-
throw uploadError;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// --- FINAL STEP: GENERATE MASTER README.md ---
|
|
602
|
-
const finalReadmePath = path.join('hls', slug, 'README.md');
|
|
603
556
|
const bucketName = process.env.TEBI_BUCKET || "txa-vod";
|
|
604
557
|
const cloudUrl = `https://s3.tebi.io/${bucketName}/hls/${slug}/${masterName}`;
|
|
558
|
+
const finalReadmePath = path.join('hls', slug, 'README.md');
|
|
605
559
|
const nokiaUrl = `https://s3.tebi.io/${bucketName}/hls/${slug}/${slug}.3gp`;
|
|
560
|
+
const spritePath = path.join('hls', slug, 'preview.jpg');
|
|
561
|
+
const spriteLowPath = path.join('hls', slug, 'preview_low.jpg');
|
|
606
562
|
|
|
607
563
|
const report = `
|
|
608
564
|
# 🎬 TXA PIPELINE REPORT: ${title}
|
|
@@ -612,7 +568,7 @@ Generated on: ${new Date().toLocaleString()}
|
|
|
612
568
|
- [x] **Total Execution Time:** ${formatDuration(Date.now() - startTime)}
|
|
613
569
|
- [x] **Master Playlist:** \`${masterName}\` (AUTO Quality)
|
|
614
570
|
- [x] **Video Qualities:** ${selectedQualities.map(q => q.label).join(', ')} (Randomized)
|
|
615
|
-
- [${existsSync(nokia3gpPath) ? 'x' : ' '}] **Legacy Format:** \`${slug}.3gp\` (Nokia S40/S60)
|
|
571
|
+
- [${skip3gp ? 'skip' : (existsSync(nokia3gpPath) ? 'x' : ' ')}] **Legacy Format:** \`${slug}.3gp\` (Nokia S40/S60)
|
|
616
572
|
- [${existsSync(spritePath) ? 'x' : ' '}] **High-Res Preview SpriteSheet:** \`preview.jpg\` (160x90 tiles, 10x10 grid)
|
|
617
573
|
- [${existsSync(spriteLowPath) ? 'x' : ' '}] **Low-Res Preview SpriteSheet:** \`preview_low.jpg\` (80x45 tiles, 10x10 grid)
|
|
618
574
|
- [${existsSync(path.join('hls', slug, 'vi.vtt')) ? 'x' : ' '}] **Vietnamese Subtitles:** AI Generated
|
|
@@ -622,7 +578,7 @@ Generated on: ${new Date().toLocaleString()}
|
|
|
622
578
|
> **[WATCH NOW (HLS)](${cloudUrl})**
|
|
623
579
|
|
|
624
580
|
## 📱 Legacy Mobile Link (Nokia/Java)
|
|
625
|
-
>
|
|
581
|
+
> ${skip3gp ? '*Disabled by user*' : `**[DOWNLOAD 3GP](${nokiaUrl})**`}
|
|
626
582
|
|
|
627
583
|
## 🖼️ Preview Sprites Information
|
|
628
584
|
- **High-Res Version:** \`preview.jpg\` - Full quality for desktop/fast connections
|
|
@@ -657,7 +613,12 @@ Generated on: ${new Date().toLocaleString()}
|
|
|
657
613
|
}
|
|
658
614
|
|
|
659
615
|
// Tự khởi chạy nếu được gọi trực tiếp qua CLI
|
|
660
|
-
|
|
616
|
+
const isCLI = process.argv[1] && (
|
|
617
|
+
import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) ||
|
|
618
|
+
process.argv[1].endsWith('pipeline.mjs')
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
if (isCLI) {
|
|
661
622
|
run().catch(err => {
|
|
662
623
|
console.error('\n❌ LỖI:', err.message);
|
|
663
624
|
process.exit(1);
|