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 +11 -3
- package/package.json +2 -2
- package/pipeline.mjs +281 -28
- package/pro-terminal.mjs +3 -3
- package/scripts/gen-readme.mjs +12 -3
- package/scripts/upload-tebi.mjs +2 -1
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.
|
|
212
|
-
*Last Update:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
console.log(' ✅ Preview
|
|
297
|
-
} catch (e) {
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
package/scripts/gen-readme.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
111
|
-
VTT (VI)
|
|
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
|
package/scripts/upload-tebi.mjs
CHANGED
|
@@ -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]
|
|
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);
|