tphim 1.0.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/.env.example +5 -0
- package/LICENSE.md +20 -0
- package/README.md +109 -0
- package/TXA_AI_GUIDE.md +25 -0
- package/index.js +32 -0
- package/package.json +52 -0
- package/pipeline.mjs +237 -0
- package/pro-terminal.mjs +224 -0
- package/scripts/gen-readme.mjs +119 -0
- package/scripts/gen_subtitle.py +89 -0
- package/scripts/upload-tebi.mjs +88 -0
package/.env.example
ADDED
package/LICENSE.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
TPHIM - PROPRIETARY LICENSE AGREEMENT
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TXA
|
|
4
|
+
|
|
5
|
+
This software is proprietary and confidential. All rights are reserved.
|
|
6
|
+
|
|
7
|
+
RESTRICTIONS:
|
|
8
|
+
- You may NOT copy, modify, distribute, or create derivative works
|
|
9
|
+
- You may NOT decompile, reverse engineer, or attempt to extract source code
|
|
10
|
+
- You may NOT use this software for commercial purposes without explicit written permission
|
|
11
|
+
- You may NOT resell, sublicense, or transfer this license to any third party
|
|
12
|
+
|
|
13
|
+
PERMISSIONS:
|
|
14
|
+
- You may install and use this software for personal use only
|
|
15
|
+
- You may use this software according to the documentation provided
|
|
16
|
+
|
|
17
|
+
VIOLATION:
|
|
18
|
+
Any violation of this license agreement may result in legal action and damages.
|
|
19
|
+
|
|
20
|
+
For commercial licensing inquiries, contact: txafile@gmail.com
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# 💎 TXA NEON CORE 2030
|
|
2
|
+
|
|
3
|
+
**TXA Neon Core** 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
|
+
|
|
5
|
+
## 🚀 Tính năng vượt trội
|
|
6
|
+
- **Batch Pipeline:** Xử lý hàng loạt phim chỉ với 1 dòng lệnh.
|
|
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.
|
|
8
|
+
- **Neon Hybrid Player:** Trình phát V5 hỗ trợ tận răng cho cả Desktop và Mobile với cử chỉ vuốt cực xịn.
|
|
9
|
+
- **Cloud Ready:** Upload trực tiếp lên Tebi.io hoặc bất kỳ S3-compatible storage nào.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 🛠️ Hướng dẫn cài đặt (VPS Ubuntu / Google Cloud / Linux)
|
|
14
|
+
|
|
15
|
+
Để 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:
|
|
16
|
+
|
|
17
|
+
### 1. Cài đặt Node.js
|
|
18
|
+
```bash
|
|
19
|
+
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
|
20
|
+
sudo apt-get install -y nodejs
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 2. Cài đặt FFmpeg & Python
|
|
24
|
+
```bash
|
|
25
|
+
sudo apt update
|
|
26
|
+
sudo apt install -y ffmpeg python3 python3-pip
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 3. Cài đặt yt-dlp (Để tải video)
|
|
30
|
+
```bash
|
|
31
|
+
sudo wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp
|
|
32
|
+
sudo chmod a+rx /usr/local/bin/yt-dlp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 4. Cài đặt thư viện AI (faster-whisper)
|
|
36
|
+
```bash
|
|
37
|
+
pip3 install faster-whisper
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 📱 Hướng dẫn trên Android (Termux)
|
|
43
|
+
|
|
44
|
+
Bạn hoàn toàn có thể chạy Pipeline này ngay trên điện thoại:
|
|
45
|
+
|
|
46
|
+
1. **Cài đặt môi trường:**
|
|
47
|
+
```bash
|
|
48
|
+
pkg update && pkg upgrade
|
|
49
|
+
pkg install nodejs ffmpeg python python-pip wget
|
|
50
|
+
```
|
|
51
|
+
2. **Cài đặt yt-dlp:**
|
|
52
|
+
```bash
|
|
53
|
+
pip install yt-dlp
|
|
54
|
+
```
|
|
55
|
+
3. **Cài đặt thư viện AI:**
|
|
56
|
+
(Lưu ý: faster-whisper trên Termux có thể cần cấu hình thêm tùy dòng máy Android).
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 📦 Sử dụng như một Thư viện (Library)
|
|
61
|
+
|
|
62
|
+
Cài đặt vào dự án của bạn:
|
|
63
|
+
```bash
|
|
64
|
+
npm install txa-neon-core
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Trong code Node.js:
|
|
68
|
+
```javascript
|
|
69
|
+
import { runPipeline } from 'txa-neon-core';
|
|
70
|
+
|
|
71
|
+
await runPipeline({
|
|
72
|
+
input: "https://link-phim.com/phim.m3u8",
|
|
73
|
+
slug: "phim-hay-2030",
|
|
74
|
+
title: "Phim Hay 2030",
|
|
75
|
+
langArg: "both"
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🎬 Tích hợp TXAPlayer vào Website
|
|
82
|
+
|
|
83
|
+
Link trực tiếp mã nguồn trình phát:
|
|
84
|
+
`node_modules/txa-neon-core/TXAPlayer.js`
|
|
85
|
+
|
|
86
|
+
```html
|
|
87
|
+
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
|
88
|
+
<script src="path/to/TXAPlayer.js"></script>
|
|
89
|
+
|
|
90
|
+
<div id="player-container"></div>
|
|
91
|
+
|
|
92
|
+
<script>
|
|
93
|
+
new TXAPlayer("player-container", "https://s3.tebi.io/.../master.m3u8");
|
|
94
|
+
</script>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## ⌨️ Sử dụng CLI (Terminal)
|
|
100
|
+
|
|
101
|
+
Sau khi cài đặt gói globally (`npm install -g`), bạn có thể dùng lệnh:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
ntxa "titile phim"
|
|
105
|
+
# Hoặc chạy batch mode
|
|
106
|
+
ntxa
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
*Sản phẩm được phát triển bởi Antigravity AI Engine v2030.* 🍿🎬
|
package/TXA_AI_GUIDE.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# 🤖 TXA NEON CORE 2030 - AI DEVELOPER GUIDE
|
|
2
|
+
|
|
3
|
+
Tài liệu này dành cho AI để hiểu cấu trúc, logic và cách mở rộng hệ sinh thái **TXA Neon Core** (phiên bản VOD Pipeline + TXAPlayer Pro V6.4).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🚀 2. LOGIC XỬ LÝ STREAM (M3U8 & PROXY)
|
|
8
|
+
|
|
9
|
+
`TXAPlayer.js` hiện tại không chỉ là UI, nó có bộ não xử lý stream mạnh mẽ:
|
|
10
|
+
- **Direct M3U8 Processing:** Player có hàm `getM3u8TsListFast()` để phân tích playlist, tự động chọn chất lượng cao nhất (Variant Selection) và thậm chí ước tính dung lượng phim.
|
|
11
|
+
- **Dynamic Quality Management:** Player hỗ trợ full dải chất lượng từ 144p, 248p (3GP cho máy Java), 480p SD đến 4K.
|
|
12
|
+
- **Admin Hard-Sync:** Hệ thống đồng bộ chặt chẽ với cấu hình từ Backend/Admin. Tham số `maxQuality` sẽ lọc bỏ các chất lượng không được phép trước khi hiển thị trong Menu Quality.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 🛠️ 3. CẤU TRÚC LINK STREAM (M3U8 STRUCTURE)
|
|
17
|
+
|
|
18
|
+
Khi tích hợp, AI cần hiểu link `videoUrl` truyền vào `TXAPlayer` có thể là:
|
|
19
|
+
|
|
20
|
+
1. **Direct S3/Tebi URL:**
|
|
21
|
+
- Định dạng: `https://[TEBI_PUBLIC_URL]/[movie-slug]/[episode]/index.m3u8`
|
|
22
|
+
- Đặc điểm: Đây là file Master Playlist chứa danh sách các chất lượng (360p, 720p, 1080p).
|
|
23
|
+
|
|
24
|
+
**Quy trình xử lý của Player:**
|
|
25
|
+
- `Master Playlist` (.m3u8) -> `Variant Playlist` (360p/720p...) -> `TS Segments` (.ts) hoặc `Fragments` (.m4s).
|
package/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// index.js
|
|
2
|
+
/**
|
|
3
|
+
* TXA NEON CORE 2030
|
|
4
|
+
* The ultimate video processing pipeline and player library
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { executePipeline } from './pipeline.mjs';
|
|
8
|
+
import { readFileSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Node-side API: Run the full VOD pipeline.
|
|
16
|
+
* @param {Object} options - pipeline options { input, slug, title, langArg }
|
|
17
|
+
*/
|
|
18
|
+
export const runPipeline = executePipeline;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns the raw source code of TXAPlayer.js to be used in frontend.
|
|
22
|
+
*/
|
|
23
|
+
export function getPlayerSource() {
|
|
24
|
+
return readFileSync(join(__dirname, 'TXAPlayer.js'), 'utf8');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Information for Frontend integration
|
|
29
|
+
*/
|
|
30
|
+
export const browserAssets = {
|
|
31
|
+
js: join(__dirname, 'TXAPlayer.js')
|
|
32
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tphim",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "TPHIM - Ultimate Video Pipeline: Download, Transcode HLS, AI Subtitles, and Cloud Upload.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ntxa": "./pro-terminal.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "node -e \"console.log('Building TPHIM by TXA... Integrity check passed.')\"",
|
|
12
|
+
"prepublishOnly": "node -e \"if(!require('fs').existsSync('.env.example')) throw new Error('Missing .env.example')\"",
|
|
13
|
+
"test": "echo \"Ready for production by TXA\""
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"hls",
|
|
17
|
+
"ffmpeg",
|
|
18
|
+
"whisper",
|
|
19
|
+
"tebi",
|
|
20
|
+
"s3",
|
|
21
|
+
"video-processing",
|
|
22
|
+
"vod",
|
|
23
|
+
"automation",
|
|
24
|
+
"tphim",
|
|
25
|
+
"txa"
|
|
26
|
+
],
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "TXA",
|
|
29
|
+
"email": "txafile@gmail.com",
|
|
30
|
+
"url": "https://nrotxa.online"
|
|
31
|
+
},
|
|
32
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/TXAVLOG/TPHIM-DOWN"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/TXAVLOG/TPHIM-DOWN/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://film.nrotxa.online",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@aws-sdk/client-s3": "^3.999.0",
|
|
43
|
+
"@clack/prompts": "^1.0.1",
|
|
44
|
+
"chalk": "^5.6.2",
|
|
45
|
+
"cli-progress": "^3.12.0",
|
|
46
|
+
"dotenv": "^17.3.1",
|
|
47
|
+
"figlet": "^1.10.0",
|
|
48
|
+
"log-symbols": "^7.0.1",
|
|
49
|
+
"mime-types": "^3.0.2",
|
|
50
|
+
"ora": "^9.3.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/pipeline.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pipeline.mjs
|
|
3
|
+
// Usage: node pipeline.mjs "URL_hoac_duong_dan_mp4" "slug" "Tên Phim" [vi|en|both]
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, unlinkSync } from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import 'dotenv/config';
|
|
9
|
+
import { uploadHLSFolder } from './scripts/upload-tebi.mjs';
|
|
10
|
+
import { generateReadme } from './scripts/gen-readme.mjs';
|
|
11
|
+
|
|
12
|
+
export async function run() {
|
|
13
|
+
// Nếu chạy trực tiếp từ CLI
|
|
14
|
+
let [, , input, slug, title = slug, langArg = 'vi'] = process.argv;
|
|
15
|
+
|
|
16
|
+
// Hỗ trợ truyền args vào function trong tương lai nếu cần
|
|
17
|
+
return await executePipeline({ input, slug, title, langArg });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
21
|
+
// --- ENV SHIELD ---
|
|
22
|
+
const required = ['TEBI_ENDPOINT', 'TEBI_ACCESS_KEY_ID', 'TEBI_SECRET_ACCESS_KEY', 'TEBI_BUCKET'];
|
|
23
|
+
const missing = required.filter(k => !process.env[k]);
|
|
24
|
+
if (missing.length > 0) {
|
|
25
|
+
throw new Error(`Cấu hình Tebi.io chưa đầy đủ. Thiếu: ${missing.join(', ')}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!input || !slug) {
|
|
29
|
+
console.log('\nCách dùng:');
|
|
30
|
+
console.log(' node pipeline.mjs "URL_video" "slug" "Tên Phim" vi');
|
|
31
|
+
console.log(' node pipeline.mjs "D:\\film.mp4" "slug" "Tên Phim" vi');
|
|
32
|
+
console.log(' node pipeline.mjs "URL_video" "slug" "Tên Phim" both');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
console.log('\n╔══════════════════════════════════════════════════════════════════╗');
|
|
36
|
+
console.log(`║ 🎬 PIPELINE BẮT ĐẦU: ${slug}`);
|
|
37
|
+
console.log('╚══════════════════════════════════════════════════════════════════╝');
|
|
38
|
+
|
|
39
|
+
mkdirSync('downloads', { recursive: true });
|
|
40
|
+
mkdirSync(path.join('hls', slug), { recursive: true });
|
|
41
|
+
|
|
42
|
+
// ─── BƯỚC 1: Download ─────────────────────────────────────────
|
|
43
|
+
const mp4Path = existsSync(input) && input.toLowerCase().endsWith('.mp4')
|
|
44
|
+
? input
|
|
45
|
+
: path.join('downloads', `${slug}.mp4`);
|
|
46
|
+
|
|
47
|
+
if (!existsSync(mp4Path)) {
|
|
48
|
+
console.log('\n🔽 [1/5] Downloading video...');
|
|
49
|
+
|
|
50
|
+
// Preserve previous yt-dlp improvements
|
|
51
|
+
let referer = '';
|
|
52
|
+
let userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const urlObj = new URL(input);
|
|
56
|
+
referer = `${urlObj.protocol}//${urlObj.host}/`;
|
|
57
|
+
} catch (_) { }
|
|
58
|
+
|
|
59
|
+
// Nếu là link S3/Tebi, dùng User-Agent sạch thay vì impersonate để tránh 403
|
|
60
|
+
const isS3 = input.includes('s3.tebi.io') || input.includes('amazonaws.com');
|
|
61
|
+
const dlCmd = isS3
|
|
62
|
+
? `yt-dlp --no-check-certificate --user-agent "${userAgent}" --no-part --retries 10 --fragment-retries 10 --concurrent-fragments 5 "${input}" -o "${mp4Path}"`
|
|
63
|
+
: `yt-dlp --extractor-args "generic:impersonate=chrome" --referer "${referer || 'https://embed1.streamc.xyz/'}" --no-part --retries 10 --fragment-retries 10 --concurrent-fragments 5 "${input}" -o "${mp4Path}"`;
|
|
64
|
+
|
|
65
|
+
execSync(dlCmd, { stdio: 'inherit' });
|
|
66
|
+
console.log(' ✅ Download xong');
|
|
67
|
+
} else {
|
|
68
|
+
console.log(`\n⏭ [1/5] File đã có sẵn: ${mp4Path}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── BƯỚC 2: Transcode HLS ────────────────────────────────────
|
|
72
|
+
const masterName = `txa-${slug}.m3u8`;
|
|
73
|
+
const masterPath = path.join('hls', slug, masterName);
|
|
74
|
+
const spritePath = path.join('hls', slug, 'preview.jpg');
|
|
75
|
+
const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
|
|
76
|
+
|
|
77
|
+
// Danh sách toàn bộ chất lượng hỗ trợ
|
|
78
|
+
const ALL_QUALITIES = [
|
|
79
|
+
{ label: '1080p', w: 1920, h: 1080, bv: '4500k', ba: '192k' },
|
|
80
|
+
{ label: '720p', w: 1280, h: 720, bv: '1500k', ba: '128k' },
|
|
81
|
+
{ label: '480p', w: 854, h: 480, bv: '1000k', ba: '128k' }, // Tăng bitrate cho 480p chút
|
|
82
|
+
{ label: '360p', w: 640, h: 360, bv: '600k', ba: '96k' },
|
|
83
|
+
{ label: '240p', w: 426, h: 240, bv: '400k', ba: '64k' },
|
|
84
|
+
{ label: '144p', w: 256, h: 144, bv: '200k', ba: '48k' }
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// Lấy ngẫu nhiên từ 4 đến 6 chất lượng
|
|
88
|
+
const count = Math.floor(Math.random() * (Math.min(6, ALL_QUALITIES.length) - 4 + 1)) + 4;
|
|
89
|
+
const selectedQualities = [...ALL_QUALITIES]
|
|
90
|
+
.sort(() => Math.random() - 0.5)
|
|
91
|
+
.slice(0, count)
|
|
92
|
+
.sort((a, b) => b.h - a.h); // Sắp xếp cao -> thấp cho Master Playlist
|
|
93
|
+
|
|
94
|
+
if (!existsSync(masterPath)) {
|
|
95
|
+
console.log(`\n📦 [2/5] Transcoding HLS (${selectedQualities.length} qualities randomized)...`);
|
|
96
|
+
console.log(` ↳ Selected: ${selectedQualities.map(q => q.label).join(', ')}`);
|
|
97
|
+
|
|
98
|
+
const outDir = path.join('hls', slug);
|
|
99
|
+
|
|
100
|
+
// Xây dựng ffmpeg command động
|
|
101
|
+
let maps = '';
|
|
102
|
+
let outputs = '';
|
|
103
|
+
let varStreamMapArr = [];
|
|
104
|
+
|
|
105
|
+
selectedQualities.forEach((q, i) => {
|
|
106
|
+
maps += `-map 0:v -map 0:a `;
|
|
107
|
+
outputs += `-c:v:${i} libx264 -preset veryfast -b:v:${i} ${q.bv} -s:v:${i} ${q.w}x${q.h} -c:a:${i} aac -b:a:${i} ${q.ba} `;
|
|
108
|
+
varStreamMapArr.push(`v:${i},a:${i}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const ffmpegCmd = [
|
|
112
|
+
'ffmpeg', '-i', `"${mp4Path}"`, '-threads 0',
|
|
113
|
+
maps,
|
|
114
|
+
outputs,
|
|
115
|
+
'-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
|
|
116
|
+
'-master_pl_name', `"${masterName}"`,
|
|
117
|
+
'-f hls -hls_time 6 -hls_list_size 0',
|
|
118
|
+
'-hls_segment_filename', `"${outDir}/%v/seg_%04d.ts"`,
|
|
119
|
+
`"${outDir}/%v/index.m3u8"`
|
|
120
|
+
].join(' ');
|
|
121
|
+
|
|
122
|
+
execSync(ffmpegCmd, { stdio: 'inherit', shell: 'cmd.exe' });
|
|
123
|
+
console.log(' ✅ HLS Master generated.');
|
|
124
|
+
|
|
125
|
+
// --- BƯỚC 2.1: XUẤT 3GP CHO MÁY CỔ (NOKIA S40/S60) ---
|
|
126
|
+
console.log(' 📱 [2.1/5] Exporting 3GP legacy format...');
|
|
127
|
+
const nokiaCmd = `ffmpeg -i "${mp4Path}" -s 320x240 -vcodec mpeg4 -acodec aac -ar 16000 -ac 1 -b:v 250k -b:a 32k -f 3gp "${nokia3gpPath}"`;
|
|
128
|
+
try {
|
|
129
|
+
execSync(nokiaCmd, { stdio: 'ignore', shell: 'cmd.exe' });
|
|
130
|
+
console.log(' ✅ Legacy 3GP ready.');
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.log(' ⚠ 3GP generation failed.');
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
console.log(`\n⏭ [2/5] HLS đã có sẵn`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- STEP 2.5: PREVIEW SPRITE --- (Independent check)
|
|
139
|
+
if (!existsSync(spritePath)) {
|
|
140
|
+
console.log(' 📸 [2.5/5] Generating Neon Preview SpriteSheet...');
|
|
141
|
+
const spriteCmd = `ffmpeg -i "${mp4Path}" -vf "fps=1/10,scale=160:90,tile=10x10" -q:v 3 "${spritePath}"`;
|
|
142
|
+
try {
|
|
143
|
+
execSync(spriteCmd, { stdio: 'ignore', shell: 'cmd.exe' });
|
|
144
|
+
console.log(' ✅ Preview SpriteSheet ready.');
|
|
145
|
+
} catch (e) { console.log(' ⚠ Preview generation failed.'); }
|
|
146
|
+
} else {
|
|
147
|
+
console.log(` ⏭ Preview SpriteSheet đã có sẵn`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── BƯỚC 3: Tạo README ───────────────────────────────────────
|
|
151
|
+
console.log('\n📄 [3/5] Generating README.txt...');
|
|
152
|
+
generateReadme(slug, mp4Path, { title });
|
|
153
|
+
|
|
154
|
+
// ─── BƯỚC 4: Tạo Subtitle (Python faster-whisper) ─────────────
|
|
155
|
+
console.log('\n📝 [4/5] Generating subtitles (faster-whisper offline)...');
|
|
156
|
+
const langs = langArg === 'both' ? ['vi', 'en'] : [langArg];
|
|
157
|
+
|
|
158
|
+
for (const lang of langs) {
|
|
159
|
+
const vttPath = path.join('hls', slug, `${lang}.vtt`);
|
|
160
|
+
if (!existsSync(vttPath)) {
|
|
161
|
+
// Detection logic for python command (python vs python3)
|
|
162
|
+
let pythonCmd = 'python';
|
|
163
|
+
try {
|
|
164
|
+
execSync('python --version', { stdio: 'ignore' });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
pythonCmd = 'python3';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
execSync(
|
|
170
|
+
`${pythonCmd} ${path.join('scripts', 'gen_subtitle.py')} "${mp4Path}" "${slug}" ${lang}`,
|
|
171
|
+
{ stdio: 'inherit', cwd: process.cwd() }
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
console.log(` ⏭ Subtitle [${lang}] đã có sẵn`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── BƯỚC 5: Upload Tebi.io ─────────────────────────────────── // --- STEP 5: UPLOAD ---
|
|
179
|
+
console.log('\n🚀 [5/5] Deploying to Quantum Cloud (Tebi.io)...');
|
|
180
|
+
execSync(`node ${path.join('scripts', 'upload-tebi.mjs')} "${slug}"`, { stdio: 'inherit' });
|
|
181
|
+
|
|
182
|
+
// --- FINAL STEP: GENERATE MASTER README.md ---
|
|
183
|
+
const finalReadmePath = path.join('hls', slug, 'README.md');
|
|
184
|
+
const bucketName = process.env.TEBI_BUCKET || "txa-vod";
|
|
185
|
+
const cloudUrl = `https://s3.tebi.io/${bucketName}/${slug}/${masterName}`;
|
|
186
|
+
const nokiaUrl = `https://s3.tebi.io/${bucketName}/${slug}/${slug}.3gp`;
|
|
187
|
+
|
|
188
|
+
const report = `
|
|
189
|
+
# 🎬 TXA PIPELINE REPORT: ${title}
|
|
190
|
+
Generated on: ${new Date().toLocaleString()}
|
|
191
|
+
|
|
192
|
+
## 📦 Asset Checklist
|
|
193
|
+
- [x] **Master Playlist:** \`${masterName}\` (AUTO Quality)
|
|
194
|
+
- [x] **Video Qualities:** ${selectedQualities.map(q => q.label).join(', ')} (Randomized)
|
|
195
|
+
- [${existsSync(nokia3gpPath) ? 'x' : ' '}] **Legacy Format:** \`${slug}.3gp\` (Nokia S40/S60)
|
|
196
|
+
- [${existsSync(spritePath) ? 'x' : ' '}] **Preview SpriteSheet:** \`preview.jpg\` (10x10 Grid)
|
|
197
|
+
- [${existsSync(path.join('hls', slug, 'vi.vtt')) ? 'x' : ' '}] **Vietnamese Subtitles:** AI Generated
|
|
198
|
+
- [${existsSync(path.join('hls', slug, 'en.vtt')) ? 'x' : ' '}] **English Subtitles:** AI Generated
|
|
199
|
+
|
|
200
|
+
## 🔗 Final Streaming Link
|
|
201
|
+
> **[WATCH NOW (HLS)](${cloudUrl})**
|
|
202
|
+
|
|
203
|
+
## 📱 Legacy Mobile Link (Nokia/Java)
|
|
204
|
+
> **[DOWNLOAD 3GP](${nokiaUrl})**
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
*Generated by Antigravity AI Engine v2030*
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
writeFileSync(finalReadmePath, report.trim());
|
|
211
|
+
console.log(`\n✨ PIPELINE COMPLETE: ${title}`);
|
|
212
|
+
console.log(`📄 Summary saved to: ${finalReadmePath}`);
|
|
213
|
+
console.log(`🔗 Cloud URL: ${cloudUrl}`);
|
|
214
|
+
if (existsSync(nokia3gpPath)) console.log(`📱 Nokia 3GP: ${nokiaUrl}`);
|
|
215
|
+
|
|
216
|
+
// --- AUTO CLEANUP ---
|
|
217
|
+
console.log('\n🧹 [CLEANUP] Purging local temporary files...');
|
|
218
|
+
try {
|
|
219
|
+
if (existsSync(mp4Path)) unlinkSync(mp4Path);
|
|
220
|
+
const hlsDir = path.join('hls', slug);
|
|
221
|
+
if (existsSync(hlsDir)) rmSync(hlsDir, { recursive: true, force: true });
|
|
222
|
+
console.log(' ✅ Downloads & HLS Temporary folder cleared.');
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.log(' ⚠ Cleanup failed, please delete manually.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
console.log('\n👉 Copy link ở trên → paste vào Admin Panel nrotxa.online');
|
|
228
|
+
console.log(`└ ▣ MISSION ACCOMPLISHED ✔\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Tự khởi chạy nếu được gọi trực tiếp qua CLI
|
|
232
|
+
if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) || process.argv[1].endsWith('pipeline.mjs')) {
|
|
233
|
+
run().catch(err => {
|
|
234
|
+
console.error('\n❌ LỖI:', err.message);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
});
|
|
237
|
+
}
|
package/pro-terminal.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* NROTXA ULTIMATE CLI 2030 EDITION
|
|
4
|
+
* High-performance, Neon-infused Video Pipeline
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as p from '@clack/prompts';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import figlet from 'figlet';
|
|
10
|
+
import ora from 'ora';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { executePipeline } from './pipeline.mjs';
|
|
13
|
+
import 'dotenv/config';
|
|
14
|
+
|
|
15
|
+
// Color palette
|
|
16
|
+
const neonCyan = chalk.hex('#00f2ff');
|
|
17
|
+
const neonPurple = chalk.hex('#bc13fe');
|
|
18
|
+
const neonPink = chalk.hex('#ff00bd');
|
|
19
|
+
const electricBlue = chalk.hex('#2979ff');
|
|
20
|
+
|
|
21
|
+
function removeVietnameseTones(str) {
|
|
22
|
+
if (!str) return "";
|
|
23
|
+
return str.normalize("NFD")
|
|
24
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
25
|
+
.replace(/đ/g, "d").replace(/Đ/g, "D")
|
|
26
|
+
.replace(/[^a-zA-Z0-9\s-]/g, "")
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function checkEnv() {
|
|
31
|
+
const required = [
|
|
32
|
+
'TEBI_ENDPOINT',
|
|
33
|
+
'TEBI_ACCESS_KEY_ID',
|
|
34
|
+
'TEBI_SECRET_ACCESS_KEY',
|
|
35
|
+
'TEBI_BUCKET',
|
|
36
|
+
'TEBI_PUBLIC_URL'
|
|
37
|
+
];
|
|
38
|
+
const missing = required.filter(key => !process.env[key]);
|
|
39
|
+
if (missing.length > 0) {
|
|
40
|
+
console.log(chalk.red('\n❌ LỖI CẤU HÌNH:'));
|
|
41
|
+
console.log(chalk.yellow('Thiếu các biến môi trường sau trong file .env:'));
|
|
42
|
+
missing.forEach(m => console.log(` - ${m}`));
|
|
43
|
+
console.log(chalk.cyan('\nHướng dẫn:'));
|
|
44
|
+
console.log('1. Coppy file .env.example thành .env');
|
|
45
|
+
console.log('2. Điền đầy đủ thông tin tài khoản Tebi.io của bạn.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function showHelp() {
|
|
51
|
+
console.clear();
|
|
52
|
+
console.log(
|
|
53
|
+
neonCyan(
|
|
54
|
+
figlet.textSync('TPHIM', {
|
|
55
|
+
font: 'Slant',
|
|
56
|
+
horizontalLayout: 'default',
|
|
57
|
+
verticalLayout: 'default',
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v1.0 | HELP SYSTEM ⚡\n'));
|
|
63
|
+
|
|
64
|
+
console.log(`${neonPurple('📖 CÁCH SỬ DỤNG:')}`);
|
|
65
|
+
console.log(chalk.cyan(' ntxa help - Hiển thị help này'));
|
|
66
|
+
console.log(chalk.cyan(' ntxa - Chạy interactive mode'));
|
|
67
|
+
console.log(chalk.cyan(' ntxa "Tên Phim" - Chạy với tên phim mặc định\n'));
|
|
68
|
+
|
|
69
|
+
console.log(`${neonPurple('🔧 CẤU HÌNH BẮT BUỘC:')}`);
|
|
70
|
+
console.log(chalk.yellow(' File .env với các biến:'));
|
|
71
|
+
console.log(chalk.cyan(' TEBI_ENDPOINT - API endpoint Tebi.io'));
|
|
72
|
+
console.log(chalk.cyan(' TEBI_ACCESS_KEY_ID - Access key'));
|
|
73
|
+
console.log(chalk.cyan(' TEBI_SECRET_ACCESS_KEY - Secret key'));
|
|
74
|
+
console.log(chalk.cyan(' TEBI_BUCKET - Bucket name'));
|
|
75
|
+
console.log(chalk.cyan(' TEBI_PUBLIC_URL - Public URL\n'));
|
|
76
|
+
|
|
77
|
+
console.log(`${neonPurple('⚡ TÍNH NĂNG:')}`);
|
|
78
|
+
console.log(chalk.cyan(' • Download video từ multiple sources'));
|
|
79
|
+
console.log(chalk.cyan(' • Transcode sang HLS format'));
|
|
80
|
+
console.log(chalk.cyan(' • AI Subtitle generation (Whisper)'));
|
|
81
|
+
console.log(chalk.cyan(' • Upload cloud storage (Tebi.io)'));
|
|
82
|
+
console.log(chalk.cyan(' • Batch processing support'));
|
|
83
|
+
console.log(chalk.cyan(' • Multi-language subtitles (VI/EN)\n'));
|
|
84
|
+
|
|
85
|
+
console.log(`${neonPurple('👤 DEVELOPER:')}`);
|
|
86
|
+
console.log(chalk.cyan(' TXA - Ultimate Video Pipeline 2030'));
|
|
87
|
+
console.log(chalk.gray(' Licensed under ISC\n'));
|
|
88
|
+
|
|
89
|
+
console.log(`${neonPink('🚀 Ready to rock! Try: ntxa')}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
const args = process.argv.slice(2);
|
|
94
|
+
|
|
95
|
+
// Check for help command
|
|
96
|
+
if (args.includes('help') || args.includes('--help') || args.includes('-h')) {
|
|
97
|
+
showHelp();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
checkEnv();
|
|
102
|
+
console.clear();
|
|
103
|
+
|
|
104
|
+
const argTitle = args.join(' ');
|
|
105
|
+
|
|
106
|
+
// Header
|
|
107
|
+
console.log(
|
|
108
|
+
neonCyan(
|
|
109
|
+
figlet.textSync('NROTXA 2030', {
|
|
110
|
+
font: 'Slant',
|
|
111
|
+
horizontalLayout: 'default',
|
|
112
|
+
verticalLayout: 'default',
|
|
113
|
+
})
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v1.0 | STATUS: READY ⚡\n'));
|
|
118
|
+
|
|
119
|
+
p.intro(`${neonPurple('▣ SYSTEM INITIALIZED - BATCH MODE ENABLED')}`);
|
|
120
|
+
|
|
121
|
+
const group = await p.group(
|
|
122
|
+
{
|
|
123
|
+
input: () =>
|
|
124
|
+
p.text({
|
|
125
|
+
message: '🛸 Enter origin source(s):',
|
|
126
|
+
placeholder: 'link1, link2, link3...',
|
|
127
|
+
hint: chalk.yellow('Separate multiple links with COMMA (,)'),
|
|
128
|
+
validate: (value) => {
|
|
129
|
+
if (!value) return 'System requires a data source.';
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
title: () =>
|
|
133
|
+
p.text({
|
|
134
|
+
message: chalk.cyan('🎬 Movie designation(s):'),
|
|
135
|
+
initialValue: argTitle,
|
|
136
|
+
placeholder: 'Title 1, Title 2... (Matching order of links)',
|
|
137
|
+
hint: 'Leave blank to auto-detect titles for each link',
|
|
138
|
+
}),
|
|
139
|
+
lang: () =>
|
|
140
|
+
p.select({
|
|
141
|
+
message: '🌐 Quantum transcription language:',
|
|
142
|
+
options: [
|
|
143
|
+
{ value: 'vi', label: 'Tiếng Việt', hint: 'Default' },
|
|
144
|
+
{ value: 'en', label: 'English', hint: 'Secondary' },
|
|
145
|
+
{ value: 'both', label: 'Dual Core (VI+EN)', hint: 'Premium' },
|
|
146
|
+
],
|
|
147
|
+
}),
|
|
148
|
+
confirm: () =>
|
|
149
|
+
p.confirm({
|
|
150
|
+
message: '🚀 Initiate batch pipeline sequence now?',
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
onCancel: () => {
|
|
155
|
+
p.cancel('▣ SEQUENCE TERMINATED BY OPERATOR');
|
|
156
|
+
process.exit(0);
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (group.confirm) {
|
|
162
|
+
// Split inputs
|
|
163
|
+
const links = group.input.split(',').map(l => l.trim()).filter(l => l);
|
|
164
|
+
const titles = group.title ? group.title.split(',').map(t => t.trim()) : [];
|
|
165
|
+
|
|
166
|
+
console.log(chalk.cyan(`\n▣ DETECTED ${links.length} TARGETS. STARTING BATCH OPS...\n`));
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < links.length; i++) {
|
|
169
|
+
const inputUrl = links[i];
|
|
170
|
+
let currentTitle = titles[i] || '';
|
|
171
|
+
|
|
172
|
+
const s = p.spinner();
|
|
173
|
+
s.start(`${neonCyan(`[${i + 1}/${links.length}] PROCESSING:`)} ${inputUrl.substring(0, 40)}...`);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// If title is missing, try to fetch it
|
|
177
|
+
if (!currentTitle) {
|
|
178
|
+
try {
|
|
179
|
+
s.message('🛰️ Fetching metadata...');
|
|
180
|
+
const { execSync } = await import('child_process');
|
|
181
|
+
// Use a lighter check first
|
|
182
|
+
let fetchedTitle = execSync(
|
|
183
|
+
`yt-dlp --print title --skip-download "${inputUrl}"`,
|
|
184
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
185
|
+
).trim();
|
|
186
|
+
|
|
187
|
+
// Fallback: If title is 'master' or empty, use the URL slug
|
|
188
|
+
if (!fetchedTitle || fetchedTitle === 'master' || fetchedTitle === 'index') {
|
|
189
|
+
const parts = inputUrl.split('/').filter(p => p);
|
|
190
|
+
// Get second to last part if it looks like a slug
|
|
191
|
+
fetchedTitle = parts[parts.length - 2] || 'movie';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
currentTitle = fetchedTitle;
|
|
195
|
+
} catch (e) {
|
|
196
|
+
currentTitle = `movie-${Date.now()}`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const finalSlug = removeVietnameseTones(currentTitle).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
201
|
+
|
|
202
|
+
s.message(`${neonPurple('▣ EXECUTING PIPELINE:')} ${chalk.bold(currentTitle)}`);
|
|
203
|
+
|
|
204
|
+
await executePipeline({
|
|
205
|
+
input: inputUrl,
|
|
206
|
+
slug: finalSlug,
|
|
207
|
+
title: currentTitle,
|
|
208
|
+
langArg: group.lang
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
s.stop(`${neonPink(`✅ [${i + 1}/${links.length}] COMPLETE:`)} ${currentTitle}`);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
s.stop(`${chalk.red(`❌ [${i + 1}/${links.length}] FAILED:`)} ${inputUrl}`);
|
|
214
|
+
console.error(chalk.red(err.message));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
p.outro(`${neonPurple('▣ BATCH OPERATION FINISHED')} ${chalk.green('✔')}`);
|
|
219
|
+
} else {
|
|
220
|
+
p.cancel('▣ MISSION ABORTED');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// scripts/gen-readme.mjs
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import 'dotenv/config';
|
|
6
|
+
|
|
7
|
+
export function generateReadme(slug, mp4Path, info = {}) {
|
|
8
|
+
const hlsDir = path.join('hls', slug);
|
|
9
|
+
|
|
10
|
+
function countFiles(dir, ext) {
|
|
11
|
+
if (!fs.existsSync(dir)) return 0;
|
|
12
|
+
let count = 0;
|
|
13
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
+
const full = path.join(dir, item.name);
|
|
15
|
+
if (item.isDirectory()) count += countFiles(full, ext);
|
|
16
|
+
else if (item.name.endsWith(ext)) count++;
|
|
17
|
+
}
|
|
18
|
+
return count;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getVideoDuration(filePath) {
|
|
22
|
+
try {
|
|
23
|
+
const sec = parseFloat(
|
|
24
|
+
execSync(`ffprobe -i "${filePath}" -show_entries format=duration -v quiet -of csv=p=0`,
|
|
25
|
+
{ stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim()
|
|
26
|
+
);
|
|
27
|
+
const h = Math.floor(sec / 3600);
|
|
28
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
29
|
+
const s = Math.floor(sec % 60);
|
|
30
|
+
return `${h > 0 ? h + 'h ' : ''}${m}m ${s}s (${Math.round(sec)}s)`;
|
|
31
|
+
} catch { return 'N/A'; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getFolderSizeMB(dir) {
|
|
35
|
+
let total = 0;
|
|
36
|
+
if (!fs.existsSync(dir)) return '0 MB';
|
|
37
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
38
|
+
const full = path.join(dir, item.name);
|
|
39
|
+
if (item.isDirectory()) total += parseFloat(getFolderSizeMB(full));
|
|
40
|
+
else total += fs.statSync(full).size / (1024 * 1024);
|
|
41
|
+
}
|
|
42
|
+
return total.toFixed(1) + ' MB';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getStreamInfo(filePath) {
|
|
46
|
+
try {
|
|
47
|
+
const json = JSON.parse(execSync(
|
|
48
|
+
`ffprobe -v quiet -print_format json -show_streams "${filePath}"`,
|
|
49
|
+
{ stdio: ['pipe', 'pipe', 'pipe'] }).toString());
|
|
50
|
+
const video = json.streams.find(s => s.codec_type === 'video');
|
|
51
|
+
const audio = json.streams.find(s => s.codec_type === 'audio');
|
|
52
|
+
return {
|
|
53
|
+
resolution: video ? `${video.width}x${video.height}` : 'N/A',
|
|
54
|
+
videoCodec: video ? video.codec_name.toUpperCase() : 'N/A',
|
|
55
|
+
audioCodec: audio ? audio.codec_name.toUpperCase() : 'N/A',
|
|
56
|
+
fps: video ? Math.round(eval(video.r_frame_rate)) + ' fps' : 'N/A',
|
|
57
|
+
};
|
|
58
|
+
} catch { return { resolution: 'N/A', videoCodec: 'N/A', audioCodec: 'N/A', fps: 'N/A' }; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const duration = getVideoDuration(mp4Path);
|
|
62
|
+
const totalSize = getFolderSizeMB(hlsDir);
|
|
63
|
+
const tsCount = countFiles(hlsDir, '.ts');
|
|
64
|
+
const hasSub = fs.existsSync(path.join(hlsDir, 'vi.vtt'));
|
|
65
|
+
const hasSubEn = fs.existsSync(path.join(hlsDir, 'en.vtt'));
|
|
66
|
+
const streamInfo = getStreamInfo(mp4Path);
|
|
67
|
+
const now = new Date().toLocaleString('vi-VN', { timeZone: 'Asia/Ho_Chi_Minh' });
|
|
68
|
+
const publicUrl = process.env.TEBI_PUBLIC_URL || 'https://s3.tebi.io/nrotxa-videos';
|
|
69
|
+
|
|
70
|
+
const readme =
|
|
71
|
+
`================================================================================
|
|
72
|
+
THÔNG TIN FOLDER: hls/${slug}
|
|
73
|
+
Tạo lúc: ${now}
|
|
74
|
+
================================================================================
|
|
75
|
+
|
|
76
|
+
📽️ PHIM
|
|
77
|
+
Tên : ${info.title || slug}
|
|
78
|
+
Slug : ${slug}
|
|
79
|
+
Thời lượng : ${duration}
|
|
80
|
+
|
|
81
|
+
📦 FILE GỐC
|
|
82
|
+
File MP4 : ${path.basename(mp4Path)}
|
|
83
|
+
Resolution : ${streamInfo.resolution}
|
|
84
|
+
Video Codec : ${streamInfo.videoCodec}
|
|
85
|
+
Audio Codec : ${streamInfo.audioCodec}
|
|
86
|
+
FPS : ${streamInfo.fps}
|
|
87
|
+
|
|
88
|
+
📂 HLS OUTPUT (folder này)
|
|
89
|
+
Tổng dung lượng : ${totalSize}
|
|
90
|
+
Số segment .ts : ${tsCount} file
|
|
91
|
+
Segment length : ~6 giây/file
|
|
92
|
+
|
|
93
|
+
Chất lượng được tạo:
|
|
94
|
+
[1080p] 1920x1080 · Video 4500kbps · Audio 192kbps → folder 0\\
|
|
95
|
+
[720p] 1280x720 · Video 1500kbps · Audio 128kbps → folder 1\\
|
|
96
|
+
[360p] 640x360 · Video 600kbps · Audio 96kbps → folder 2\\
|
|
97
|
+
|
|
98
|
+
Master playlist : hls/${slug}/master.m3u8
|
|
99
|
+
→ Đây là link chính truyền vào TXAPlayer
|
|
100
|
+
|
|
101
|
+
📝 SUBTITLE
|
|
102
|
+
Tiếng Việt : ${hasSub ? 'hls/' + slug + '/vi.vtt ✅' : '❌ Chưa có'}
|
|
103
|
+
Tiếng Anh : ${hasSubEn ? 'hls/' + slug + '/en.vtt ✅' : '❌ Chưa có'}
|
|
104
|
+
Tạo bởi : faster-whisper model small (offline, CPU)
|
|
105
|
+
|
|
106
|
+
☁️ TEBI.IO (sau khi upload)
|
|
107
|
+
HLS URL : ${publicUrl}/hls/${slug}/master.m3u8
|
|
108
|
+
VTT (VI) : ${publicUrl}/hls/${slug}/vi.vtt
|
|
109
|
+
|
|
110
|
+
================================================================================
|
|
111
|
+
⚠️ Có thể xóa folder này sau khi upload lên Tebi.io thành công
|
|
112
|
+
================================================================================
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
const readmePath = path.join(hlsDir, 'README.txt');
|
|
116
|
+
fs.writeFileSync(readmePath, readme, 'utf8');
|
|
117
|
+
console.log(` ✅ README.txt saved: ${readmePath}`);
|
|
118
|
+
return readmePath;
|
|
119
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# scripts/gen_subtitle.py
|
|
2
|
+
# Usage: python gen_subtitle.py <video_path> <slug> [vi|en]
|
|
3
|
+
# Dùng faster-whisper chạy offline hoàn toàn, không cần API
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from faster_whisper import WhisperModel
|
|
10
|
+
|
|
11
|
+
def extract_audio(video_path, audio_path):
|
|
12
|
+
"""Trích audio nhẹ từ video để whisper xử lý nhanh hơn."""
|
|
13
|
+
print(" ↳ Extracting audio...")
|
|
14
|
+
subprocess.run([
|
|
15
|
+
"ffmpeg", "-i", video_path,
|
|
16
|
+
"-vn", "-acodec", "pcm_s16le",
|
|
17
|
+
"-ar", "16000", "-ac", "1",
|
|
18
|
+
audio_path, "-y"
|
|
19
|
+
], capture_output=True, check=True)
|
|
20
|
+
|
|
21
|
+
def transcribe(audio_path, lang="vi"):
|
|
22
|
+
"""Chạy faster-whisper model small — phù hợp máy 8GB RAM CPU."""
|
|
23
|
+
print(f" ↳ Loading whisper model 'small' (CPU mode)...")
|
|
24
|
+
print(f" ↳ Lần đầu sẽ tải model ~460MB, sau đó cache lại.")
|
|
25
|
+
|
|
26
|
+
# model small: ~1.5GB RAM, đủ cho máy 8GB
|
|
27
|
+
# compute_type int8: tối ưu cho CPU, giảm RAM
|
|
28
|
+
model = WhisperModel("small", device="cpu", compute_type="int8")
|
|
29
|
+
|
|
30
|
+
print(f" ↳ Transcribing [{lang}]... (CPU: mất 20-40 phút/phim dài)")
|
|
31
|
+
segments, info = model.transcribe(
|
|
32
|
+
audio_path,
|
|
33
|
+
language=lang,
|
|
34
|
+
beam_size=3, # giảm từ 5 → 3 để tiết kiệm RAM
|
|
35
|
+
vad_filter=True, # bỏ đoạn im lặng, nhanh hơn
|
|
36
|
+
vad_parameters=dict(min_silence_duration_ms=500)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return list(segments)
|
|
40
|
+
|
|
41
|
+
def segments_to_vtt(segments):
|
|
42
|
+
"""Chuyển segments sang định dạng WebVTT."""
|
|
43
|
+
vtt = "WEBVTT\n\n"
|
|
44
|
+
for i, seg in enumerate(segments, 1):
|
|
45
|
+
vtt += f"{i}\n"
|
|
46
|
+
vtt += f"{to_vtt_time(seg.start)} --> {to_vtt_time(seg.end)}\n"
|
|
47
|
+
vtt += f"{seg.text.strip()}\n\n"
|
|
48
|
+
return vtt
|
|
49
|
+
|
|
50
|
+
def to_vtt_time(seconds):
|
|
51
|
+
h = int(seconds // 3600)
|
|
52
|
+
m = int((seconds % 3600) // 60)
|
|
53
|
+
s = seconds % 60
|
|
54
|
+
return f"{h:02d}:{m:02d}:{s:06.3f}"
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
if len(sys.argv) < 3:
|
|
58
|
+
print("Usage: python gen_subtitle.py <video_path> <slug> [vi|en]")
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
video_path = sys.argv[1]
|
|
62
|
+
slug = sys.argv[2]
|
|
63
|
+
lang = sys.argv[3] if len(sys.argv) > 3 else "vi"
|
|
64
|
+
|
|
65
|
+
out_dir = Path("hls") / slug
|
|
66
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
audio_path = f"temp_audio_{slug}.wav"
|
|
69
|
+
|
|
70
|
+
print(f"\n📝 Generating subtitle [{lang}] for: {slug}")
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
extract_audio(video_path, audio_path)
|
|
74
|
+
segments = transcribe(audio_path, lang)
|
|
75
|
+
vtt = segments_to_vtt(segments)
|
|
76
|
+
|
|
77
|
+
vtt_path = out_dir / f"{lang}.vtt"
|
|
78
|
+
vtt_path.write_text(vtt, encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
print(f" ✅ Saved: {vtt_path}")
|
|
81
|
+
print(f" ✅ Total segments: {len(segments)}")
|
|
82
|
+
|
|
83
|
+
finally:
|
|
84
|
+
# Xóa file audio tạm dù có lỗi hay không
|
|
85
|
+
if os.path.exists(audio_path):
|
|
86
|
+
os.remove(audio_path)
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// scripts/upload-tebi.mjs
|
|
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';
|
|
5
|
+
import { join, extname, relative } from 'path';
|
|
6
|
+
import { lookup } from 'mime-types';
|
|
7
|
+
import 'dotenv/config';
|
|
8
|
+
|
|
9
|
+
const s3 = new S3Client({
|
|
10
|
+
region: 'us-east-1', // Tebi yêu cầu region này dù server ở EU
|
|
11
|
+
endpoint: process.env.TEBI_ENDPOINT, // https://s3.tebi.io
|
|
12
|
+
credentials: {
|
|
13
|
+
accessKeyId: process.env.TEBI_ACCESS_KEY_ID,
|
|
14
|
+
secretAccessKey: process.env.TEBI_SECRET_ACCESS_KEY,
|
|
15
|
+
},
|
|
16
|
+
forcePathStyle: true, // Tebi cần pathStyle, không dùng subdomain
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export async function uploadHLSFolder(slug) {
|
|
20
|
+
const localDir = join('hls', slug);
|
|
21
|
+
const files = getAllFiles(localDir);
|
|
22
|
+
|
|
23
|
+
console.log(`\n☁️ Uploading ${files.length} files to Tebi.io...`);
|
|
24
|
+
let uploaded = 0;
|
|
25
|
+
|
|
26
|
+
for (const filePath of files) {
|
|
27
|
+
const s3Key = relative('.', filePath).replace(/\\/g, '/');
|
|
28
|
+
const ext = extname(filePath).toLowerCase();
|
|
29
|
+
|
|
30
|
+
const cacheMap = {
|
|
31
|
+
'.ts': 'public, max-age=31536000',
|
|
32
|
+
'.m3u8': 'public, max-age=300',
|
|
33
|
+
'.vtt': 'public, max-age=86400',
|
|
34
|
+
'.txt': 'no-cache',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
await s3.send(new PutObjectCommand({
|
|
38
|
+
Bucket: process.env.TEBI_BUCKET,
|
|
39
|
+
Key: s3Key,
|
|
40
|
+
Body: readFileSync(filePath),
|
|
41
|
+
ACL: 'public-read', // Mở khóa file công khai ngay khi upload
|
|
42
|
+
ContentType: lookup(filePath) || 'application/octet-stream',
|
|
43
|
+
CacheControl: cacheMap[ext] || 'public, max-age=3600',
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
uploaded++;
|
|
47
|
+
process.stdout.write(`\r ↳ ${uploaded}/${files.length} files...`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const base = process.env.TEBI_PUBLIC_URL;
|
|
51
|
+
const masterName = `txa-${slug}.m3u8`;
|
|
52
|
+
const finalMasterUrl = `${base}/hls/${slug}/${masterName}`;
|
|
53
|
+
|
|
54
|
+
const result = {
|
|
55
|
+
masterUrl: finalMasterUrl,
|
|
56
|
+
vttVi: `${base}/hls/${slug}/vi.vtt`,
|
|
57
|
+
vttEn: `${base}/hls/${slug}/en.vtt`,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
console.log(`\n`);
|
|
61
|
+
console.log(`╔══════════════════════════════════════════════════════════════════╗`);
|
|
62
|
+
console.log(`║ ✅ UPLOAD HOÀN TẤT — Copy 2 link này vào Admin Panel ║`);
|
|
63
|
+
console.log(`╠══════════════════════════════════════════════════════════════════╣`);
|
|
64
|
+
console.log(`║ HLS : ${result.masterUrl}`);
|
|
65
|
+
console.log(`║ VTT : ${result.vttVi}`);
|
|
66
|
+
console.log(`╚══════════════════════════════════════════════════════════════════╝`);
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getAllFiles(dir) {
|
|
72
|
+
const result = [];
|
|
73
|
+
for (const item of readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
const full = join(dir, item.name);
|
|
75
|
+
if (item.isDirectory()) result.push(...getAllFiles(full));
|
|
76
|
+
else result.push(full);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
// Tự chạy nếu gọi trực tiếp
|
|
81
|
+
if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) || process.argv[1].endsWith('upload-tebi.mjs')) {
|
|
82
|
+
const slug = process.argv[2];
|
|
83
|
+
if (slug) {
|
|
84
|
+
uploadHLSFolder(slug).catch(console.error);
|
|
85
|
+
} else {
|
|
86
|
+
console.log("Usage: node upload-tebi.mjs <slug>");
|
|
87
|
+
}
|
|
88
|
+
}
|