tphim 2.2.0 → 2.3.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 💎 TPHIM - Ultimate Video Pipeline
1
+ # 💎 TPHIM - Ultimate Video Pipeline v2.2.1
2
2
 
3
3
  **TPHIM** là bộ công cụ tối thượng để xây dựng hệ thống VOD (Video on Demand) chuyên nghiệp. Hỗ trợ tự động hóa từ khâu tải video, transcode HLS đa chất lượng, tạo phụ đề AI và upload lên Cloud (S3/Tebi).
4
4
 
@@ -8,9 +8,17 @@
8
8
  - **Resume Upload:** Tự động lưu tiến trình upload, tiếp tục từ điểm dừng nếu bị gián đoạn.
9
9
  - **Cloud Ready:** Upload trực tiếp lên Tebi.io hoặc bất kỳ S3-compatible storage nào.
10
10
  - **Interactive CLI:** Giao diện terminal đẹp mắt với neon theme.
11
+ - **🆕 Dual Preview Sprites:** Tự động tạo cả High-Res và Low-Res preview sprites cho tối ưu mobile/desktop.
11
12
 
12
13
  ---
13
14
 
15
+ ## 📋 Changelog v2.2.1
16
+ - ✨ **NEW:** Dual Preview Sprites (High-Res + Low-Res) với FFmpeg Complex Filter
17
+ - ⚡ **OPTIMIZATION:** Tiết kiệm CPU với single-pass processing
18
+ - 📱 **MOBILE:** Low-Res sprites (80x45 tiles) tối ưu cho mobile và preview nhanh
19
+ - 🔧 **TECH:** Sử dụng `child_process.spawn` thay cho `execSync` để better performance
20
+ - 🚀 **VPS:** Auto-detect Ubuntu VPS và tối ưu FFmpeg parameters (preset: superfast, tune: zerolatency)
21
+ i
14
22
  ## 🛠️ Hướng dẫn cài đặt (VPS Ubuntu / Google Cloud / Linux)
15
23
 
16
24
  Để chạy được bộ công cụ này trên Linux/VPS, bạn cần cài đặt các "vũ khí" sau:
@@ -208,5 +216,5 @@ ntxa -h # Tương tự
208
216
 
209
217
  ---
210
218
 
211
- *Phát triển bởi TXA - Ultimate Video Pipeline 2030 v2.2.0* 🍿🎬
212
- *Last Update: February 2026*
219
+ *Phát triển bởi TXA - Ultimate Video Pipeline 2030 v2.2.2* 🍿🎬
220
+ *Last Update: March 2026*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tphim",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",
@@ -67,4 +67,4 @@
67
67
  "engines": {
68
68
  "node": ">=18.0.0"
69
69
  }
70
- }
70
+ }
package/pipeline.mjs CHANGED
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env node
2
- // pipeline.mjs
2
+ // pipeline.mjs v2.2.2
3
3
  // Usage: node pipeline.mjs "URL_hoac_duong_dan_mp4" "slug" "Tên Phim" [vi|en|both|skip]
4
-
5
- import { execSync } from 'child_process';
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)
10
+
11
+ import { execSync, spawn } from 'child_process';
6
12
  import { existsSync, mkdirSync, writeFileSync, rmSync, unlinkSync, readFileSync } from 'fs';
7
13
  import os from 'os';
8
14
  import path, { dirname } from 'path';
@@ -14,8 +20,44 @@ import { generateReadme } from './scripts/gen-readme.mjs';
14
20
 
15
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
22
  const IS_WIN = os.platform() === 'win32';
23
+ const IS_LINUX = os.platform() === 'linux';
17
24
  const SHELL_OPTS = IS_WIN ? { shell: 'cmd.exe' } : {};
18
25
 
