tphim 2.3.2 → 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 +102 -138
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tphim",
3
- "version": "2.3.2",
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,58 +422,24 @@ 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
- // --- 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);
426
-
427
- // Detect VPS environment and optimize
425
+ // --- BATCH PROCESSING LOGIC ---
428
426
  const vpsOpt = detectVPSOptimization();
429
- console.log(` 💻 System: ${IS_LINUX ? 'Linux' : 'Windows'} | CPU Cores: ${vpsOpt.cpuCores}`);
430
- if (vpsOpt.isVPS && vpsOpt.isUbuntu) {
431
- console.log(` 🚀 Ubuntu VPS Mode: Enabled (preset: ${vpsOpt.preset}, tune: ${vpsOpt.tune})`);
432
- }
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
- }
427
+ const masterName = `txa-${slug}.m3u8`;
428
+ const masterPath = path.join('hls', slug, masterName);
429
+ const logPath = path.join('hls', slug, 'pipeline.log');
430
+ const outDir = path.join('hls', slug);
446
431
 
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
- }
432
+ const log = (msg) => {
433
+ console.log(msg);
434
+ appendFileSync(logPath, msg + '\n');
435
+ };
464
436
 
465
- // ─── BƯỚC 3: Transcode HLS ────────────────────────────────────
466
- const masterPath = path.join('hls', slug, masterName);
467
- const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
437
+ writeFileSync(logPath, `🎬 Log for ${title} - ${new Date().toLocaleString()}\n\n`);
468
438
 
469
- // Danh sách toàn bộ chất lượng hỗ trợ
470
439
  const ALL_QUALITIES = [
471
440
  { label: '1080p', w: 1920, h: 1080, bv: '4500k', ba: '192k' },
472
441
  { label: '720p', w: 1280, h: 720, bv: '1500k', ba: '128k' },
473
- { 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' },
474
443
  { label: '360p', w: 640, h: 360, bv: '600k', ba: '96k' },
475
444
  { label: '240p', w: 426, h: 240, bv: '400k', ba: '64k' },
476
445
  { label: '144p', w: 256, h: 144, bv: '200k', ba: '48k' }
@@ -482,33 +451,35 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
482
451
  .slice(0, count)
483
452
  .sort((a, b) => b.h - a.h);
484
453
 
485
- if (!existsSync(masterPath)) {
486
- console.log(`\n📦 [3/5] Transcoding HLS (${selectedQualities.length} qualities randomized)...`);
487
- console.log(` ↳ Selected: ${selectedQualities.map(q => q.label).join(', ')}`);
454
+ const batches = [];
455
+ for (let i = 0; i < selectedQualities.length; i += 2) {
456
+ batches.push(selectedQualities.slice(i, i + 2));
457
+ }
488
458
 
489
- const outDir = path.join('hls', slug);
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);
490
462
 
491
- // [TURBO HLS] Build dynamic filter chain for sequential scaling
492
- // Scaling from previously scaled version (e.g. 1080 -> 720) is much faster
493
- let filterParts = [`[0:v]split=${selectedQualities.length}`];
494
- let splitLabels = selectedQualities.map((_, i) => `[v_raw${i}]`);
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(', ')}`);
466
+
467
+ let filterParts = [`[0:v]split=${batch.length}`];
468
+ let splitLabels = batch.map((_, i) => `[v_raw${i}]`);
495
469
  filterParts[0] += splitLabels.join('');
496
470
 
497
471
  let outputs = '';
498
472
  let maps = '';
499
473
  let varStreamMapArr = [];
500
474
 
501
- selectedQualities.forEach((q, i) => {
475
+ batch.forEach((q, i) => {
502
476
  filterParts.push(`[v_raw${i}]scale=${q.w}:${q.h},format=yuv420p[v${i}]`);
503
477
  maps += `-map "[v${i}]" -map 0:a `;
504
- 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 `;
505
479
  varStreamMapArr.push(`v:${i},a:${i}`);
506
480
  });
507
481
 
