tphim 2.1.0 → 2.2.2
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 +37 -3
- package/package.json +3 -3
- package/pipeline.mjs +171 -21
- package/pro-terminal.mjs +6 -4
- package/scripts/gen-readme.mjs +12 -3
- package/scripts/upload-tebi.mjs +57 -6
package/README.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
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
|
|
|
5
5
|
## 🚀 Tính năng vượt trội
|
|
6
6
|
- **Batch Pipeline:** Xử lý hàng loạt phim chỉ với 1 dòng lệnh.
|
|
7
7
|
- **AI Subtitles:** Tự động tạo phụ đề Tiếng Việt/Tiếng Anh bằng công cụ AI (Whisper) chạy offline. Hỗ trợ bỏ qua phụ đề để tăng tốc độ.
|
|
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.
|
|
8
9
|
- **Cloud Ready:** Upload trực tiếp lên Tebi.io hoặc bất kỳ S3-compatible storage nào.
|
|
9
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.
|
|
10
12
|
|
|
11
13
|
---
|
|
12
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
|
|
13
22
|
## 🛠️ Hướng dẫn cài đặt (VPS Ubuntu / Google Cloud / Linux)
|
|
14
23
|
|
|
15
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:
|
|
@@ -172,6 +181,7 @@ TEBI_PUBLIC_URL=https://your-bucket.tebi.io
|
|
|
172
181
|
- Tự động fetch metadata từ video
|
|
173
182
|
- Chọn ngôn ngữ phụ đề (VI/EN/Both/Skip)
|
|
174
183
|
- Batch processing với progress bar
|
|
184
|
+
- **Resume Upload**: Tự động tiếp tục upload nếu bị gián đoạn
|
|
175
185
|
|
|
176
186
|
**Help System:**
|
|
177
187
|
```bash
|
|
@@ -180,7 +190,31 @@ ntxa --help # Tương tự
|
|
|
180
190
|
ntxa -h # Tương tự
|
|
181
191
|
```
|
|
182
192
|
|
|
193
|
+
## 🔄 Resume Upload Feature
|
|
194
|
+
|
|
195
|
+
**Tự động lưu và khôi phục tiến trình upload:**
|
|
196
|
+
|
|
197
|
+
- **Progress Tracking**: Tạo file `.upload-progress.json` trong thư mục HLS
|
|
198
|
+
- **Smart Resume**: Chỉ upload những file chưa có trên cloud
|
|
199
|
+
- **Crash Recovery**: Nếu mất kết nối, chạy lại là tiếp tục từ điểm dừng
|
|
200
|
+
- **Auto Cleanup**: Xóa progress file khi upload hoàn tất
|
|
201
|
+
|
|
202
|
+
**Cách hoạt động:**
|
|
203
|
+
```bash
|
|
204
|
+
# Lần đầu chạy
|
|
205
|
+
☁️ Uploading 1227 files to Tebi.io... (0 already uploaded)
|
|
206
|
+
↳ 107/1227 files...
|
|
207
|
+
|
|
208
|
+
# Nếu bị gián đoạn và chạy lại
|
|
209
|
+
🔄 Found progress file. Resuming from 106 uploaded files...
|
|
210
|
+
☁️ Uploading 1121 files to Tebi.io... (106 already uploaded)
|
|
211
|
+
↳ 113/1227 files...
|
|
212
|
+
|
|
213
|
+
# Bỏ qua file đã tồn tại
|
|
214
|
+
⏭ Skipping hls/test-phim-2024/1080p/index.m3u8 (already exists on cloud)
|
|
215
|
+
```
|
|
216
|
+
|
|
183
217
|
---
|
|
184
218
|
|
|
185
|
-
*Phát triển bởi TXA - Ultimate Video Pipeline 2030 v2.
|
|
186
|
-
*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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tphim",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "TPHIM - Ultimate Video Pipeline: Download, Transcode HLS, AI Subtitles (with skip option), and Cloud Upload.",
|
|
3
|
+
"version": "2.2.2",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -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,82 @@ 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 (High-Res + Low-Res)...');
|
|
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 (High-Res + Low-Res).');
|
|
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
|
+
function formatDuration(ms) {
|
|
192
|
+
const seconds = Math.floor((ms / 1000) % 60);
|
|
193
|
+
const minutes = Math.floor((ms / (1000 * 60)) % 60);
|
|
194
|
+
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
|
|
195
|
+
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
|
|
196
|
+
|
|
197
|
+
const parts = [];
|
|
198
|
+
if (days > 0) parts.push(`${days}d`);
|
|
199
|
+
if (hours > 0 || days > 0) parts.push(`${hours}h`);
|
|
200
|
+
if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`);
|
|
201
|
+
parts.push(`${seconds}s`);
|
|
202
|
+
|
|
203
|
+
return parts.join('');
|
|
204
|
+
}
|
|
205
|
+
|
|
88
206
|
export async function run() {
|
|
89
207
|
// Nếu chạy trực tiếp từ CLI
|
|
90
208
|
let [, , input, slug, title = slug, langArg = 'vi'] = process.argv;
|
|
@@ -116,6 +234,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
116
234
|
mkdirSync('downloads', { recursive: true });
|
|
117
235
|
mkdirSync(path.join('hls', slug), { recursive: true });
|
|
118
236
|
|
|
237
|
+
let startTime = Date.now();
|
|
119
238
|
// ─── BƯỚC 1: Download ─────────────────────────────────────────
|
|
120
239
|
const mp4Path = existsSync(input) && input.toLowerCase().endsWith('.mp4')
|
|
121
240
|
? input
|
|
@@ -159,6 +278,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
159
278
|
}
|
|
160
279
|
};
|
|
161
280
|
|
|
281
|
+
startTime = Date.now();
|
|
162
282
|
let downloaded = false;
|
|
163
283
|
|
|
164
284
|
// Attempt 1: Direct download (no proxy)
|
|
@@ -220,6 +340,13 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
220
340
|
const spritePath = path.join('hls', slug, 'preview.jpg');
|
|
221
341
|
const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
|
|
222
342
|
|
|
343
|
+
// Detect VPS environment and optimize
|
|
344
|
+
const vpsOpt = detectVPSOptimization();
|
|
345
|
+
console.log(` 💻 System: ${IS_LINUX ? 'Linux' : 'Windows'} | CPU Cores: ${vpsOpt.cpuCores}`);
|
|
346
|
+
if (vpsOpt.isVPS && vpsOpt.isUbuntu) {
|
|
347
|
+
console.log(` 🚀 Ubuntu VPS Mode: Enabled (preset: ${vpsOpt.preset}, tune: ${vpsOpt.tune})`);
|
|
348
|
+
}
|
|
349
|
+
|
|
223
350
|
// Danh sách toàn bộ chất lượng hỗ trợ
|
|
224
351
|
const ALL_QUALITIES = [
|
|
225
352
|
{ label: '1080p', w: 1920, h: 1080, bv: '4500k', ba: '192k' },
|
|
@@ -250,7 +377,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
250
377
|
|
|
251
378
|
selectedQualities.forEach((q, i) => {
|
|
252
379
|
maps += `-map 0:v -map 0:a `;
|
|
253
|
-
outputs += `-c:v:${i} libx264 -preset
|
|
380
|
+
outputs += `-c:v:${i} libx264 -preset ${vpsOpt.preset} -tune ${vpsOpt.tune} -b:v:${i} ${q.bv} -s:v:${i} ${q.w}x${q.h} -c:a:${i} aac -b:a:${i} ${q.ba} -movflags +faststart `;
|
|
254
381
|
varStreamMapArr.push(`v:${i},a:${i}`);
|
|
255
382
|
});
|
|
256
383
|
|
|
@@ -258,9 +385,12 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
258
385
|
const timestamp = Date.now();
|
|
259
386
|
const encodedPrefix = Buffer.from(`txa_${timestamp}`).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8);
|
|
260
387
|
|
|
388
|
+
// Optimize thread count based on CPU cores
|
|
389
|
+
const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores, 8) : 0; // 0 = auto, but limit on VPS
|
|
390
|
+
|
|
261
391
|
const ffmpegCmd = [
|
|
262
|
-
`"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`,
|
|
263
|
-
|
|
392
|
+
`"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`,
|
|
393
|
+
`-threads ${threadCount}`, `-preset ${vpsOpt.preset}`, `-tune ${vpsOpt.tune}`,
|
|
264
394
|
maps,
|
|
265
395
|
outputs,
|
|
266
396
|
'-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
|
|
@@ -276,7 +406,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
276
406
|
// --- BƯỚC 2.1: XUẤT 3GP CHO MÁY CỔ (NOKIA S40/S60) ---
|
|
277
407
|
console.log(' [2.1/5] Exporting 3GP legacy format...');
|
|
278
408
|
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}"`;
|
|
409
|
+
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
410
|
try {
|
|
281
411
|
execSync(nokiaCmd, { stdio: 'ignore', ...SHELL_OPTS });
|
|
282
412
|
console.log(' ✅ Legacy 3GP ready.');
|
|
@@ -288,15 +418,26 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
288
418
|
}
|
|
289
419
|
|
|
290
420
|
// --- STEP 2.5: PREVIEW SPRITE --- (Independent check)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
421
|
+
const spriteLowPath = path.join('hls', slug, 'preview_low.jpg');
|
|
422
|
+
|
|
423
|
+
if (!existsSync(spritePath) || !existsSync(spriteLowPath)) {
|
|
294
424
|
try {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
425
|
+
const outDir = path.join('hls', slug);
|
|
426
|
+
const { highResPath, lowResPath } = await generateDualPreviewSprites(mp4Path, outDir);
|
|
427
|
+
console.log(' ✅ Dual Preview Sprites generated successfully.');
|
|
428
|
+
} catch (e) {
|
|
429
|
+
console.log(' ⚠ Dual sprite generation failed, falling back to single sprite...');
|
|
430
|
+
// Fallback to original method
|
|
431
|
+
const spriteCmd = `"${FFMPEG_BIN}" -i "${mp4Path}" -vf "fps=1/10,scale=160:90,tile=10x10" -q:v 3 "${spritePath}"`;
|
|
432
|
+
try {
|
|
433
|
+
execSync(spriteCmd, { stdio: 'ignore', ...SHELL_OPTS });
|
|
434
|
+
console.log(' ✅ Fallback Preview SpriteSheet ready.');
|
|
435
|
+
} catch (fallbackError) {
|
|
436
|
+
console.log(' ⚠ All sprite generation methods failed.');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
298
439
|
} else {
|
|
299
|
-
console.log(` ⏭ Preview
|
|
440
|
+
console.log(` ⏭ Dual Preview Sprites already exist`);
|
|
300
441
|
}
|
|
301
442
|
|
|
302
443
|
// ─── BƯỚC 3: Tạo README ───────────────────────────────────────
|
|
@@ -333,14 +474,14 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
333
474
|
|
|
334
475
|
// ─── BƯỚC 5: Upload Tebi.io ───────────────────────────────────
|
|
335
476
|
console.log('\n🚀 [5/5] Deploying to Quantum Cloud (Tebi.io)...');
|
|
336
|
-
|
|
477
|
+
|
|
337
478
|
// Optimize upload with parallel processing
|
|
338
479
|
const uploadCmd = `node ${path.join(__dirname, 'scripts', 'upload-tebi.mjs')} "${slug}"`;
|
|
339
|
-
|
|
480
|
+
|
|
340
481
|
// Run upload with higher process priority and optimized settings
|
|
341
482
|
try {
|
|
342
|
-
execSync(uploadCmd, {
|
|
343
|
-
stdio: 'inherit',
|
|
483
|
+
execSync(uploadCmd, {
|
|
484
|
+
stdio: 'inherit',
|
|
344
485
|
cwd: process.cwd(),
|
|
345
486
|
maxBuffer: 1024 * 1024, // Increase buffer size
|
|
346
487
|
env: {
|
|
@@ -357,18 +498,20 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
357
498
|
// --- FINAL STEP: GENERATE MASTER README.md ---
|
|
358
499
|
const finalReadmePath = path.join('hls', slug, 'README.md');
|
|
359
500
|
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`;
|
|
501
|
+
const cloudUrl = `https://s3.tebi.io/${bucketName}/hls/${slug}/${masterName}`;
|
|
502
|
+
const nokiaUrl = `https://s3.tebi.io/${bucketName}/hls/${slug}/${slug}.3gp`;
|
|
362
503
|
|
|
363
504
|
const report = `
|
|
364
505
|
# 🎬 TXA PIPELINE REPORT: ${title}
|
|
365
506
|
Generated on: ${new Date().toLocaleString()}
|
|
366
507
|
|
|
367
508
|
## 📦 Asset Checklist
|
|
509
|
+
- [x] **Total Execution Time:** ${formatDuration(Date.now() - startTime)}
|
|
368
510
|
- [x] **Master Playlist:** \`${masterName}\` (AUTO Quality)
|
|
369
511
|
- [x] **Video Qualities:** ${selectedQualities.map(q => q.label).join(', ')} (Randomized)
|
|
370
512
|
- [${existsSync(nokia3gpPath) ? 'x' : ' '}] **Legacy Format:** \`${slug}.3gp\` (Nokia S40/S60)
|
|
371
|
-
- [${existsSync(spritePath) ? 'x' : ' '}] **Preview SpriteSheet:** \`preview.jpg\` (10x10
|
|
513
|
+
- [${existsSync(spritePath) ? 'x' : ' '}] **High-Res Preview SpriteSheet:** \`preview.jpg\` (160x90 tiles, 10x10 grid)
|
|
514
|
+
- [${existsSync(spriteLowPath) ? 'x' : ' '}] **Low-Res Preview SpriteSheet:** \`preview_low.jpg\` (80x45 tiles, 10x10 grid)
|
|
372
515
|
- [${existsSync(path.join('hls', slug, 'vi.vtt')) ? 'x' : ' '}] **Vietnamese Subtitles:** AI Generated
|
|
373
516
|
- [${existsSync(path.join('hls', slug, 'en.vtt')) ? 'x' : ' '}] **English Subtitles:** AI Generated
|
|
374
517
|
|
|
@@ -378,6 +521,10 @@ Generated on: ${new Date().toLocaleString()}
|
|
|
378
521
|
## 📱 Legacy Mobile Link (Nokia/Java)
|
|
379
522
|
> **[DOWNLOAD 3GP](${nokiaUrl})**
|
|
380
523
|
|
|
524
|
+
## 🖼️ Preview Sprites Information
|
|
525
|
+
- **High-Res Version:** \`preview.jpg\` - Full quality for desktop/fast connections
|
|
526
|
+
- **Low-Res Version:** \`preview_low.jpg\` - Optimized for mobile/preview (50% size, lighter file)
|
|
527
|
+
|
|
381
528
|
---
|
|
382
529
|
*Generated by Antigravity AI Engine v2030*
|
|
383
530
|
`;
|
|
@@ -387,6 +534,9 @@ Generated on: ${new Date().toLocaleString()}
|
|
|
387
534
|
console.log(`📄 Summary saved to: ${finalReadmePath}`);
|
|
388
535
|
console.log(`🔗 Cloud URL: ${cloudUrl}`);
|
|
389
536
|
if (existsSync(nokia3gpPath)) console.log(`📱 Nokia 3GP: ${nokiaUrl}`);
|
|
537
|
+
if (existsSync(spritePath)) console.log(`🖼️ High-Res Preview: https://s3.tebi.io/${bucketName}/hls/${slug}/preview.jpg`);
|
|
538
|
+
if (existsSync(spriteLowPath)) console.log(`📱 Low-Res Preview: https://s3.tebi.io/${bucketName}/hls/${slug}/preview_low.jpg`);
|
|
539
|
+
console.log(`⏱ TOTAL TIME: ${formatDuration(Date.now() - startTime)}`);
|
|
390
540
|
|
|
391
541
|
// --- AUTO CLEANUP ---
|
|
392
542
|
console.log('\n🧹 [CLEANUP] Purging local temporary files...');
|
package/pro-terminal.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* NROTXA ULTIMATE CLI 2030 EDITION
|
|
4
|
-
* High-performance, Neon-infused Video Pipeline
|
|
3
|
+
* NROTXA ULTIMATE CLI 2030 EDITION v2.2.2
|
|
4
|
+
* High-performance, Neon-infused Video Pipeline with Resume Upload
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as p from '@clack/prompts';
|
|
@@ -111,7 +111,7 @@ function showHelp() {
|
|
|
111
111
|
)
|
|
112
112
|
);
|
|
113
113
|
|
|
114
|
-
console.log(chalk.gray(' ⚡ TPHIM ULTIMATE VIDEO CORE v2.
|
|
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'));
|
|
@@ -131,6 +131,8 @@ function showHelp() {
|
|
|
131
131
|
console.log(chalk.cyan(' • Download video từ multiple sources'));
|
|
132
132
|
console.log(chalk.cyan(' • Transcode HLS 4-6 qualities'));
|
|
133
133
|
console.log(chalk.cyan(' • AI Subtitle generation (Whisper)'));
|
|
134
|
+
console.log(chalk.cyan(' • Skip subtitle option (fast mode)'));
|
|
135
|
+
console.log(chalk.cyan(' • Resume upload (crash recovery)'));
|
|
134
136
|
console.log(chalk.cyan(' • Upload cloud storage (Tebi.io)'));
|
|
135
137
|
console.log(chalk.cyan(' • Batch processing support'));
|
|
136
138
|
console.log(chalk.cyan(' • Proxy rotation tự động'));
|
|
@@ -168,7 +170,7 @@ async function main() {
|
|
|
168
170
|
)
|
|
169
171
|
);
|
|
170
172
|
|
|
171
|
-
console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v2.
|
|
173
|
+
console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v2.2.2 | STATUS: READY ⚡\n'));
|
|
172
174
|
|
|
173
175
|
p.intro(`${neonPurple('▣ SYSTEM INITIALIZED - BATCH MODE ENABLED')}`);
|
|
174
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// scripts/upload-tebi.mjs
|
|
2
2
|
// Tebi.io S3-compatible — dùng @aws-sdk/client-s3 bình thường
|
|
3
|
-
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
4
|
-
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
|
4
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
5
5
|
import { join, extname, relative } from 'path';
|
|
6
6
|
import { lookup } from 'mime-types';
|
|
7
7
|
import 'dotenv/config';
|
|
@@ -19,14 +19,44 @@ const s3 = new S3Client({
|
|
|
19
19
|
export async function uploadHLSFolder(slug) {
|
|
20
20
|
const localDir = join('hls', slug);
|
|
21
21
|
const files = getAllFiles(localDir);
|
|
22
|
+
const progressFile = join('hls', slug, '.upload-progress.json');
|
|
23
|
+
|
|
24
|
+
// Load existing progress
|
|
25
|
+
let uploadedFiles = [];
|
|
26
|
+
if (existsSync(progressFile)) {
|
|
27
|
+
try {
|
|
28
|
+
const progressData = JSON.parse(readFileSync(progressFile, 'utf-8'));
|
|
29
|
+
uploadedFiles = progressData.uploadedFiles || [];
|
|
30
|
+
console.log(`\n🔄 Found progress file. Resuming from ${uploadedFiles.length} uploaded files...`);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.log('\n⚠ Progress file corrupted, starting fresh...');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
// Filter files that haven't been uploaded yet
|
|
37
|
+
const filesToUpload = files.filter(file => !uploadedFiles.includes(relative('.', file).replace(/\\/g, '/')));
|
|
38
|
+
|
|
39
|
+
console.log(`\n☁️ Uploading ${filesToUpload.length} files to Tebi.io... (${files.length - filesToUpload.length} already uploaded)`);
|
|
40
|
+
let uploaded = uploadedFiles.length;
|
|
25
41
|
|
|
26
|
-
for (const filePath of
|
|
42
|
+
for (const filePath of filesToUpload) {
|
|
27
43
|
const s3Key = relative('.', filePath).replace(/\\/g, '/');
|
|
28
44
|
const ext = extname(filePath).toLowerCase();
|
|
29
45
|
|
|
46
|
+
// Check if file already exists on cloud
|
|
47
|
+
try {
|
|
48
|
+
await s3.send(new HeadObjectCommand({
|
|
49
|
+
Bucket: process.env.TEBI_BUCKET,
|
|
50
|
+
Key: s3Key
|
|
51
|
+
}));
|
|
52
|
+
console.log(`\n ⏭ Skipping ${s3Key} (already exists on cloud)`);
|
|
53
|
+
uploaded++;
|
|
54
|
+
uploadedFiles.push(s3Key);
|
|
55
|
+
continue;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// File doesn't exist, proceed with upload
|
|
58
|
+
}
|
|
59
|
+
|
|
30
60
|
const cacheMap = {
|
|
31
61
|
'.ts': 'public, max-age=31536000',
|
|
32
62
|
'.m3u8': 'public, max-age=300',
|
|
@@ -44,9 +74,30 @@ export async function uploadHLSFolder(slug) {
|
|
|
44
74
|
}));
|
|
45
75
|
|
|
46
76
|
uploaded++;
|
|
77
|
+
uploadedFiles.push(s3Key);
|
|
78
|
+
|
|
79
|
+
// Save progress after each successful upload
|
|
80
|
+
writeFileSync(progressFile, JSON.stringify({
|
|
81
|
+
uploadedFiles,
|
|
82
|
+
totalFiles: files.length,
|
|
83
|
+
lastUpdate: new Date().toISOString()
|
|
84
|
+
}));
|
|
85
|
+
|
|
47
86
|
process.stdout.write(`\r ↳ ${uploaded}/${files.length} files...`);
|
|
48
87
|
}
|
|
49
88
|
|
|
89
|
+
// Clean up progress file when complete
|
|
90
|
+
if (uploaded === files.length) {
|
|
91
|
+
try {
|
|
92
|
+
if (existsSync(progressFile)) {
|
|
93
|
+
unlinkSync(progressFile);
|
|
94
|
+
console.log('\n 🧹 Cleaned up progress file');
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {
|
|
97
|
+
// Ignore cleanup error
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
50
101
|
const base = process.env.TEBI_PUBLIC_URL;
|
|
51
102
|
const masterName = `txa-${slug}.m3u8`;
|
|
52
103
|
const finalMasterUrl = `${base}/hls/${slug}/${masterName}`;
|
|
@@ -78,7 +129,7 @@ function getAllFiles(dir) {
|
|
|
78
129
|
return result;
|
|
79
130
|
}
|
|
80
131
|
// Tự chạy nếu gọi trực tiếp
|
|
81
|
-
if (import.meta.url.endsWith(process.argv[1]
|
|
132
|
+
if (import.meta.url.endsWith(process.argv[1]?.replace(/\\/g, '/')) || process.argv[1]?.endsWith('upload-tebi.mjs')) {
|
|
82
133
|
const slug = process.argv[2];
|
|
83
134
|
if (slug) {
|
|
84
135
|
uploadHLSFolder(slug).catch(console.error);
|