26
+ // Function to detect if running on Ubuntu VPS and optimize accordingly
27
+ function detectVPSOptimization() {
28
+ if (!IS_LINUX) return { isVPS: false, cpuCores: 4, preset: 'ultrafast', tune: 'fastdecode' };
29
+
30
+ try {
31
+ // Check for Ubuntu/Debian
32
+ const releaseInfo = readFileSync('/etc/os-release', 'utf8');
33
+ const isUbuntu = releaseInfo.includes('ubuntu') || releaseInfo.includes('Ubuntu');
34
+
35
+ // Check if running in cloud environment (VPS indicators)
36
+ const isVPS = existsSync('/proc/vz') || // Virtuozzo/OpenVZ
37
+ existsSync('/proc/xen') || // Xen
38
+ existsSync('/sys/class/dmi/id/product_name') &&
39
+ readFileSync('/sys/class/dmi/id/product_name', 'utf8').toLowerCase().includes('cloud');
40
+
41
+ // Get CPU cores for optimization
42
+ const cpuCores = os.cpus().length;
43
+
44
+ // Optimize FFmpeg parameters based on environment
45
+ let preset = 'ultrafast';
46
+ let tune = 'fastdecode';
47
+
48
+ if (isVPS && isUbuntu) {
49
+ console.log(' 🚀 VPS Ubuntu detected - Applying performance optimizations...');
50
+ preset = 'superfast'; // Balance between speed and quality
51
+ tune = 'fastdecode,zerolatency'; // Optimize for streaming
52
+ }
53
+
54
+ return { isVPS, isUbuntu, cpuCores, preset, tune };
55
+ } catch (error) {
56
+ console.log(' ⚠ Could not detect VPS environment, using default settings');
57
+ return { isVPS: false, cpuCores: 4, preset: 'ultrafast', tune: 'fastdecode' };
58
+ }
59
+ }
60
+
19
61
  // Resolve binaries
20
62
  const FFMPEG_BIN = ffmpeg || 'ffmpeg';
21
63
  const YTDLP_BIN = 'yt-dlp'; // default to PATH if not found in project