508
- const timestamp = Date.now();
509
- const encodedPrefix = Buffer.from(`txa_${timestamp}`).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8);
510
- const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores * 2, 16) : 0; // Oversubscribe for maximum saturation
511
-
482
+ const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores * 2, 16) : 0;
512
483
  const ffmpegCmd = [
513
484
  `"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`,
514
485
  `-threads ${threadCount}`,
@@ -516,90 +487,78 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
516
487
  maps,
517
488
  outputs,
518
489
  '-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
519
- '-master_pl_name', `"${masterName}"`,
520
- '-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',
521
491
  '-hls_segment_filename', `"${outDir}/%v/${encodedPrefix}_%04d.ts"`,
522
492
  `"${outDir}/%v/index.m3u8"`
523
493
  ].join(' ');
524
494
 
495
+ log(` 🛠 Running FFmpeg Transcoding...`);
525
496
  execSync(ffmpegCmd, { stdio: 'inherit', ...SHELL_OPTS });
526
- console.log(' HLS Master generated.');
527
497
 
528
- // --- BƯỚC 2.1: XUẤT 3GP CHO MÁY CỔ (NOKIA S40/S60) ---
529
- console.log(' [2.1/5] Exporting 3GP legacy format...');
530
- console.log(' 📱 [2.1/5] Exporting 3GP legacy format...');
531
- 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}"`;
532
- try {
533
- execSync(nokiaCmd, { stdio: 'ignore', ...SHELL_OPTS });
534
- console.log(' ✅ Legacy 3GP ready.');
535
- } catch (e) {
536
- console.log(' ⚠ 3GP generation failed.');
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
+ });
507
+
508
+ processedQualities.push(...batch);
509
+ updateMasterPlaylist(masterPath, processedQualities);
510
+
511
+ // Upload Batch
512
+ log(` 🚀 Uploading Batch ${bIndex + 1} to Tebi...`);
513
+ await uploadHLSFolder(slug);
514
+
515
+ // After Batch 1, capture and upload storyboards
516
+ if (bIndex === 0) {
517
+ log(`\n📸 [Fast-Track] First Batch Uploaded. Generating Storyboards...`);
518
+ await generateStoryboards(mp4Path, outDir);
519
+ await generateDualPreviewSprites(mp4Path, outDir);
520
+ log(` 🚀 Uploading Preview Assets...`);
521
+ await uploadHLSFolder(slug);
537
522
  }
538
- } else {
539
- console.log(`\n⏭ [2/5] HLS đã có sẵn`);
540
523
  }
541
524
 
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)');
533
+ }
542
534
 
543
- // ─── BƯỚC 3: Tạo README ───────────────────────────────────────
544
- console.log('\n📄 [3/5] Generating README.txt...');
535
+ log('\n📄 Generating README and Subtitles...');
545
536
  generateReadme(slug, mp4Path, { title });
546
537
 
