nexus-typo-mcp 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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, cùng một lượt
67
- ghép; `max_mb` khai trần dung lượng (có `share` thì áp vào bản gửi).
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 sau khi xuất xong (tự soát ĐẠT), ghi ở `~/.nexus-typo/may.json`:
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, `{video}` là bản chính, `{share}` là bản gửi (không có bản gửi thì bỏ tham số đó). Dạng chuỗi
77
- chạy qua shell, đường file ở biến môi trường `NT_VIDEO_RA`, `NT_VIDEO_GUI`. Lệnh chỉ đọc từ file này, công cụ MCP
78
- không nhận lệnh.
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
 
@@ -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 cùng lượt, 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)
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
- const kq = await ghep(docMay(), thuMucLamViec(video), { nhe: !!tuy.nhe, gui: !!tuy.gui, maxMb: soTran(tuy), inRa: (x) => console.log(' ' + x) });
206
- console.log(`Đã xuất: ${kq.ra} (${kq.hinh.boMaHoa} ${kq.hinh.bitrateM} Mbit/s, ${kq.mb} MB, ${kq.giay} s)`);
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
- await buoc('xuat', () => LENH.xuat({ vi, tuy: { nhe: tuy.nhe, gui: tuy.gui, 'max-mb': tuy['max-mb'] } }));
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, thuMucLamViec(video)); }
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
- /** 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á). */
311
- async function chaySauXuat(may, thuMuc) {
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 l = lenhSauXuat(may.sauXuat, { video: xuat.chinh.ra, share: xuat.gui?.ra || null });
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), video: xuat.chinh.ra, share: xuat.gui?.ra || null };
324
- ghiJson(path.join(thuMuc, 'sau-xuat.json'), kq);
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
 