@@ -85,6 +127,136 @@ function getRandomProxy(proxies, exclude = []) {
85
127
 
86
128
  const PROXY_LIST = loadProxies();
87
129
 
130
+ // Function to create dual preview sprites using complex filter
131
+ function generateDualPreviewSprites(mp4Path, outputDir) {
132
+ return new Promise((resolve, reject) => {
133
+ const highResPath = path.join(outputDir, 'preview.jpg');
134
+ const lowResPath = path.join(outputDir, 'preview_low.jpg');
135
+
136
+ console.log(' 📸 [2.5/5] Generating Dual Preview Sprites (Legacy format)...');
137
+
138
+ // Complex filter để tạo cả 2 phiên bản từ 1 luồng
139
+ const complexFilter = [
140
+ '[0:v]split=2[v0][v1]', // Split video stream thành 2 nhánh
141
+ '[v0]fps=1/10,scale=160:90,tile=10x10,format=yuv420p[v_high]', // High-res version
142
+ '[v1]fps=1/10,scale=80:45,tile=10x10,format=yuv420p[v_low]' // Low-res version (50% size)
143
+ ].join(';');
144
+
145
+ const args = [
146
+ '-i', mp4Path,
147
+ '-filter_complex', complexFilter,
148
+ '-map', '[v_high]',
149
+ '-q:v', '3', // Higher quality for high-res
150
+ '-y',
151
+ highResPath,
152
+ '-map', '[v_low]',
153
+ '-q:v', '8', // Lower quality for low-res (lighter file)
154
+ '-y',
155
+ lowResPath
156
+ ];
157
+
158
+ const ffmpeg = spawn(FFMPEG_BIN, args, {
159
+ stdio: ['ignore', 'pipe', 'pipe'],
160
+ shell: IS_WIN
161
+ });
162
+
163
+ let stdout = '';
164
+ let stderr = '';
165
+
166
+ ffmpeg.stdout.on('data', (data) => {
167
+ stdout += data.toString();
168
+ });
169
+
170
+ ffmpeg.stderr.on('data', (data) => {
171
+ stderr += data.toString();
172
+ });
173
+
174
+ ffmpeg.on('close', (code) => {
175
+ if (code === 0) {
176
+ console.log(' ✅ Dual Preview Sprites ready.');
177
+ resolve({ highResPath, lowResPath });
178
+ } else {
179
+ console.error(' ⚠ Dual sprite generation failed:', stderr);
180
+ reject(new Error(`FFmpeg exited with code ${code}`));
181
+ }
182
+ });
183
+
184
+ ffmpeg.on('error', (error) => {
185
+ console.error(' ⚠ FFmpeg spawn error:', error);
186
+ reject(error);
187
+ });
188
+ });
189
+ }
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
+
245
+ function formatDuration(ms) {
246
+ const seconds = Math.floor((ms / 1000) % 60);
247
+ const minutes = Math.floor((ms / (1000 * 60)) % 60);
248
+ const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
249
+ const days = Math.floor(ms / (1000 * 60 * 60 * 24));
250
+
251
+ const parts = [];
252
+ if (days > 0) parts.push(`${days}d`);
253
+ if (hours > 0 || days > 0) parts.push(`${hours}h`);
254
+ if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`);
255
+ parts.push(`${seconds}s`);
256
+
257
+ return parts.join('');
258
+ }
259
+
88
260
  export async function run() {
89
261
  // Nếu chạy trực tiếp từ CLI
90
262
  let [, , input, slug, title = slug, langArg = 'vi'] = process.argv;
@@ -116,11 +288,43 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
116
288
  mkdirSync('downloads', { recursive: true });
117
289
  mkdirSync(path.join('hls', slug), { recursive: true });
118
290
 
291
+ let startTime = Date.now();
119
292
  // ─── BƯỚC 1: Download ─────────────────────────────────────────
120
- const mp4Path = existsSync(input) && input.toLowerCase().endsWith('.mp4')
293
+ let mp4Path = existsSync(input) && input.toLowerCase().endsWith('.mp4')
121
294
  ? input
122
295
  : path.join('downloads', `${slug}.mp4`);
123
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
+
124
328
  if (!existsSync(mp4Path)) {
125
329
  console.log('\n🔽 [1/5] Downloading video...');
126
330
 
@@ -159,6 +363,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
159
363
  }
160
364
  };
161
365
 
366
+ startTime = Date.now();
162
367
  let downloaded = false;
163
368
 
164
369
  // Attempt 1: Direct download (no proxy)
@@ -220,6 +425,13 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
220
425
  const spritePath = path.join('hls', slug, 'preview.jpg');
221
426
  const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
222
427
 
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
+ }
434
+
223
435
  // Danh sách toàn bộ chất lượng hỗ trợ
224
436
  const ALL_QUALITIES = [
225
437
  { label: '1080p', w: 1920, h: 1080, bv: '4500k', ba: '192k' },
@@ -243,29 +455,36 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
243
455
 
244
456
  const outDir = path.join('hls', slug);
245
457
 
246
- // Xây dựng ffmpeg command động
247
- let maps = '';
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
+
248
464
  let outputs = '';
465
+ let maps = '';
249
466
  let varStreamMapArr = [];
250
467
 
251
468
  selectedQualities.forEach((q, i) => {
252
- maps += `-map 0:v -map 0:a `;
253
- outputs += `-c:v:${i} libx264 -preset ultrafast -tune fastdecode -b:v:${i} ${q.bv} -s:v:${i} ${q.w}x${q.h} -c:a:${i} aac -b:a:${i} ${q.ba} -movflags +faststart `;
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 `;
254
472
  varStreamMapArr.push(`v:${i},a:${i}`);
255
473
  });
256
474
 
257
- // Generate encoded segment name with timestamp
258
475
  const timestamp = Date.now();
259
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
260
478
 
261
479
  const ffmpegCmd = [
262
- `"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`,
263
- '-threads 0', '-preset ultrafast', '-tune fastdecode',
480
+ `"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`,
481
+ `-threads ${threadCount}`,
482
+ '-filter_complex', `"${filterParts.join(';')}"`,
264
483
  maps,
265
484
  outputs,
266
485
  '-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
267
486
  '-master_pl_name', `"${masterName}"`,
268
- '-f hls -hls_time 4 -hls_list_size 0 -hls_segment_type mpegts',
487
+ '-f hls -hls_time 6 -hls_list_size 0 -hls_segment_type mpegts', // 6s segments are faster to write than 4s
269
488
  '-hls_segment_filename', `"${outDir}/%v/${encodedPrefix}_%04d.ts"`,
270
489
  `"${outDir}/%v/index.m3u8"`
271
490
  ].join(' ');