547
- // ─── BƯỚC 4: Tạo Subtitle (Python faster-whisper) ─────────────
548
538
  if (langArg !== 'skip') {
549
- console.log('\n📝 [4/5] Generating subtitles (faster-whisper offline)...');
550
539
  const langs = langArg === 'both' ? ['vi', 'en'] : [langArg];
551
-
552
540
  for (const lang of langs) {
553
541
  const vttPath = path.join('hls', slug, `${lang}.vtt`);
554
542
  if (!existsSync(vttPath)) {
555
- // Detection logic for python command (python vs python3)
556
543
  let pythonCmd = 'python';
544
+ try { execSync('python --version', { stdio: 'ignore' }); } catch (e) { pythonCmd = 'python3'; }
557
545
  try {
558
- execSync('python --version', { stdio: 'ignore' });
559
- } catch (e) {
560
- pythonCmd = 'python3';
561
- }
562
-
563
- execSync(
564
- `${pythonCmd} ${path.join(__dirname, 'scripts', 'gen_subtitle.py')} "${mp4Path}" "${slug}" ${lang} "${FFMPEG_BIN}"`,
565
- { stdio: 'inherit', cwd: process.cwd() }
566
- );
567
- } else {
568
- 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.`); }
569
548
  }
570
549
  }
571
- } else {
572
- console.log('\n⏭ [4/5] Skipping subtitle generation (selected "skip" option)');
573
550
  }
574
551
 
575
- // ─── BƯỚC 5: Upload Tebi.io ───────────────────────────────────
576
- console.log('\n🚀 [5/5] Deploying to Quantum Cloud (Tebi.io)...');
577
-
578
- // Optimize upload with parallel processing
579
- const uploadCmd = `node ${path.join(__dirname, 'scripts', 'upload-tebi.mjs')} "${slug}"`;
552
+ // Final Sync Upload
553
+ log('\n🚀 Final sync upload...');
554
+ await uploadHLSFolder(slug);
580
555
 
581
- // Run upload with higher process priority and optimized settings
582
- try {
583
- execSync(uploadCmd, {
584
- stdio: 'inherit',
585
- cwd: process.cwd(),
586
- maxBuffer: 1024 * 1024, // Increase buffer size
587
- env: {
588
- ...process.env,
589
- NODE_OPTIONS: '--max-old-space-size=4096', // Optimize Node.js memory
590
- UV_THREADPOOL_SIZE: '16' // Increase libuv thread pool
591
- }
592
- });
593
- } catch (uploadError) {
594
- console.error(' ⚠ Upload failed:', uploadError.message);
595
- throw uploadError;
596
- }
597
-
598
- // --- FINAL STEP: GENERATE MASTER README.md ---
599
- const finalReadmePath = path.join('hls', slug, 'README.md');
600
556
  const bucketName = process.env.TEBI_BUCKET || "txa-vod";
601
557
  const cloudUrl = `https://s3.tebi.io/${bucketName}/hls/${slug}/${masterName}`;
558
+ const finalReadmePath = path.join('hls', slug, 'README.md');
602
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');
603
562
 
604
563
  const report = `
605
564
  # 🎬 TXA PIPELINE REPORT: ${title}
@@ -609,7 +568,7 @@ Generated on: ${new Date().toLocaleString()}
609
568
  - [x] **Total Execution Time:** ${formatDuration(Date.now() - startTime)}
610
569
  - [x] **Master Playlist:** \`${masterName}\` (AUTO Quality)
611
570
  - [x] **Video Qualities:** ${selectedQualities.map(q => q.label).join(', ')} (Randomized)
612
- - [${existsSync(nokia3gpPath) ? 'x' : ' '}] **Legacy Format:** \`${slug}.3gp\` (Nokia S40/S60)
571
+ - [${skip3gp ? 'skip' : (existsSync(nokia3gpPath) ? 'x' : ' ')}] **Legacy Format:** \`${slug}.3gp\` (Nokia S40/S60)
613
572
  - [${existsSync(spritePath) ? 'x' : ' '}] **High-Res Preview SpriteSheet:** \`preview.jpg\` (160x90 tiles, 10x10 grid)
614
573
  - [${existsSync(spriteLowPath) ? 'x' : ' '}] **Low-Res Preview SpriteSheet:** \`preview_low.jpg\` (80x45 tiles, 10x10 grid)
615
574
  - [${existsSync(path.join('hls', slug, 'vi.vtt')) ? 'x' : ' '}] **Vietnamese Subtitles:** AI Generated
@@ -619,7 +578,7 @@ Generated on: ${new Date().toLocaleString()}
619
578
  > **[WATCH NOW (HLS)](${cloudUrl})**
620
579
 
621
580
  ## 📱 Legacy Mobile Link (Nokia/Java)
622
- > **[DOWNLOAD 3GP](${nokiaUrl})**
581
+ > ${skip3gp ? '*Disabled by user*' : `**[DOWNLOAD 3GP](${nokiaUrl})**`}
623
582
 
624
583
  ## 🖼️ Preview Sprites Information
625
584
  - **High-Res Version:** \`preview.jpg\` - Full quality for desktop/fast connections
@@ -654,7 +613,12 @@ Generated on: ${new Date().toLocaleString()}
654
613
  }
655
614
 
656
615
  // Tự khởi chạy nếu được gọi trực tiếp qua CLI
657
- 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) {
658
622
  run().catch(err => {
659
623
  console.error('\n❌ LỖI:', err.message);
660
624
  process.exit(1);