@@ -40,10 +40,28 @@ 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) cùng lượt, bản chính không đổi, `video_share` là đường file gửi. Tốn thêm một lượt mã hoá chạy song song (máy yếu thêm vài phút).
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 đó sau khi tự soát ĐẠT, kết quả ở `post_export` (`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.
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ở việc dài từ phiên chính:** `render`, `prepare_video` gọi từ phiên trò chuyện chính, không gọi từ lượt chạy theo lịch hay tác vụ nền ngắn của nền tảng. Lượt đó kết thúc thì nền tảng dọn hết tiến trình nó mở, việc thành `interrupted` dù tool đã tách việc khỏi MCP. Lịch chỉ gọi `job_status` để canh, báo người dùng. Thấy `interrupted` mà máy chưa bị thay thì gọi lại `render` từ phiên chính.
60
+ - **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`.
61
+ - **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`).
62
+ - **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.
63
+ - **Đư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).
64
+ - **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
65
 
48
66
  ## Quy tắc cứng
49
67
 
package/mcp/may.mjs CHANGED
@@ -360,14 +360,14 @@ const TOOLS = [
360
360
  inputSchema: { type: 'object', required: ['video'], properties: { video: VIDEO,
361
361
  scenes: { type: 'array', items: { type: 'integer' }, description: 'chỉ ra ảnh các cảnh này (số cảnh từ 1), sau khi sửa' }, wait_seconds: CHO } } },
362
362
  { name: 'render', handler: render,
363
- description: 'Dựng video: đọc máy, lớp chữ, SFX, nhạc, ghép, tự soát. Chạy nền, tách khỏi MCP (ứng dụng AI đóng thì việc vẫn chạy; thời gian tuỳ máy, log ghi giờ còn lại): trả job_id, theo dõi bằng job_status. Gọi lại khi hỏng giữa chừng thì phần đã xong được dùng lại. Video ra ở thư mục ket-qua cạnh video.',
363
+ description: 'Dựng video: đọc máy, lớp chữ, SFX, nhạc, ghép, tự soát. Chạy nền, tách khỏi MCP (ứng dụng AI đóng thì việc vẫn chạy; thời gian tuỳ máy, log ghi giờ còn lại): trả job_id, theo dõi bằng job_status. Gọi lại khi hỏng giữa chừng thì phần đã xong được dùng lại. Video ra ở thư mục ket-qua cạnh video. Gọi từ phiên trò chuyện chính, không từ lượt chạy theo lịch (lượt đó kết thúc thì nền tảng dọn luôn việc).',
364
364
  inputSchema: { type: 'object', required: ['video'], properties: { video: VIDEO,
365
365
  music_mode: { type: 'string', enum: ['giu', 'them', 'doi'], description: 'giu: giữ tiếng video (mặc định); them: thêm nhạc (video chưa có nhạc); doi: thay nhạc cũ (cần khoá ElevenLabs để tách giọng, hoặc voice_file)' },
366
366
  music_file: { type: 'string', description: 'file nhạc người dùng đưa (mp3, wav, mp4)' },
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) cùng lượt, 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' },
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; render gọi từ phiên chính, lịch chỉ gọi job_status.' +
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.2",
3
+ "version": "1.3.4",
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: typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length,
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 < 2)
58
- canhBao.push(`máy còn ${may.ramTrongGB} GB RAM dùng được: đóng bớt ứng dụng, bước ghép cần khoảng 2 GB ở khổ 2K`);
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) cùng một lượt ghép, giải mã và chồng chữ một lầ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 < 1 * GB) loi.push(`bộ nhớ trống còn ${(boNhoTrong / GB).toFixed(1)} GB (cần ≥ 1 GB): đóng bớt ứng dụng rồi chạy lại`);
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: mảng [chương trình, ...tham số] chạy thẳng, thay {video}, {share} bằng đường file
156
- * (tham số chỉ có {share} mà không có bản gửi thì bỏ); chuỗi thì chạy qua shell, đường file ở biến môi trường NT_VIDEO_RA,
157
- * NT_VIDEO_GUI (không ghép vào chuỗi lệnh). Hàm thuần. */
158
- export function lenhSauXuat(cauHinh, { video, share = null }) {
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 env = { NT_VIDEO_RA: video, NT_VIDEO_GUI: share || '' };
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 thay = (x) => x.replaceAll('{video}', video).replaceAll('{share}', share || '');
164
- const args = cauHinh.slice(1).filter((x) => share || x.trim() !== '{share}').map(thay);
165
- return { exe: thay(cauHinh[0]), args, shell: false, env };
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
- export async function ghep(may, thuMuc, { nhe = false, gui = false, maxMb = null, inRa = () => {} } = {}) {
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
- // có bản gửi thì trần áp vào bản gửi, bản chính giữ nguyên tham số
179
- const hinh = thamSoHinh(vao, { nvenc, nhe, maxMb: gui ? null : maxMb });
180
- const hinhGui = gui ? thamSoHinh(vao, { nvenc, nhe: true, maxMb }) : null;
181
- const ra = tepRa(vao.duongDan, nhe ? '_nhe.mp4' : '.mp4');
182
- const raGui = gui ? tepRa(vao.duongDan, '_share.mp4') : null;
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 tamCua = (f) => path.join(THU_MUC_TAM, `${crypto.createHash('sha1').update(path.resolve(f)).digest('hex').slice(0, 8)}_${path.basename(f)}`);
186
- const tam = tamCua(ra), tamGui = gui ? tamCua(raGui) : null;
187
- fs.rmSync(path.join(thuMuc, 'xuat.json'), { force: true });
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: ramDungDuoc(),
216
+ boNhoTrong, canRam,
195
217
  oDia: [...new Set([THU_MUC_TAM, path.dirname(ra)])].map((d) => ({ thuMuc: d, trong: choTrong(d) })),
196
- canByte: ((hinh.bitrateM + (hinhGui?.bitrateM || 0)) * 1.5 * 1e6 / 8) * T,
197
- ffmpegCungTep: await ffmpegDangGhi([tam, ra, ...(gui ? [tamGui, raGui] : [])]),
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) throw new Error('chưa ghép được:\n ' + kiem.loi.join('\n '));
201
-
202
- const job = JSON.parse(fs.readFileSync(doc('job.json', 'phu-de'), 'utf8'));
203
- let nguoi = matNaDung(thuMuc, job, vao);
204
- const ne = nguoi && docNe(thuMuc, job);
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
- const tams = [tam, tamGui].filter(Boolean);
211
- for (const f of tams) fs.rmSync(f, { force: true });
212
- const r = await chay(may.ffmpeg, ['-hide_banner', '-nostdin', '-v', 'error', '-i', vao.duongDan, '-i', lop, '-i', am, ...(nguoi ? ['-i', nguoi] : []),
213
- '-filter_complex', loc, '-map', '[v]', '-map', '2:a', ...hinh.thamSo, ...thamSoTieng(vao), '-movflags', '+faststart', '-y', tam,
214
- ...(gui ? ['-map', '[vg]', '-map', '2:a', ...hinhGui.thamSo, ...thamSoTieng(vao), '-movflags', '+faststart', '-y', tamGui] : [])]);
215
- if (r.ma !== 0) { for (const f of tams) fs.rmSync(f, { force: true }); throw new Error('ghép, xuất lỗi: ' + r.loi.slice(-500)); }
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
- if (gui) chuyen(tamGui, raGui);
218
- const mb = (f) => +(fs.statSync(f).size / 1e6).toFixed(1);
219
- const ban = (f, h) => ({ ra: f, boMaHoa: h.boMaHoa, bitrateM: h.bitrateM, duKienMb: h.duKienMb, mb: mb(f), maxMb: h.maxMb });
220
- const bao = { chinh: ban(ra, hinh), gui: gui ? ban(raGui, hinhGui) : null };
221
- for (const x of [bao.chinh, bao.gui]) if (x?.maxMb != null && x.mb > x.maxMb) inRa(`cảnh báo: ${path.basename(x.ra)} ra ${x.mb} MB, vượt trần ${x.maxMb} MB: khai trần thấp hơn chút rồi chạy lại xuat`);
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: bao.gui, mb: bao.chinh.mb, giay: +((Date.now() - bd) / 1000).toFixed(1) };
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
- // bản gửi (P66): cùng lượt 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
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
- fs.writeFileSync(path.join(thuMuc, nhe ? 'bao-cao-nhe.json' : 'bao-cao.json'), JSON.stringify(bao, null, 1));
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
  }