@@ -276,7 +495,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
276
495
  // --- BƯỚC 2.1: XUẤT 3GP CHO MÁY CỔ (NOKIA S40/S60) ---
277
496
  console.log(' [2.1/5] Exporting 3GP legacy format...');
278
497
  console.log(' 📱 [2.1/5] Exporting 3GP legacy format...');
279
- const nokiaCmd = `"${FFMPEG_BIN}" -i "${mp4Path}" -s 320x240 -vcodec mpeg4 -acodec aac -ar 16000 -ac 1 -b:v 250k -b:a 32k -f 3gp "${nokia3gpPath}"`;
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}"`;
280
499
  try {
281
500
  execSync(nokiaCmd, { stdio: 'ignore', ...SHELL_OPTS });
282
501
  console.log(' ✅ Legacy 3GP ready.');
@@ -287,16 +506,41 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
287
506
  console.log(`\n⏭ [2/5] HLS đã có sẵn`);
288
507
  }
289
508
 
290
- // --- STEP 2.5: PREVIEW SPRITE --- (Independent check)
291
- if (!existsSync(spritePath)) {
292
- console.log(' 📸 [2.5/5] Generating Neon Preview SpriteSheet...');
293
- const spriteCmd = `"${FFMPEG_BIN}" -i "${mp4Path}" -vf "fps=1/10,scale=160:90,tile=10x10" -q:v 3 "${spritePath}"`;
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)) {
294
528
  try {
295
- execSync(spriteCmd, { stdio: 'ignore', ...SHELL_OPTS });
296
- console.log(' ✅ Preview SpriteSheet ready.');
297
- } catch (e) { console.log(' ⚠ Preview generation failed.'); }
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
+ }
298
542
  } else {
299
- console.log(` ⏭ Preview SpriteSheet đã có sẵn`);
543
+ console.log(` ⏭ Dual Preview Sprites already exist`);
300
544
  }
301
545
 
302
546
  // ─── BƯỚC 3: Tạo README ───────────────────────────────────────
@@ -333,14 +577,14 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
333
577
 
334
578
  // ─── BƯỚC 5: Upload Tebi.io ───────────────────────────────────
335
579
  console.log('\n🚀 [5/5] Deploying to Quantum Cloud (Tebi.io)...');
336
-
580
+
337
581
  // Optimize upload with parallel processing
338
582
  const uploadCmd = `node ${path.join(__dirname, 'scripts', 'upload-tebi.mjs')} "${slug}"`;
339
-
583
+
340
584
  // Run upload with higher process priority and optimized settings
