nexus-typo-mcp 1.3.2 → 1.3.3
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 +10 -6
- package/cli/nexus-typo.mjs +52 -18
- package/mcp/huong-dan-may.md +19 -2
- package/mcp/may.mjs +3 -2
- package/package.json +1 -1
- package/src/thiet-bi.mjs +57 -5
- package/src/xuat/ghep.mjs +99 -51
- package/src/xuat/soat.mjs +35 -11
package/README.md
CHANGED
|
@@ -63,19 +63,23 @@ onnxruntime và tải mô hình vào `~/.nexus-typo/` (khoảng 300 MB).
|
|
|
63
63
|
|
|
64
64
|
## Gửi file đi
|
|
65
65
|
|
|
66
|
-
`render` với `share: true` ra thêm bản gửi `_share.mp4` (bitrate bằng video gốc) cạnh bản chính,
|
|
67
|
-
|
|
66
|
+
`render` với `share: true` ra thêm bản gửi `_share.mp4` (bitrate bằng video gốc) cạnh bản chính, làm sau bản chính
|
|
67
|
+
(mỗi lúc một bộ mã hoá, đỡ RAM); `max_mb` khai trần dung lượng (có `share` thì áp vào bản gửi).
|
|
68
68
|
Tải lên một lần (GitHub Release, kho nhận file qua một kết nối) chỉ hợp file dưới khoảng 500 MB.
|
|
69
69
|
|
|
70
|
-
Lệnh chạy
|
|
70
|
+
Lệnh chạy cho từng file ra: bản chính ngay khi tự soát ĐẠT (trước khi làm bản gửi), bản gửi khi bản gửi xong. Ghi ở
|
|
71
|
+
`~/.nexus-typo/may.json`:
|
|
71
72
|
|
|
72
73
|
```json
|
|
73
74
|
{ "post_export_cmd": ["rclone", "copy", "{share}", "drive:video-ra"] }
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
Dạng mảng chạy thẳng
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
Dạng mảng chạy thẳng: lượt bản chính thay `{video}` và bỏ tham số có `{share}`, lượt bản gửi thay `{share}` và bỏ
|
|
78
|
+
tham số có `{video}`; lệnh không nhắc file của lượt nào thì bỏ qua lượt đó (ví dụ trên chỉ gửi bản gửi), không nhắc file
|
|
79
|
+
nào thì chạy một lần sau bản chính. Dạng chuỗi chạy qua shell mỗi lượt, đường file ở biến môi trường `NT_FILE` (file của
|
|
80
|
+
lượt), `NT_BAN` (`chinh`, `gui`). Lệnh chỉ đọc từ file này, công cụ MCP không nhận lệnh.
|
|
81
|
+
|
|
82
|
+
Bước ghép thiếu RAM thì tự chờ, đo lại mỗi 30 s, tối đa 15 phút (`--cho-ram <phút>` ở CLI), rồi báo RAM đang bị giữ ở đâu.
|
|
79
83
|
|
|
80
84
|
## Biến môi trường
|
|
81
85
|
|
package/cli/nexus-typo.mjs
CHANGED
|
@@ -14,11 +14,11 @@ import { phuDe } from '../src/phude/buoc.mjs';
|
|
|
14
14
|
import { ve, xem } from '../src/ve/buoc.mjs';
|
|
15
15
|
import { sfx } from '../src/sfx/buoc.mjs';
|
|
16
16
|
import { nhac, dsBai } from '../src/nhac/buoc.mjs';
|
|
17
|
-
import { ghep, thamSoHinh, lenhSauXuat } from '../src/xuat/ghep.mjs';
|
|
17
|
+
import { ghep, ghepGui, thamSoHinh, lenhSauXuat, CHO_RAM_PHUT } from '../src/xuat/ghep.mjs';
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
19
|
import { docThietBi, canDia, kiemTruocDung, tomTatMay } from '../src/thiet-bi.mjs';
|
|
20
20
|
import { timTrinhDuyet } from '../src/trinh-duyet.mjs';
|
|
21
|
-
import { soat } from '../src/xuat/soat.mjs';
|
|
21
|
+
import { soat, soatGui } from '../src/xuat/soat.mjs';
|
|
22
22
|
import { tepRa } from '../src/thu-muc.mjs';
|
|
23
23
|
import { ghiDist, soDist } from '../src/dist/dung.mjs';
|
|
24
24
|
import { bang, thu } from '../src/duyet/duyet.mjs';
|
|
@@ -46,12 +46,13 @@ const GIUP = `nexus-typo <lệnh> [tham số]
|
|
|
46
46
|
nhac <video> [--che-do giu|them|doi] [--nhac <file>] [--nhac-tu <giây>] [--duoi <min-max>] [--bpm <nhịp>] [--voice <file>] [--nhip vua|nhanh|cham]
|
|
47
47
|
Bước 8b: nhạc (giữ, thêm, đổi) + SFX + master -> am-thanh.wav, nhac.json
|
|
48
48
|
nhac-ds [--dai <giây>] [--nhip vua] Xếp bài thư viện nhạc theo nhịp (đo phách lần đầu lâu, sau dùng lại)
|
|
49
|
-
xuat <video> [--nhe] [--gui] [--max-mb <số>]
|
|
49
|
+
xuat <video> [--nhe] [--gui] [--max-mb <số>] [--cho-ram <phút>]
|
|
50
50
|
Bước 9: ghép lớp chữ + tiếng vào video, cùng chuẩn input (--nhe: bitrate bằng input;
|
|
51
|
-
--gui: ra thêm bản gửi _share.mp4
|
|
52
|
-
lượng, có --gui thì áp vào bản gửi, không thì vào bản chính
|
|
51
|
+
--gui: ra thêm bản gửi _share.mp4 sau bản chính (bản chính giữ nguyên); --max-mb: trần dung
|
|
52
|
+
lượng, có --gui thì áp vào bản gửi, không thì vào bản chính; thiếu RAM thì chờ, đo lại mỗi
|
|
53
|
+
30 s, tối đa --cho-ram phút (mặc định ${CHO_RAM_PHUT}, 0 là không chờ))
|
|
53
54
|
soat <video> [--nhe] Bước 10: tự soát video ra -> bao-cao.md
|
|
54
|
-
dung <video> [--che-do tu-dong|duyet] [--nhac-che-do giu|them|doi] [--nhac <file>] [--nhac-tu <giây>] [--duoi <min-max>] [--bpm <nhịp>] [--voice <file>] [--nhe] [--gui] [--max-mb <số>]
|
|
55
|
+
dung <video> [--che-do tu-dong|duyet] [--nhac-che-do giu|them|doi] [--nhac <file>] [--nhac-tu <giây>] [--duoi <min-max>] [--bpm <nhịp>] [--voice <file>] [--nhe] [--gui] [--max-mb <số>] [--cho-ram <phút>]
|
|
55
56
|
Bước 5 đến 10 liền: kiem, phu-de, (duyet: ra ảnh duyệt rồi dừng), ve, nhac, xuat, soat
|
|
56
57
|
bang [--style M3,M15] [--nen <ảnh|video>] [--tu <giây>] [--mau vang|cyan|kem-dao] [--nen-sang] [--cau "dẫn|NHẤN|bổ sung|dẫn 2"] [--mau-nhan <mã|tat-ca>] [--ra <file.jpg>]
|
|
57
58
|
Bảng style trên nền thật (gồm style chưa duyệt), cho vòng duyệt style mới
|
|
@@ -202,9 +203,8 @@ const LENH = {
|
|
|
202
203
|
async xuat({ vi, tuy }) {
|
|
203
204
|
const video = vi[0];
|
|
204
205
|
if (!video) throw new Error('Thiếu đường dẫn video: nexus-typo xuat <video>');
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (kq.gui) console.log(`Bản gửi: ${kq.gui.ra} (${kq.gui.bitrateM} Mbit/s, ${kq.gui.mb} MB)`);
|
|
206
|
+
await xuatChinh(video, tuy);
|
|
207
|
+
if (tuy.gui) await xuatGui(video, tuy);
|
|
208
208
|
},
|
|
209
209
|
async soat({ vi, tuy }) {
|
|
210
210
|
const video = vi[0];
|
|
@@ -232,7 +232,7 @@ const LENH = {
|
|
|
232
232
|
const tb = docThietBi([thuMucLamViec(video), path.dirname(tepRa(video, '.mp4', { tao: false }))], { trinhDuyet: timTrinhDuyet(may.chrome) });
|
|
233
233
|
const gui = !!tuy.gui, maxMb = soTran(tuy);
|
|
234
234
|
const tsGui = gui ? thamSoHinh(vao, { nvenc: false, nhe: true, maxMb }) : null;
|
|
235
|
-
const can = canDia({ W: vao.hinh.rong, H: vao.hinh.cao, T: vao.dai || vao.hinh.dai || 0, bitrateM: thamSoHinh(vao, { nvenc: false, nhe: !!tuy.nhe, maxMb: gui ? null : maxMb }).bitrateM + (tsGui?.bitrateM || 0) });
|
|
235
|
+
const can = canDia({ W: vao.hinh.rong, H: vao.hinh.cao, T: vao.dai || vao.hinh.dai || 0, nhan: tb.nhan, bitrateM: thamSoHinh(vao, { nvenc: false, nhe: !!tuy.nhe, maxMb: gui ? null : maxMb }).bitrateM + (tsGui?.bitrateM || 0) });
|
|
236
236
|
const k = kiemTruocDung(tb, can);
|
|
237
237
|
ghiJson(path.join(thuMucLamViec(video), 'may-doc.json'), { ...tb, can, ...k });
|
|
238
238
|
console.log('máy: ' + tomTatMay(tb));
|
|
@@ -249,10 +249,22 @@ const LENH = {
|
|
|
249
249
|
}
|
|
250
250
|
await buoc('ve', () => LENH.ve({ vi, tuy: {} }));
|
|
251
251
|
await buoc('nhac', () => LENH.nhac({ vi, tuy: { 'che-do': tuy['nhac-che-do'], nhac: tuy.nhac, 'nhac-tu': tuy['nhac-tu'], duoi: tuy.duoi, bpm: tuy.bpm, voice: tuy.voice, nhip: tuy.nhip } }));
|
|
252
|
-
|
|
252
|
+
// P69 (anh chốt 03/11/2026): bản chính trước, soát, gửi (lệnh sau xuất) xong mới làm bản gửi; mỗi lượt một bộ mã hoá
|
|
253
|
+
const tx = { nhe: tuy.nhe, gui: tuy.gui, 'max-mb': tuy['max-mb'], 'cho-ram': tuy['cho-ram'] };
|
|
254
|
+
await buoc('xuat', () => xuatChinh(video, tx));
|
|
253
255
|
await buoc('soat', () => LENH.soat({ vi, tuy: { nhe: tuy.nhe } }));
|
|
254
|
-
const may = docMay({ kiem: false });
|
|
255
|
-
if (may.sauXuat) { console.log('\n== sau xuất'); await chaySauXuat(may,
|
|
256
|
+
const may = docMay({ kiem: false }), tm = thuMucLamViec(video);
|
|
257
|
+
if (may.sauXuat) { console.log('\n== sau xuất, bản chính'); await chaySauXuat(may, tm, 'chinh'); }
|
|
258
|
+
if (!tuy.gui) return;
|
|
259
|
+
await buoc('xuat bản gửi', () => xuatGui(video, tx));
|
|
260
|
+
const g = await soatGui(may, tm, { inRa: (x) => console.log(' ' + x) });
|
|
261
|
+
if (!g.dat) {
|
|
262
|
+
for (const x of g.loi) console.error(' LỖI: ' + x);
|
|
263
|
+
console.log(' cảnh báo: bản gửi chưa đạt, không chạy lệnh sau xuất cho bản gửi; bản chính đã xong, đã soát');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
console.log('Bản gửi: thông số ngoài bitrate trùng input');
|
|
267
|
+
if (may.sauXuat) { console.log('\n== sau xuất, bản gửi'); await chaySauXuat(may, tm, 'gui'); }
|
|
256
268
|
},
|
|
257
269
|
async bang({ tuy }) {
|
|
258
270
|
const styles = typeof tuy.style === 'string' ? tuy.style.split(',').map((x) => x.trim()) : null;
|
|
@@ -307,10 +319,32 @@ function soTran(tuy) {
|
|
|
307
319
|
return n;
|
|
308
320
|
}
|
|
309
321
|
|
|
310
|
-
|
|
311
|
-
|
|
322
|
+
function soPhutCho(tuy) {
|
|
323
|
+
if (tuy['cho-ram'] == null) return CHO_RAM_PHUT;
|
|
324
|
+
const n = Number(tuy['cho-ram']);
|
|
325
|
+
if (!(n >= 0)) throw new Error('--cho-ram là số phút ≥ 0, vd --cho-ram 30');
|
|
326
|
+
return n;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function xuatChinh(video, tuy) {
|
|
330
|
+
const kq = await ghep(docMay(), thuMucLamViec(video), { nhe: !!tuy.nhe, gui: !!tuy.gui, maxMb: soTran(tuy), choRamPhut: soPhutCho(tuy), inRa: (x) => console.log(' ' + x) });
|
|
331
|
+
console.log(`Đã xuất: ${kq.ra} (${kq.hinh.boMaHoa} ${kq.hinh.bitrateM} Mbit/s, ${kq.mb} MB, ${kq.giay} s)`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function xuatGui(video, tuy) {
|
|
335
|
+
const g = await ghepGui(docMay(), thuMucLamViec(video), { maxMb: soTran(tuy), choRamPhut: soPhutCho(tuy), inRa: (x) => console.log(' ' + x) });
|
|
336
|
+
console.log(`Bản gửi: ${g.ra} (${g.bitrateM} Mbit/s, ${g.mb} MB, ${g.giay} s)`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Lệnh sau xuất (P66): chỉ chạy khi tự soát đạt; lỗi thì báo, video ra vẫn giữ. Không in nội dung lệnh (có thể chứa khoá).
|
|
340
|
+
* P69: mỗi file một lượt (ban chinh, gui), kết quả ghi vào sau-xuat.json theo ban. */
|
|
341
|
+
async function chaySauXuat(may, thuMuc, ban) {
|
|
312
342
|
const xuat = JSON.parse(fs.readFileSync(path.join(thuMuc, 'xuat.json'), 'utf8'));
|
|
313
|
-
const
|
|
343
|
+
const tepSau = path.join(thuMuc, 'sau-xuat.json');
|
|
344
|
+
const cu = fs.existsSync(tepSau) ? JSON.parse(fs.readFileSync(tepSau, 'utf8')) : {};
|
|
345
|
+
const tep = ban === 'gui' ? xuat.gui?.ra : xuat.chinh.ra;
|
|
346
|
+
const l = lenhSauXuat(may.sauXuat, { video: xuat.chinh.ra, share: xuat.gui?.ra || null, ban });
|
|
347
|
+
if (l.boQua) { console.log(` lệnh sau xuất không nhắc file ${ban === 'gui' ? 'bản gửi ({share})' : 'bản chính ({video})'}: bỏ qua lượt này`); ghiJson(tepSau, { ...cu, [ban]: { boQua: true, tep } }); return; }
|
|
314
348
|
console.log(` chạy lệnh sau xuất (post_export_cmd ở ${may.tep})…`);
|
|
315
349
|
const bd = Date.now();
|
|
316
350
|
const ma = await new Promise((xong) => {
|
|
@@ -320,8 +354,8 @@ async function chaySauXuat(may, thuMuc) {
|
|
|
320
354
|
p.on('error', (e) => { console.log(' ' + e.message); xong(-1); });
|
|
321
355
|
p.on('close', (c) => xong(c ?? -1));
|
|
322
356
|
});
|
|
323
|
-
const kq = { ma, giay: +((Date.now() - bd) / 1000).toFixed(1),
|
|
324
|
-
ghiJson(
|
|
357
|
+
const kq = { ma, giay: +((Date.now() - bd) / 1000).toFixed(1), tep };
|
|
358
|
+
ghiJson(tepSau, { ...cu, [ban]: kq });
|
|
325
359
|
console.log(ma === 0 ? ` lệnh sau xuất xong (${kq.giay} s)` : ` cảnh báo: lệnh sau xuất lỗi (mã ${ma}); video ra vẫn ở ket-qua, gửi tay hoặc sửa lệnh rồi chạy lại`);
|
|
326
360
|
}
|
|
327
361
|
|
package/mcp/huong-dan-may.md
CHANGED
|
@@ -40,10 +40,27 @@ Sửa sau khi người dùng góp ý: sửa kịch bản, `validate_script`, `re
|
|
|
40
40
|
|
|
41
41
|
## Gửi file đi
|
|
42
42
|
|
|
43
|
-
- Bản chính giữ chất lượng cao nên nặng (video 3 phút khổ 2K có thể hơn 1 GB). Người dùng nói cần gửi file qua mạng thì `render` với `share: true`: ra thêm `_share.mp4` (bitrate bằng video gốc)
|
|
43
|
+
- Bản chính giữ chất lượng cao nên nặng (video 3 phút khổ 2K có thể hơn 1 GB). Người dùng nói cần gửi file qua mạng thì `render` với `share: true`: ra thêm `_share.mp4` (bitrate bằng video gốc) sau bản chính, bản chính không đổi, `video_share` là đường file gửi. Bản chính xong (tự soát, lệnh gửi nếu có) rồi mới mã hoá bản gửi, mỗi lúc một bộ mã hoá cho đỡ RAM; tốn thêm một lượt mã hoá (máy yếu thêm vài phút).
|
|
44
44
|
- Kênh nhận có giới hạn thì thêm `max_mb` (vd 450 cho kênh nhận tới 500 MB): có `share` thì trần áp vào bản gửi; không có `share` thì hạ bitrate bản chính, kém nét hơn, nên ưu tiên `share`.
|
|
45
45
|
- Tải lên một lần (GitHub Release, kho nhận file qua một kết nối) chỉ hợp file dưới khoảng 500 MB; kết nối qua proxy hay đứt sau vài phút. Drive trên web, ứng dụng Drive trên máy tải lại được khi đứt.
|
|
46
|
-
- Máy có lệnh gửi riêng (vd rclone lên Drive) thì người dùng tự ghi `post_export_cmd` vào `~/.nexus-typo/may.json`; tool chạy lệnh đó
|
|
46
|
+
- Máy có lệnh gửi riêng (vd rclone lên Drive) thì người dùng tự ghi `post_export_cmd` vào `~/.nexus-typo/may.json`; tool chạy lệnh đó cho từng file: bản chính ngay khi tự soát ĐẠT, bản gửi khi bản gửi xong; kết quả ở `post_export.chinh`, `post_export.gui` (`ma` 0 là xong). Lệnh này chỉ đọc từ file cấu hình, không truyền qua công cụ, đừng tự ghi file đó thay người dùng.
|
|
47
|
+
|
|
48
|
+
## Thiếu RAM
|
|
49
|
+
|
|
50
|
+
- Bước ghép cần RAM theo khổ video (khoảng 1,3 GB ở 1440x2560, 0,8 GB ở 1080x1920). Thiếu thì tool tự chờ, đo lại mỗi 30 s, tối đa 15 phút; vẫn thiếu thì báo lỗi kèm dòng "RAM máy ...": tiến trình nào giữ, bao nhiêu GB không thuộc tiến trình nào.
|
|
51
|
+
- Phần không thuộc tiến trình nào là máy ảo, hệ thống giữ, không dọn được từ trong máy. Đừng ghép tay bằng ffmpeg (cùng thông số thì cần cùng chừng đó RAM; hạ thông số thì video kém nét). Chờ máy khác, máy mới rồi `render` lại: lớp chữ, tiếng đã dựng được dùng lại, chỉ còn bước ghép.
|
|
52
|
+
|
|
53
|
+
## Chạy trên máy ảo trên mây (AI tự vận hành, không người ngồi máy)
|
|
54
|
+
|
|
55
|
+
Phần này cho AI chạy Nexus Typo trong máy ảo của nền tảng (vd Muse): người dùng chỉ giao việc qua tin nhắn, máy do hệ thống cấp và có thể bị thay giữa chừng.
|
|
56
|
+
|
|
57
|
+
- **Đầu lượt:** `check_setup`, ghi lại RAM dùng được, ổ trống, số nhân. RAM dùng được dưới mức bước ghép cần (mục Thiếu RAM) thì báo người dùng ngay, trước khi dựng.
|
|
58
|
+
- **Chỗ để file:** video và thư mục làm việc để trên ổ đĩa bền qua lần thay máy (thư mục làm việc của nền tảng), không để ở `/tmp` (thường là ổ RAM nhỏ, mất khi thay máy). Thư mục `<tên video>.nexus-typo` cạnh video giữ lời đã chép, lớp chữ đã dựng: đừng xoá, đừng dời.
|
|
59
|
+
- **Máy bị thay giữa chừng:** việc thành `interrupted` hoặc `job_id` không còn. Gọi lại `render` cùng tham số, tool dùng lại lớp chữ, tiếng đã dựng, chỉ làm phần còn thiếu. Đừng mở việc mới khi `job_status` còn `running`.
|
|
60
|
+
- **Canh việc:** `job_status` với `wait_seconds` 50. Tự đặt lịch kiểm thì 5 đến 10 phút một lần; mỗi lần báo người dùng ngắn: đang ở bước nào, giờ còn lại (dòng giờ còn lại trong `log`).
|
|
61
|
+
- **Thiếu RAM:** tool tự chờ 15 phút rồi mới báo lỗi, đọc dòng "RAM máy ..." trong `log`. Phần "không thuộc tiến trình nào" lớn là máy ảo giữ: đừng cố dọn (`swapon`, `drop_caches`, tắt tiến trình hệ thống thường bị cấm và không giúp), đừng ghép tay bằng ffmpeg, đừng hạ thông số. Báo người dùng số đo, chờ máy mới (máy vừa lên thường còn nhiều RAM nhất); việc đầu tiên trên máy mới là `render` lại, chưa làm việc nặng khác trước.
|
|
62
|
+
- **Đưa file ra khỏi máy ảo:** `render` với `share: true`. Bản chính xong, tự soát ĐẠT thì lệnh gửi (`post_export_cmd`) chạy cho bản chính trước, bản gửi làm sau rồi gửi tiếp; nên giao bản chính cho người dùng ngay khi `post_export.chinh` xong, khỏi chờ bản gửi. Kênh tải lên một kết nối thì gửi bản gửi dưới 500 MB (`max_mb`); kết nối hay đứt sau vài phút nên dùng cách tải lại được khi đứt (chia phần, tải tiếp).
|
|
63
|
+
- **Báo kết quả:** đường file, MB (`mb`, `share_mb`), tự soát ĐẠT hay chưa, RAM dùng được lúc ghép (dòng "ghép bản chính" trong `log`), lệnh gửi xong chưa (`post_export`).
|
|
47
64
|
|
|
48
65
|
## Quy tắc cứng
|
|
49
66
|
|
package/mcp/may.mjs
CHANGED
|
@@ -367,7 +367,7 @@ const TOOLS = [
|
|
|
367
367
|
music_start: { type: 'number', description: 'giây bắt đầu lấy trong bài nhạc' },
|
|
368
368
|
voice_file: { type: 'string', description: 'rãnh giọng đọc riêng (xuất từ CapCut) khi đổi nhạc' },
|
|
369
369
|
light: { type: 'boolean', description: 'bản nhẹ (bitrate bằng video vào) để gửi Zalo' },
|
|
370
|
-
share: { type: 'boolean', description: 'ra thêm bản gửi _share.mp4 (bitrate bằng video vào, có max_mb thì theo trần)
|
|
370
|
+
share: { type: 'boolean', description: 'ra thêm bản gửi _share.mp4 (bitrate bằng video vào, có max_mb thì theo trần) sau bản chính, bản chính giữ nguyên chất lượng; bật khi người dùng cần gửi file qua mạng' },
|
|
371
371
|
max_mb: { type: 'number', description: 'trần dung lượng MB: có share thì áp vào bản gửi, không thì hạ bitrate bản chính (kém nét hơn); vd 450 để qua kênh nhận tới 500 MB' },
|
|
372
372
|
wait_seconds: CHO } } },
|
|
373
373
|
{ name: 'job_status', handler: jobStatus,
|
|
@@ -400,7 +400,8 @@ export async function xuLy(msg) {
|
|
|
400
400
|
return ok(id, { protocolVersion: (params && params.protocolVersion) || '2025-06-18',
|
|
401
401
|
capabilities: { tools: {} }, serverInfo: { name: 'nexus-typo', title: 'Nexus Typo', version: PKG.version },
|
|
402
402
|
instructions: 'Nexus Typo trên máy: thêm tiêu đề chữ (typo), phụ đề, tiếng động vào video BĐS dọc 9:16 đã dựng xong, ra video MP4 ngay trên máy. ' +
|
|
403
|
-
'Gọi get_guide (topic "may") trước. Thứ tự: prepare_video, fix_transcript nếu tên riêng sai, viết kịch bản, validate_script tới khi đạt, previews nếu người dùng muốn duyệt, render rồi job_status tới khi xong.' +
|
|
403
|
+
'Gọi get_guide (topic "may") trước. Thứ tự: prepare_video, fix_transcript nếu tên riêng sai, viết kịch bản, validate_script tới khi đạt, previews nếu người dùng muốn duyệt, render rồi job_status tới khi xong. ' +
|
|
404
|
+
'Chạy trong máy ảo trên mây không người ngồi máy (máy do nền tảng cấp, có thể bị thay giữa chừng): theo mục "Chạy trên máy ảo trên mây" của get_guide.' +
|
|
404
405
|
(banMoi ? ` Có bản mới ${banMoi} (đang chạy ${PKG.version}): nhắc người dùng khởi động lại ứng dụng AI để npx lấy bản mới.` : '') });
|
|
405
406
|
case 'ping': return ok(id, {});
|
|
406
407
|
case 'tools/list':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexus-typo-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "MCP server cho agent AI thêm tiêu đề chữ, phụ đề, tiếng động vào video bất động sản dọc 9:16 và xuất MP4 ngay trên máy (Nexus Typo, typo.nexusone.vn)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
package/src/thiet-bi.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
6
7
|
|
|
7
8
|
export const GB = 1024 ** 3;
|
|
8
9
|
const gb = (x) => (x == null || !Number.isFinite(x) ? null : +(x / GB).toFixed(1));
|
|
@@ -33,7 +34,7 @@ export function oTrong(thuMuc) {
|
|
|
33
34
|
export function docThietBi(thuMuc = [], { trinhDuyet = null } = {}) {
|
|
34
35
|
const ds = [thuMuc].flat().filter(Boolean);
|
|
35
36
|
return {
|
|
36
|
-
nhan:
|
|
37
|
+
nhan: soNhan(),
|
|
37
38
|
cpu: ((os.cpus()[0] || {}).model || '').trim() || null,
|
|
38
39
|
ramGB: gb(os.totalmem()), ramTrongGB: gb(ramDungDuoc()),
|
|
39
40
|
oDia: [...new Set(ds.map((d) => path.resolve(d)))].map((d) => ({ thuMuc: d, trongGB: gb(oTrong(d)) })),
|
|
@@ -41,11 +42,62 @@ export function docThietBi(thuMuc = [], { trinhDuyet = null } = {}) {
|
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/** RAM bước ghép cần (byte) cho một bộ mã hoá libx265 medium, giải mã đầu vào 2 luồng (P69). Đo 03/11/2026 trên máy nhà,
|
|
46
|
+
* ffmpeg -benchmark maxrss, clip 10 s: 1440x2560 4 luồng 1,10 GB, 12 luồng 1,21 GB; 1080x1920 4 luồng 0,67 GB, 12 luồng 0,75 GB.
|
|
47
|
+
* Cộng 20 % dự phòng (Linux, mặt nạ chữ sau người). NVENC cần ít hơn, dùng chung số này cho chắc. */
|
|
48
|
+
export function ramGhep({ W, H, nhan = 4 }) {
|
|
49
|
+
const mb = (120 + 270 * (W * H) / 1e6) * (1 + 0.012 * Math.max(0, nhan - 4));
|
|
50
|
+
return Math.round(mb * 1.2 * 1024 ** 2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const soNhan = () => (typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length);
|
|
54
|
+
|
|
55
|
+
/** Linux: đẩy file đang ghi dở xuống đĩa trước khi đo RAM (máy ảo Muse: sau sync RAM dùng được tăng khoảng 0,3 GB). */
|
|
56
|
+
export function dongBo() {
|
|
57
|
+
if (process.platform !== 'win32') { try { spawnSync('sync', [], { timeout: 60000 }); } catch { /* không có sync thì thôi */ } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Ai đang giữ RAM (P69): tiến trình giữ nhiều nhất, ổ RAM (tmpfs), phần không thuộc tiến trình nào. Hàm thuần. */
|
|
61
|
+
export function tomRam({ tong, dung, ds = [], meminfo = null }) {
|
|
62
|
+
const kb = (k) => { const m = meminfo && new RegExp(`^${k}:\\s+(\\d+)\\s+kB`, 'm').exec(meminfo); return m ? +m[1] * 1024 : 0; };
|
|
63
|
+
const tienTrinh = ds.reduce((t, x) => t + x.byte, 0), shmem = kb('Shmem');
|
|
64
|
+
return {
|
|
65
|
+
tongGB: gb(tong), dungDuocGB: gb(dung), tienTrinhGB: gb(tienTrinh), shmemGB: gb(shmem),
|
|
66
|
+
ngoaiGB: gb(Math.max(0, tong - dung - tienTrinh - shmem)),
|
|
67
|
+
top: [...ds].sort((a, b) => b.byte - a.byte).slice(0, 5).map((x) => ({ ten: x.ten, pid: x.pid, gb: gb(x.byte) })),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Một dòng báo RAM cho người dùng, AI đọc. Hàm thuần. */
|
|
72
|
+
export function dongRam(b) {
|
|
73
|
+
const top = b.top.filter((x) => x.gb >= 0.1).map((x) => `${x.ten} PID ${x.pid} ${x.gb} GB`).join(', ');
|
|
74
|
+
let s = `RAM máy ${b.tongGB} GB, dùng được ${b.dungDuocGB} GB; các tiến trình giữ ${b.tienTrinhGB} GB${top ? ` (nhiều nhất: ${top})` : ''}`;
|
|
75
|
+
if (b.shmemGB >= 0.1) s += `; ổ RAM (tmpfs, bộ nhớ chia sẻ) ${b.shmemGB} GB`;
|
|
76
|
+
if (b.ngoaiGB >= 1) s += `; ${b.ngoaiGB} GB không thuộc tiến trình nào (máy ảo, hệ thống giữ): không dọn được từ trong máy, chờ máy mới hoặc ghép ở máy khác (thư mục làm việc giữ nguyên, chỉ chạy lại bước xuất)`;
|
|
77
|
+
return s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Đọc RAM thật của máy cho dongRam. */
|
|
81
|
+
export function baoRam() {
|
|
82
|
+
let ds = [], meminfo = null;
|
|
83
|
+
try {
|
|
84
|
+
if (process.platform === 'win32') {
|
|
85
|
+
const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'Get-Process | ForEach-Object { "$($_.WorkingSet64)`t$($_.Id)`t$($_.ProcessName)" }'], { encoding: 'utf8', windowsHide: true, timeout: 20000 });
|
|
86
|
+
ds = (r.stdout || '').split(/\r?\n/).map((x) => x.split('\t')).filter((x) => x.length === 3).map(([b, pid, ten]) => ({ ten, pid: +pid, byte: +b }));
|
|
87
|
+
} else {
|
|
88
|
+
const r = spawnSync('ps', ['-eo', 'rss=,pid=,comm='], { encoding: 'utf8', timeout: 20000 });
|
|
89
|
+
ds = (r.stdout || '').split('\n').map((x) => x.trim().split(/\s+/)).filter((x) => x.length >= 3).map(([k, pid, ...t]) => ({ ten: t.join(' '), pid: +pid, byte: +k * 1024 }));
|
|
90
|
+
meminfo = fs.readFileSync('/proc/meminfo', 'utf8');
|
|
91
|
+
}
|
|
92
|
+
} catch { /* đọc được tới đâu báo tới đó */ }
|
|
93
|
+
return tomRam({ tong: os.totalmem(), dung: ramDungDuoc(), ds: ds.filter((x) => Number.isFinite(x.byte)), meminfo });
|
|
94
|
+
}
|
|
95
|
+
|
|
44
96
|
/** Ước dung lượng một lượt dựng (byte): lớp chữ ProRes 4444 khung thưa (đo máy nhà: clip 176 s 1440x2560 ra 1,5 GB, tức
|
|
45
97
|
* khoảng 1,3 byte mỗi điểm ảnh mỗi giây; lấy 3 cho cảnh chữ dày), video ra theo bitrate x 1,5 (như bước ghép tự kiểm). */
|
|
46
|
-
export function canDia({ W, H, T, bitrateM = 0 }) {
|
|
98
|
+
export function canDia({ W, H, T, bitrateM = 0, nhan = 4 }) {
|
|
47
99
|
const lop = W * H * T * 3, ra = (bitrateM * 1.5 * 1e6 / 8) * T;
|
|
48
|
-
return { lop, ra, tong: lop + ra };
|
|
100
|
+
return { lop, ra, tong: lop + ra, ram: ramGhep({ W, H, nhan }) };
|
|
49
101
|
}
|
|
50
102
|
|
|
51
103
|
/** Kiểm đầu lượt dựng: ổ không đủ thì lỗi ngay (khỏi dựng 1 giờ rồi hỏng lúc ghép), RAM thấp thì cảnh báo. */
|
|
@@ -54,8 +106,8 @@ export function kiemTruocDung(may, can) {
|
|
|
54
106
|
const o = may.oDia[0];
|
|
55
107
|
if (o?.trongGB != null && o.trongGB * GB < can.tong * 1.2)
|
|
56
108
|
loi.push(`ổ chứa ${o.thuMuc} còn ${o.trongGB} GB, lượt dựng này cần khoảng ${gb(can.tong * 1.2)} GB (lớp chữ ${gb(can.lop)} GB, video ra ${gb(can.ra)} GB): dọn bớt rồi chạy lại`);
|
|
57
|
-
if (may.ramTrongGB != null && may.ramTrongGB <
|
|
58
|
-
canhBao.push(`máy còn ${may.ramTrongGB} GB RAM dùng được: đóng bớt ứng dụng
|
|
109
|
+
if (may.ramTrongGB != null && can.ram && may.ramTrongGB * GB < can.ram)
|
|
110
|
+
canhBao.push(`máy còn ${may.ramTrongGB} GB RAM dùng được, bước ghép khổ này cần khoảng ${gb(can.ram)} GB: đóng bớt ứng dụng (thiếu thì bước ghép tự chờ RAM trống)`);
|
|
59
111
|
if (may.nhan <= 4) canhBao.push(`máy ${may.nhan} nhân: dựng lớp chữ, ghép sẽ lâu, giữ máy mở tới khi xong`);
|
|
60
112
|
return { loi, canhBao };
|
|
61
113
|
}
|
package/src/xuat/ghep.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// medium 35M 42,0 dB (trượt sát), medium 45M 43,8 dB SSIM 0,9949 (nét như slow 35M, nhanh ~2,5 lần), nên dùng medium và
|
|
7
7
|
// bitrate 2,6 lần input. TikTok, Facebook nén lại còn vài Mbit/s nên file nặng hơn không ảnh hưởng chất lượng đăng.
|
|
8
8
|
// Gửi file (P66, báo cáo Kiki 03/11/2026): bản chính giữ tham số trên. --max-mb kẹp bitrate theo dung lượng khai; --gui ra
|
|
9
|
-
// thêm bản gửi _share.mp4 (bitrate bằng input, có trần thì theo trần)
|
|
9
|
+
// thêm bản gửi _share.mp4 (bitrate bằng input, có trần thì theo trần). P69 (anh chốt 03/11/2026, máy ảo Muse thiếu RAM): bản
|
|
10
|
+
// chính trước, bản gửi sau, mỗi lượt một bộ mã hoá (hai bộ cùng lúc cần 2,0 GB, một bộ 1,1 GB ở 1440x2560 4 nhân); `dung` gửi
|
|
11
|
+
// bản chính (lệnh sau xuất) xong mới làm bản gửi. Máy không NVENC giải mã đầu vào 2 luồng: bớt RAM, không đổi chất lượng, tốc độ.
|
|
10
12
|
// File đang ghi để ngoài Drive (d-5): ghi vào .cache/xuat/ của repo (cài gói npm: ~/.nexus-typo/cache/xuat/) rồi mới chuyển vào ket-qua/, Drive chỉ đồng bộ file xong.
|
|
11
13
|
import crypto from 'node:crypto';
|
|
12
14
|
import fs from 'node:fs';
|
|
@@ -15,7 +17,7 @@ import { chay, canTieng, docMoc } from '../doc/doc.mjs';
|
|
|
15
17
|
import { tepRa } from '../thu-muc.mjs';
|
|
16
18
|
import { DEM } from '../may.mjs';
|
|
17
19
|
import { matNaDung, docNe, khoangTat } from '../nguoi/nguoi.mjs';
|
|
18
|
-
import { ramDungDuoc } from '../thiet-bi.mjs';
|
|
20
|
+
import { ramDungDuoc, ramGhep, soNhan, dongBo, baoRam, dongRam } from '../thiet-bi.mjs';
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
const THU_MUC_TAM = DEM('xuat');
|
|
@@ -108,11 +110,10 @@ const GB = 1024 ** 3;
|
|
|
108
110
|
* Kiểm trước khi ghép (d-4). Hàm thuần: nhận số đo máy, trả { loi, canhBao }.
|
|
109
111
|
* 26/10 một ffmpeg mồ côi ghi cùng file ra, bộ nhớ ảo cạn, libx265 báo "Generic error in an external library".
|
|
110
112
|
*/
|
|
111
|
-
export function danhGiaTruocGhep({ boNhoTrong, oDia = [], canByte, ffmpegCungTep = [] }) {
|
|
113
|
+
export function danhGiaTruocGhep({ boNhoTrong, canRam = 1 * GB, oDia = [], canByte, ffmpegCungTep = [] }) {
|
|
112
114
|
const loi = [], canhBao = [];
|
|
113
115
|
for (const p of ffmpegCungTep) loi.push(`ffmpeg cũ (PID ${p.pid}) còn ghi ${path.basename(p.tep)}: tắt bằng "taskkill /PID ${p.pid} /F" rồi chạy lại`);
|
|
114
|
-
if (boNhoTrong <
|
|
115
|
-
else if (boNhoTrong < 2 * GB) canhBao.push(`bộ nhớ trống còn ${(boNhoTrong / GB).toFixed(1)} GB, ghép có thể lỗi hoặc chậm`);
|
|
116
|
+
if (boNhoTrong < canRam) loi.push(`RAM dùng được còn ${(boNhoTrong / GB).toFixed(1)} GB, bước ghép khổ này cần khoảng ${(canRam / GB).toFixed(1)} GB`);
|
|
116
117
|
for (const d of oDia) {
|
|
117
118
|
if (d.trong < canByte * 1.2) loi.push(`ổ chứa ${d.thuMuc} còn ${(d.trong / GB).toFixed(1)} GB, cần khoảng ${(canByte * 1.2 / GB).toFixed(1)} GB`);
|
|
118
119
|
}
|
|
@@ -152,22 +153,28 @@ function chuyen(tu, den) {
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
/** Lệnh sau xuất (P66) từ cấu hình máy
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
|
|
156
|
+
/** Lệnh sau xuất (P66) từ cấu hình máy, chạy mỗi file một lần (P69): ban 'chinh' ngay khi bản chính xong, ban 'gui' khi bản
|
|
157
|
+
* gửi xong. Mảng [chương trình, ...tham số] chạy thẳng: lượt bản chính thay {video}, bỏ tham số có {share}; lượt bản gửi thay
|
|
158
|
+
* {share}, bỏ tham số có {video}; lệnh không nhắc file nào thì chỉ chạy ở lượt bản chính, không nhắc file của lượt này thì bỏ
|
|
159
|
+
* qua ({ boQua: true }). Chuỗi chạy qua shell mỗi lượt, đường file ở biến môi trường NT_FILE (file của lượt), NT_BAN
|
|
160
|
+
* (chinh, gui), NT_VIDEO_RA, NT_VIDEO_GUI (của lượt đó, lượt kia rỗng); không ghép vào chuỗi lệnh. Hàm thuần. */
|
|
161
|
+
export function lenhSauXuat(cauHinh, { video, share = null, ban = 'chinh' }) {
|
|
159
162
|
if (!cauHinh) return null;
|
|
160
|
-
const
|
|
163
|
+
const gui = ban === 'gui', tep = gui ? share : video;
|
|
164
|
+
const env = { NT_FILE: tep || '', NT_BAN: ban, NT_VIDEO_RA: gui ? '' : video, NT_VIDEO_GUI: gui ? share || '' : '' };
|
|
161
165
|
if (typeof cauHinh === 'string' && cauHinh.trim()) return { exe: cauHinh, args: [], shell: true, env };
|
|
162
166
|
if (Array.isArray(cauHinh) && cauHinh.length && cauHinh.every((x) => typeof x === 'string')) {
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
167
|
+
const cua = gui ? '{share}' : '{video}', kia = gui ? '{video}' : '{share}';
|
|
168
|
+
const nhac = (k) => cauHinh.some((x) => x.includes(k));
|
|
169
|
+
if (!tep || !(nhac(cua) || (!gui && !nhac(kia)))) return { boQua: true, env };
|
|
170
|
+
const args = cauHinh.slice(1).filter((x) => !x.includes(kia)).map((x) => x.replaceAll(cua, tep));
|
|
171
|
+
return { exe: cauHinh[0].replaceAll(cua, tep), args, shell: false, env };
|
|
166
172
|
}
|
|
167
173
|
throw new Error('post_export_cmd trong cấu hình máy phải là chuỗi lệnh hoặc mảng [chương trình, tham số...]');
|
|
168
174
|
}
|
|
169
175
|
|
|
170
|
-
|
|
176
|
+
/** Phần chung của bản chính, bản gửi: đọc input, lớp chữ, tiếng, mặt nạ người, dựng bộ lọc ghép. */
|
|
177
|
+
async function chuanBi(may, thuMuc) {
|
|
171
178
|
const doc = (f, goiY) => { const p = path.join(thuMuc, f); if (!fs.existsSync(p)) throw new Error(`Thiếu ${f}: chạy ${goiY} trước`); return p; };
|
|
172
179
|
const vao = JSON.parse(fs.readFileSync(doc('vao.json', 'doc'), 'utf8'));
|
|
173
180
|
canTieng(vao);
|
|
@@ -175,50 +182,91 @@ export async function ghep(may, thuMuc, { nhe = false, gui = false, maxMb = null
|
|
|
175
182
|
const codec = vao.hinh.codec === 'h264' ? 'h264' : 'hevc';
|
|
176
183
|
if (vao.hinh.moc === undefined) vao.hinh.moc = await docMoc(may, vao.duongDan); // thư mục làm từ trước bản này
|
|
177
184
|
const nvenc = await coNvenc(may, codec);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
const
|
|
185
|
+
const job = JSON.parse(fs.readFileSync(doc('job.json', 'phu-de'), 'utf8'));
|
|
186
|
+
let nguoi = matNaDung(thuMuc, job, vao);
|
|
187
|
+
const ne = nguoi && docNe(thuMuc, job);
|
|
188
|
+
if (ne && ne.canh.every((x) => x.tat)) nguoi = null; // luật 1c: mọi cảnh chữ ra trước người, ghép như không có mặt nạ
|
|
189
|
+
const tat = nguoi ? khoangTat(job, ne) : null;
|
|
190
|
+
return { vao, lop, am, nvenc, nguoi, loc: (pix) => locGhep(pix, nguoi ? { W: vao.hinh.rong, H: vao.hinh.cao, tat } : null) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const ngu = (ms) => new Promise((x) => setTimeout(x, ms));
|
|
194
|
+
export const CHO_RAM_PHUT = 15;
|
|
195
|
+
|
|
196
|
+
/** Chờ RAM dùng được đủ `can` (P69): đo lại mỗi 30 s, tối đa `phut` phút. Trả RAM đo lần cuối. */
|
|
197
|
+
export async function choRam(can, { phut = CHO_RAM_PHUT, inRa = () => {}, nhip = 30000, doRam = () => { dongBo(); return ramDungDuoc(); } } = {}) {
|
|
198
|
+
let co = doRam();
|
|
199
|
+
if (co >= can || !(phut > 0)) return co;
|
|
200
|
+
const gb = (x) => (x / GB).toFixed(1), het = Date.now() + phut * 60000;
|
|
201
|
+
inRa(`RAM dùng được ${gb(co)} GB, bước ghép cần khoảng ${gb(can)} GB: chờ RAM trống, đo lại mỗi ${nhip / 1000} s, tối đa ${phut} phút`);
|
|
202
|
+
while (co < can && Date.now() < het) { await ngu(Math.min(nhip, Math.max(0, het - Date.now()))); co = doRam(); }
|
|
203
|
+
inRa(co >= can ? `RAM đủ (${gb(co)} GB), ghép tiếp` : `chờ ${phut} phút, RAM vẫn chỉ ${gb(co)} GB`);
|
|
204
|
+
return co;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Mã hoá một file ra (mỗi lượt một bộ mã hoá): chờ RAM, kiểm ổ, ffmpeg ghi file tạm ngoài Drive rồi chuyển vào chỗ cuối. */
|
|
208
|
+
async function maHoa(may, cb, hinh, ra, { ten, inRa, choRamPhut }) {
|
|
209
|
+
const { vao } = cb, T = vao.dai || vao.hinh.dai || 0;
|
|
183
210
|
fs.mkdirSync(THU_MUC_TAM, { recursive: true });
|
|
184
211
|
// tên tạm gắn đường ra: hai video cùng tên ở hai thư mục trước dùng chung file tạm, báo nhầm "ffmpeg cũ, taskkill" (P25)
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
fs.rmSync(path.join(thuMuc, 'sau-xuat.json'), { force: true });
|
|
189
|
-
|
|
190
|
-
const T = vao.dai || vao.hinh.dai || 0;
|
|
191
|
-
const hTran = [hinh, hinhGui].find((h) => h?.theoTran), vaoM = (vao.hinh.bitrate || vao.bitrate || 0) / 1e6;
|
|
192
|
-
if (hTran && hTran.bitrateM < vaoM) inRa(`cảnh báo: trần ${maxMb} MB đưa bitrate hình xuống ${hTran.bitrateM} Mbit/s, thấp hơn video gốc ${vaoM.toFixed(1)} Mbit/s nên ${hTran === hinhGui ? 'bản gửi' : 'video ra'} kém nét hơn gốc`);
|
|
212
|
+
const tam = path.join(THU_MUC_TAM, `${crypto.createHash('sha1').update(path.resolve(ra)).digest('hex').slice(0, 8)}_${path.basename(ra)}`);
|
|
213
|
+
const canRam = ramGhep({ W: vao.hinh.rong, H: vao.hinh.cao, nhan: soNhan() });
|
|
214
|
+
const boNhoTrong = await choRam(canRam, { phut: choRamPhut, inRa });
|
|
193
215
|
const kiem = danhGiaTruocGhep({
|
|
194
|
-
boNhoTrong
|
|
216
|
+
boNhoTrong, canRam,
|
|
195
217
|
oDia: [...new Set([THU_MUC_TAM, path.dirname(ra)])].map((d) => ({ thuMuc: d, trong: choTrong(d) })),
|
|
196
|
-
canByte: (
|
|
197
|
-
ffmpegCungTep: await ffmpegDangGhi([tam, ra
|
|
218
|
+
canByte: (hinh.bitrateM * 1.5 * 1e6 / 8) * T,
|
|
219
|
+
ffmpegCungTep: await ffmpegDangGhi([tam, ra]),
|
|
198
220
|
});
|
|
199
221
|
for (const x of kiem.canhBao) inRa('cảnh báo: ' + x);
|
|
200
|
-
if (kiem.loi.length)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (ne && ne.canh.every((x) => x.tat)) nguoi = null; // luật 1c: mọi cảnh chữ ra trước người, ghép như không có mặt nạ
|
|
206
|
-
let loc = locGhep(hinh.pix, nguoi ? { W: vao.hinh.rong, H: vao.hinh.cao, tat: khoangTat(job, ne) } : null);
|
|
207
|
-
if (gui) loc += ';[v]split=2[v][vg]';
|
|
208
|
-
inRa(`ghép${nguoi ? ', chữ sau người' : ''}, mã hoá ${hinh.boMaHoa} ${hinh.pix} ${hinh.bitrateM} Mbit/s${gui ? `, bản gửi ${hinhGui.bitrateM} Mbit/s cùng lượt` : ''}, khung mốc mỗi ${hinh.khungMoc} khung…`);
|
|
222
|
+
if (kiem.loi.length) {
|
|
223
|
+
if (boNhoTrong < canRam) kiem.loi.push(dongRam(baoRam()));
|
|
224
|
+
throw new Error(`chưa ghép được ${ten}:\n ` + kiem.loi.join('\n '));
|
|
225
|
+
}
|
|
226
|
+
inRa(`ghép ${ten}${cb.nguoi ? ', chữ sau người' : ''}, mã hoá ${hinh.boMaHoa} ${hinh.pix} ${hinh.bitrateM} Mbit/s, khung mốc mỗi ${hinh.khungMoc} khung, RAM dùng được ${(boNhoTrong / GB).toFixed(1)} GB (cần khoảng ${(canRam / GB).toFixed(1)} GB)…`);
|
|
209
227
|
const bd = Date.now();
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const r = await chay(may.ffmpeg, ['-hide_banner', '-nostdin', '-v', 'error', '-i', vao.duongDan, '-i', lop, '-i', am,
|
|
213
|
-
'-filter_complex', loc, '-map', '[v]', '-map', '2:a', ...hinh.thamSo, ...thamSoTieng(vao),
|
|
214
|
-
|
|
215
|
-
if (r.ma !== 0) {
|
|
228
|
+
fs.rmSync(tam, { force: true });
|
|
229
|
+
const giai = cb.nvenc ? [] : ['-threads', '2']; // x265, x264 chậm hơn giải mã nhiều: 2 luồng đủ nhanh, bớt RAM
|
|
230
|
+
const r = await chay(may.ffmpeg, ['-hide_banner', '-nostdin', '-v', 'error', ...giai, '-i', vao.duongDan, ...giai, '-i', cb.lop, '-i', cb.am,
|
|
231
|
+
...(cb.nguoi ? [...giai, '-i', cb.nguoi] : []), '-filter_complex', cb.loc(hinh.pix), '-map', '[v]', '-map', '2:a', ...hinh.thamSo, ...thamSoTieng(vao),
|
|
232
|
+
'-movflags', '+faststart', '-y', tam]);
|
|
233
|
+
if (r.ma !== 0) { fs.rmSync(tam, { force: true }); throw new Error(`ghép ${ten} lỗi: ` + r.loi.slice(-500)); }
|
|
216
234
|
chuyen(tam, ra);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
235
|
+
const mb = +(fs.statSync(ra).size / 1e6).toFixed(1);
|
|
236
|
+
if (hinh.maxMb != null && mb > hinh.maxMb) inRa(`cảnh báo: ${path.basename(ra)} ra ${mb} MB, vượt trần ${hinh.maxMb} MB: khai trần thấp hơn chút rồi chạy lại xuat`);
|
|
237
|
+
return { ra, boMaHoa: hinh.boMaHoa, bitrateM: hinh.bitrateM, duKienMb: hinh.duKienMb, mb, maxMb: hinh.maxMb, giay: +((Date.now() - bd) / 1000).toFixed(1) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const canhBaoTran = (h, vao, ten, inRa) => {
|
|
241
|
+
const vaoM = (vao.hinh.bitrate || vao.bitrate || 0) / 1e6;
|
|
242
|
+
if (h.theoTran && h.bitrateM < vaoM) inRa(`cảnh báo: trần ${h.maxMb} MB đưa bitrate hình xuống ${h.bitrateM} Mbit/s, thấp hơn video gốc ${vaoM.toFixed(1)} Mbit/s nên ${ten} kém nét hơn gốc`);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** Bản chính. gui: lượt này còn ra bản gửi sau (trần MB khi đó áp vào bản gửi, bản chính giữ nguyên tham số). */
|
|
246
|
+
export async function ghep(may, thuMuc, { nhe = false, gui = false, maxMb = null, inRa = () => {}, choRamPhut } = {}) {
|
|
247
|
+
const cb = await chuanBi(may, thuMuc), { vao } = cb;
|
|
248
|
+
const hinh = thamSoHinh(vao, { nvenc: cb.nvenc, nhe, maxMb: gui ? null : maxMb });
|
|
249
|
+
const ra = tepRa(vao.duongDan, nhe ? '_nhe.mp4' : '.mp4');
|
|
250
|
+
for (const f of ['xuat.json', 'sau-xuat.json']) fs.rmSync(path.join(thuMuc, f), { force: true });
|
|
251
|
+
fs.rmSync(tepRa(vao.duongDan, '_share.mp4', { tao: false }), { force: true }); // bản gửi lượt trước, khỏi nhầm là của lượt này
|
|
252
|
+
canhBaoTran(hinh, vao, 'video ra', inRa);
|
|
253
|
+
const chinh = await maHoa(may, cb, hinh, ra, { ten: 'bản chính', inRa, choRamPhut });
|
|
254
|
+
const bao = { chinh: { ...chinh, giay: undefined }, gui: null, ...(gui ? { choGui: true } : {}) };
|
|
222
255
|
fs.writeFileSync(path.join(thuMuc, 'xuat.json'), JSON.stringify(bao, null, 1) + '\n');
|
|
223
|
-
return { ra, hinh, gui:
|
|
256
|
+
return { ra, hinh, gui: null, mb: chinh.mb, giay: chinh.giay };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Bản gửi _share.mp4 (P69): làm sau bản chính, đọc xuat.json của lượt đó. Bitrate bằng input, có trần thì theo trần. */
|
|
260
|
+
export async function ghepGui(may, thuMuc, { maxMb = null, inRa = () => {}, choRamPhut } = {}) {
|
|
261
|
+
const tepXuat = path.join(thuMuc, 'xuat.json');
|
|
262
|
+
const bao = fs.existsSync(tepXuat) ? JSON.parse(fs.readFileSync(tepXuat, 'utf8')) : null;
|
|
263
|
+
if (!bao?.chinh?.ra || !fs.existsSync(bao.chinh.ra)) throw new Error('chưa có bản chính của lượt này: chạy xuat trước');
|
|
264
|
+
const cb = await chuanBi(may, thuMuc), { vao } = cb;
|
|
265
|
+
const hinh = thamSoHinh(vao, { nvenc: cb.nvenc, nhe: true, maxMb });
|
|
266
|
+
canhBaoTran(hinh, vao, 'bản gửi', inRa);
|
|
267
|
+
const gui = await maHoa(may, cb, hinh, tepRa(vao.duongDan, '_share.mp4'), { ten: 'bản gửi', inRa, choRamPhut });
|
|
268
|
+
bao.gui = { ...gui, giay: undefined };
|
|
269
|
+
delete bao.choGui;
|
|
270
|
+
fs.writeFileSync(tepXuat, JSON.stringify(bao, null, 1) + '\n');
|
|
271
|
+
return gui;
|
|
224
272
|
}
|
package/src/xuat/soat.mjs
CHANGED
|
@@ -162,15 +162,7 @@ export async function soat(may, thuMuc, tepVideo, { inRa = () => {} } = {}) {
|
|
|
162
162
|
// P25: trước không kiểm, báo cáo vẫn ĐẠT dù thiếu ảnh soát
|
|
163
163
|
if (rAnh.ma !== 0 || !fs.existsSync(anh)) loi.push(`không ra được ảnh soát 24 khung: ${rAnh.loi.trim().slice(-200) || 'ffmpeg không ghi file'}`);
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
if (xuat?.gui) {
|
|
167
|
-
if (!fs.existsSync(xuat.gui.ra)) loi.push(`bản gửi: không thấy ${path.basename(xuat.gui.ra)}`);
|
|
168
|
-
else {
|
|
169
|
-
inRa('so thông số bản gửi…');
|
|
170
|
-
xuat.gui.thongSo = soThongSo(vao, await thongSo(may, xuat.gui.ra));
|
|
171
|
-
for (const d of xuat.gui.thongSo) if (!d.dat && !d.muc.startsWith('bitrate hình')) loi.push(`bản gửi, ${d.muc}: input ${d.vao}, ra ${d.ra}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
165
|
+
if (xuat?.gui) await kiemGui(may, vao, xuat, loi, inRa);
|
|
174
166
|
for (const x of [xuat?.chinh, xuat?.gui]) if (x?.maxMb != null && x.mb > x.maxMb) canhBao.push(`${path.basename(x.ra)} ${x.mb} MB, vượt trần ${x.maxMb} MB`);
|
|
175
167
|
|
|
176
168
|
if (ve?.vung?.loi?.length) loi.push(`chữ lệch vùng: ${ve.vung.loi.length} chỗ`);
|
|
@@ -178,11 +170,42 @@ export async function soat(may, thuMuc, tepVideo, { inRa = () => {} } = {}) {
|
|
|
178
170
|
for (const x of ve?.nguoi?.bao || []) canhBao.push(`người che chữ (luật 1c): ${x}`);
|
|
179
171
|
|
|
180
172
|
const bao = { video: tepVideo, dat: !loi.length, loi, canhBao, thongSo: ts, am, quang, net, kemNhat, anh, xuat };
|
|
181
|
-
|
|
182
|
-
fs.writeFileSync(path.join(thuMuc, nhe ? 'bao-cao-nhe.md' : 'bao-cao.md'), baoCaoMd(bao, { vao, giai, nhac, sfx, ve, pd }));
|
|
173
|
+
ghiBao(thuMuc, bao, nhe, { vao, giai, nhac, sfx, ve, pd });
|
|
183
174
|
return bao;
|
|
184
175
|
}
|
|
185
176
|
|
|
177
|
+
function ghiBao(thuMuc, bao, nhe, ctx) {
|
|
178
|
+
fs.writeFileSync(path.join(thuMuc, nhe ? 'bao-cao-nhe.json' : 'bao-cao.json'), JSON.stringify(bao, null, 1));
|
|
179
|
+
fs.writeFileSync(path.join(thuMuc, nhe ? 'bao-cao-nhe.md' : 'bao-cao.md'), baoCaoMd(bao, ctx));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Bản gửi: cùng bộ lọc ghép, cùng khung hình với bản chính nên chỉ so thông số, không đo lại nét, tiếng. */
|
|
183
|
+
async function kiemGui(may, vao, xuat, loi, inRa) {
|
|
184
|
+
if (!fs.existsSync(xuat.gui.ra)) { loi.push(`bản gửi: không thấy ${path.basename(xuat.gui.ra)}`); return; }
|
|
185
|
+
inRa('so thông số bản gửi…');
|
|
186
|
+
xuat.gui.thongSo = soThongSo(vao, await thongSo(may, xuat.gui.ra));
|
|
187
|
+
for (const d of xuat.gui.thongSo) if (!d.dat && !d.muc.startsWith('bitrate hình')) loi.push(`bản gửi, ${d.muc}: input ${d.vao}, ra ${d.ra}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Soát bản gửi làm sau bản chính (P69): so thông số, ghi thêm vào báo cáo của bản chính (đã soát trước đó). */
|
|
191
|
+
export async function soatGui(may, thuMuc, { inRa = () => {} } = {}) {
|
|
192
|
+
const docJ = (f) => { const p = path.join(thuMuc, f); return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null; };
|
|
193
|
+
const xuat = docJ('xuat.json'), vao = docJ('vao.json');
|
|
194
|
+
if (!xuat?.gui) throw new Error('chưa có bản gửi trong xuat.json');
|
|
195
|
+
const nhe = /_nhe\.mp4$/.test(xuat.chinh.ra);
|
|
196
|
+
const bao = docJ(nhe ? 'bao-cao-nhe.json' : 'bao-cao.json');
|
|
197
|
+
if (!bao || path.resolve(bao.video) !== path.resolve(xuat.chinh.ra)) throw new Error('chưa soát bản chính của lượt này: chạy soat trước');
|
|
198
|
+
canTieng(vao);
|
|
199
|
+
const loi = [];
|
|
200
|
+
await kiemGui(may, vao, xuat, loi, inRa);
|
|
201
|
+
if (xuat.gui.maxMb != null && xuat.gui.mb > xuat.gui.maxMb) bao.canhBao.push(`${path.basename(xuat.gui.ra)} ${xuat.gui.mb} MB, vượt trần ${xuat.gui.maxMb} MB`);
|
|
202
|
+
bao.loi.push(...loi);
|
|
203
|
+
bao.dat = !bao.loi.length;
|
|
204
|
+
bao.xuat = xuat;
|
|
205
|
+
ghiBao(thuMuc, bao, nhe, { vao, giai: docJ('kich-ban.giai.json'), nhac: docJ('nhac.json'), sfx: docJ('sfx.json'), ve: docJ('ve.bao.json'), pd: docJ('phu-de.bao.json') });
|
|
206
|
+
return { dat: !loi.length, loi, gui: xuat.gui };
|
|
207
|
+
}
|
|
208
|
+
|
|
186
209
|
/** Dòng vùng chữ của báo cáo. Lấn mép (dấu, bóng chữ chạm mép vùng) ghi kèm mức lấn lớn nhất: dưới 1 % khung là
|
|
187
210
|
* cảnh báo nhẹ, nói rõ không đáng kể để người dùng khỏi lo (P66 mục 6). Hàm thuần. */
|
|
188
211
|
export function dongVung(vung) {
|
|
@@ -201,6 +224,7 @@ function baoCaoMd(b, { vao, giai, nhac, sfx, ve, pd }) {
|
|
|
201
224
|
if (b.xuat) {
|
|
202
225
|
const dong = (ten, x) => `- ${ten}: ${path.basename(x.ra)}, ${x.boMaHoa} ${x.bitrateM} Mbit/s, ${x.mb} MB${x.maxMb != null ? ` (trần ${x.maxMb} MB)` : ''}`;
|
|
203
226
|
d.push('## File ra', dong('Bản chính', b.xuat.chinh));
|
|
227
|
+
if (b.xuat.choGui && !b.xuat.gui) d.push('- Bản gửi: làm sau bản chính');
|
|
204
228
|
if (b.xuat.gui) d.push(dong('Bản gửi', b.xuat.gui) + (b.xuat.gui.thongSo ? `, thông số ngoài bitrate ${b.xuat.gui.thongSo.every((x) => x.dat || x.muc.startsWith('bitrate hình')) ? 'trùng input' : 'LỆCH input'}` : ''));
|
|
205
229
|
d.push('');
|
|
206
230
|
}
|