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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/pipeline.mjs +98 -137
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tphim",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
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",
package/pipeline.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
- // pipeline.mjs v2.2.2
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.2.2 FEATURES:
6
- // - Track and Display Total Execution Time (xxhxxmxxs)
7
- // - Dual Preview Sprites (High-Res + Low-Res) with FFmpeg Complex Filter
8
- // - Optimized CPU usage with single-pass processing
9
- // - Low-Res sprites optimized for mobile/preview (80x45 tiles, lighter file)
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('\nCách dùng:');
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
- // ─── BƯỚC 2: Transcode HLS ────────────────────────────────────
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 spritePath = path.join('hls', slug, 'preview.jpg');
426
- const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
429
+ const logPath = path.join('hls', slug, 'pipeline.log');
430
+ const outDir = path.join('hls', slug);
427
431
 
428
- // Detect VPS environment and optimize
429
- const vpsOpt = detectVPSOptimization();
430
- console.log(` 💻 System: ${IS_LINUX ? 'Linux' : 'Windows'} | CPU Cores: ${vpsOpt.cpuCores}`);
431
- if (vpsOpt.isVPS && vpsOpt.isUbuntu) {
432
- console.log(` 🚀 Ubuntu VPS Mode: Enabled (preset: ${vpsOpt.preset}, tune: ${vpsOpt.tune})`);
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' }, // Tăng bitrate cho 480p chút
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); // Sắp xếp cao -> thấp cho Master Playlist
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
- if (!existsSync(masterPath)) {
453
- console.log(`\n📦 [2/5] Transcoding HLS (${selectedQualities.length} qualities randomized)...`);
454
- console.log(` ↳ Selected: ${selectedQualities.map(q => q.label).join(', ')}`);
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
- const outDir = path.join('hls', slug);
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
- // [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}]`);
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
- selectedQualities.forEach((q, i) => {
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 ultrafast -tune fastdecode -b:v:${i} ${q.bv} -c:a:${i} aac -b:a:${i} ${q.ba} -movflags +faststart `;
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 timestamp = Date.now();
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
- '-master_pl_name', `"${masterName}"`,
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
- // --- BƯỚC 2.1: XUẤT 3GP CHO MÁY CỔ (NOKIA S40/S60) ---
496
- console.log(' [2.1/5] Exporting 3GP legacy format...');
497
- console.log(' 📱 [2.1/5] Exporting 3GP legacy format...');
498
- 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}"`;
499
- try {
500
- execSync(nokiaCmd, { stdio: 'ignore', ...SHELL_OPTS });
501
- console.log(' ✅ Legacy 3GP ready.');
502
- } catch (e) {
503
- console.log(' ⚠ 3GP generation failed.');
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
- // --- 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');
508
+ processedQualities.push(...batch);
509
+ updateMasterPlaylist(masterPath, processedQualities);
512
510
 
513
- const outDir = path.join('hls', slug);
511
+ // Upload Batch
512
+ log(` 🚀 Uploading Batch ${bIndex + 1} to Tebi...`);
513
+ await uploadHLSFolder(slug);
514
514
 
515
- // 1. New Storyboard format (L1, L2, L3)
516
- if (!existsSync(storyboardCheckPath)) {
517
- try {
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
- } catch (e) {
520
- console.log(' Storyboard generation failed:', e.message);
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
- // 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`);
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
- // ─── BƯỚC 3: Tạo README ───────────────────────────────────────
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('python --version', { stdio: 'ignore' });
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
- // ─── BƯỚC 5: Upload Tebi.io ───────────────────────────────────
579
- console.log('\n🚀 [5/5] Deploying to Quantum Cloud (Tebi.io)...');
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
- > **[DOWNLOAD 3GP](${nokiaUrl})**
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
- if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) || process.argv[1].endsWith('pipeline.mjs')) {
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);