341
585
  try {
342
- execSync(uploadCmd, {
343
- stdio: 'inherit',
586
+ execSync(uploadCmd, {
587
+ stdio: 'inherit',
344
588
  cwd: process.cwd(),
345
589
  maxBuffer: 1024 * 1024, // Increase buffer size
346
590
  env: {
@@ -357,18 +601,20 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
357
601
  // --- FINAL STEP: GENERATE MASTER README.md ---
358
602
  const finalReadmePath = path.join('hls', slug, 'README.md');
359
603
  const bucketName = process.env.TEBI_BUCKET || "txa-vod";
360
- const cloudUrl = `https://s3.tebi.io/${bucketName}/${slug}/${masterName}`;
361
- const nokiaUrl = `https://s3.tebi.io/${bucketName}/${slug}/${slug}.3gp`;
604
+ const cloudUrl = `https://s3.tebi.io/${bucketName}/hls/${slug}/${masterName}`;
605
+ const nokiaUrl = `https://s3.tebi.io/${bucketName}/hls/${slug}/${slug}.3gp`;
362
606
 
363
607
  const report = `
364
608
  # 🎬 TXA PIPELINE REPORT: ${title}
365
609
  Generated on: ${new Date().toLocaleString()}
366
610
 
367
611
  ## 📦 Asset Checklist
612
+ - [x] **Total Execution Time:** ${formatDuration(Date.now() - startTime)}
368
613
  - [x] **Master Playlist:** \`${masterName}\` (AUTO Quality)
369
614
  - [x] **Video Qualities:** ${selectedQualities.map(q => q.label).join(', ')} (Randomized)
370
615
  - [${existsSync(nokia3gpPath) ? 'x' : ' '}] **Legacy Format:** \`${slug}.3gp\` (Nokia S40/S60)
371
- - [${existsSync(spritePath) ? 'x' : ' '}] **Preview SpriteSheet:** \`preview.jpg\` (10x10 Grid)
616
+ - [${existsSync(spritePath) ? 'x' : ' '}] **High-Res Preview SpriteSheet:** \`preview.jpg\` (160x90 tiles, 10x10 grid)
617
+ - [${existsSync(spriteLowPath) ? 'x' : ' '}] **Low-Res Preview SpriteSheet:** \`preview_low.jpg\` (80x45 tiles, 10x10 grid)
372
618
  - [${existsSync(path.join('hls', slug, 'vi.vtt')) ? 'x' : ' '}] **Vietnamese Subtitles:** AI Generated
373
619
  - [${existsSync(path.join('hls', slug, 'en.vtt')) ? 'x' : ' '}] **English Subtitles:** AI Generated
374
620
 
@@ -378,6 +624,10 @@ Generated on: ${new Date().toLocaleString()}
378
624
  ## 📱 Legacy Mobile Link (Nokia/Java)
379
625
  > **[DOWNLOAD 3GP](${nokiaUrl})**
380
626
 
627
+ ## 🖼️ Preview Sprites Information
628
+ - **High-Res Version:** \`preview.jpg\` - Full quality for desktop/fast connections
629
+ - **Low-Res Version:** \`preview_low.jpg\` - Optimized for mobile/preview (50% size, lighter file)
630
+
381
631
  ---
382
632
  *Generated by Antigravity AI Engine v2030*
383
633
  `;
@@ -387,6 +637,9 @@ Generated on: ${new Date().toLocaleString()}
387
637
  console.log(`📄 Summary saved to: ${finalReadmePath}`);
388
638
  console.log(`🔗 Cloud URL: ${cloudUrl}`);
389
639
  if (existsSync(nokia3gpPath)) console.log(`📱 Nokia 3GP: ${nokiaUrl}`);
640
+ if (existsSync(spritePath)) console.log(`🖼️ High-Res Preview: https://s3.tebi.io/${bucketName}/hls/${slug}/preview.jpg`);
641
+ if (existsSync(spriteLowPath)) console.log(`📱 Low-Res Preview: https://s3.tebi.io/${bucketName}/hls/${slug}/preview_low.jpg`);
642
+ console.log(`⏱ TOTAL TIME: ${formatDuration(Date.now() - startTime)}`);
390
643
 
391
644
  // --- AUTO CLEANUP ---
392
645
  console.log('\n🧹 [CLEANUP] Purging local temporary files...');
package/pro-terminal.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * NROTXA ULTIMATE CLI 2030 EDITION v2.2.0
3
+ * NROTXA ULTIMATE CLI 2030 EDITION v2.2.2
4
4
  * High-performance, Neon-infused Video Pipeline with Resume Upload
5
5
  */
6
6
 
@@ -111,7 +111,7 @@ function showHelp() {
111
111
  )
112
112
  );
113
113
 
114
- console.log(chalk.gray(' ⚡ TPHIM ULTIMATE VIDEO CORE v2.2.0 | STATUS: READY ⚡\n'));
114
+ console.log(chalk.gray(' ⚡ TPHIM ULTIMATE VIDEO CORE v2.2.2 | STATUS: READY ⚡\n'));
115
115
 
116
116
  console.log(`${neonPurple('📖 CÁCH SỬ DỤNG:')}`);
117
117
  console.log(chalk.cyan(' ntxa help - Hiển thị help này'));
@@ -170,7 +170,7 @@ async function main() {
170
170
  )
171
171
  );
172
172
 
173
- console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v2.2.0 | STATUS: READY ⚡\n'));
173
+ console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v2.2.2 | STATUS: READY ⚡\n'));
174
174
 
175
175
  p.intro(`${neonPurple('▣ SYSTEM INITIALIZED - BATCH MODE ENABLED')}`);
176
176
 
@@ -64,6 +64,8 @@ export function generateReadme(slug, mp4Path, info = {}) {
64
64
  const duration = getVideoDuration(mp4Path);
65
65
  const totalSize = getFolderSizeMB(hlsDir);
66
66
  const tsCount = countFiles(hlsDir, '.ts');
67
+ const hasHighResSprite = fs.existsSync(path.join(hlsDir, 'preview.jpg'));
68
+ const hasLowResSprite = fs.existsSync(path.join(hlsDir, 'preview_low.jpg'));
67
69
  const hasSub = fs.existsSync(path.join(hlsDir, 'vi.vtt'));
68
70
  const hasSubEn = fs.existsSync(path.join(hlsDir, 'en.vtt'));
69
71
  const streamInfo = getStreamInfo(mp4Path);
@@ -101,14 +103,21 @@ export function generateReadme(slug, mp4Path, info = {}) {
101
103
  Master playlist : hls/${slug}/master.m3u8
102
104
  → Đây là link chính truyền vào TXAPlayer
103
105
 
104
- 📝 SUBTITLE
106
+ �️ PREVIEW SPRITES
107
+ High-Res Sprite : ${hasHighResSprite ? 'preview.jpg ✅ (160x90 tiles)' : '❌ Chưa có'}
108
+ Low-Res Sprite : ${hasLowResSprite ? 'preview_low.jpg ✅ (80x45 tiles)' : '❌ Chưa có'}
109
+ Usage : Frontend có thể chọn dựa trên tốc độ mạng
110
+
111
+ � SUBTITLE
105
112
  Tiếng Việt : ${hasSub ? 'hls/' + slug + '/vi.vtt ✅' : '❌ Chưa có'}
106
113
  Tiếng Anh : ${hasSubEn ? 'hls/' + slug + '/en.vtt ✅' : '❌ Chưa có'}
107
114
  Tạo bởi : faster-whisper model small (offline, CPU)
108
115
 
109
116
  ☁️ TEBI.IO (sau khi upload)
110
- HLS URL : ${publicUrl}/hls/${slug}/master.m3u8
111
- VTT (VI) : ${publicUrl}/hls/${slug}/vi.vtt
117
+ HLS URL : ${publicUrl}/hls/${slug}/master.m3u8
118
+ VTT (VI) : ${publicUrl}/hls/${slug}/vi.vtt
119
+ High-Res Sprite: ${hasHighResSprite ? publicUrl + '/hls/' + slug + '/preview.jpg' : '❌ Chưa có'}
120
+ Low-Res Sprite : ${hasLowResSprite ? publicUrl + '/hls/' + slug + '/preview_low.jpg' : '❌ Chưa có'}
112
121
 
113
122
  ================================================================================
114
123
  ⚠️ Có thể xóa folder này sau khi upload lên Tebi.io thành công
@@ -59,6 +59,7 @@ export async function uploadHLSFolder(slug) {
59
59
 
60
60
  const cacheMap = {
61
61
  '.ts': 'public, max-age=31536000',
62
+ '.jpg': 'public, max-age=31536000',
62
63
  '.m3u8': 'public, max-age=300',
63
64
  '.vtt': 'public, max-age=86400',
64
65
  '.txt': 'no-cache',
@@ -129,7 +130,7 @@ function getAllFiles(dir) {
129
130
  return result;
130
131
  }
131
132
  // Tự chạy nếu gọi trực tiếp
132
- if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) || process.argv[1].endsWith('upload-tebi.mjs')) {
133
+ if (import.meta.url.endsWith(process.argv[1]?.replace(/\\/g, '/')) || process.argv[1]?.endsWith('upload-tebi.mjs')) {
133
134
  const slug = process.argv[2];
134
135
  if (slug) {
135
136
  uploadHLSFolder(slug).catch(console.error);