channel-worker 2.5.134 → 2.5.135

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.134",
3
+ "version": "2.5.135",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -782,7 +782,10 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
782
782
  }
783
783
  return '';
784
784
  };
785
- const tileLike = (el) => vis(el) && (!!mediaOf(el) || isPick(el));
785
+ // Ô thật rộng ~90px. Chặn trên 320px để thân hộp thoại "Chọn hình thu nhỏ"
786
+ // (rộng 500, có ảnh xem trước) không bị nhận là một ô — lượt test thật
787
+ // 23/09 đọc nhầm hộp thoại thành "2 ô [chọn-ảnh, ảnh-local]".
788
+ const tileLike = (el) => vis(el) && el.getBoundingClientRect().width <= 320 && (!!mediaOf(el) || isPick(el));
786
789
  // Ô đang chọn: viền/bóng xanh của FB (#0866FF, #1877F2) hoặc aria-*.
787
790
  const blueish = (c) => {
788
791
  const m = String(c || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/);
@@ -835,8 +838,16 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
835
838
  };
836
839
  let pickLeaf = null;
837
840
  for (const root of roots) {
841
+ // Bỏ qua tiêu đề của hộp thoại "Chọn hình thu nhỏ": cùng chữ với ô, và
842
+ // FB gắn role=dialog + aria-label đó lên phần đầu hộp thoại (đo 23/09).
843
+ const inPickerModal = (el) => {
844
+ if (el.closest('[__fbpw_thumb_modal__]')) return true;
845
+ const d = el.closest("[role='dialog']");
846
+ return !!d && d !== root && PICK.test(norm(d.getAttribute('aria-label')));
847
+ };
838
848
  const hits = [...root.querySelectorAll('[aria-label]')].filter((el) => PICK.test(norm(el.getAttribute('aria-label'))) && vis(el))
839
- .concat(textHits(root, /hình|ảnh|thumbnail/i, PICK));
849
+ .concat(textHits(root, /hình|ảnh|thumbnail/i, PICK))
850
+ .filter((el) => !inPickerModal(el));
840
851
  if (hits.length) { pickLeaf = hits[0]; break; }
841
852
  }
842
853
  let row = null;
@@ -911,7 +922,9 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
911
922
  for (let scope = row, d = 0; scope && d < 3 && !input; scope = scope.parentElement, d++) {
912
923
  for (const el of scope.querySelectorAll("input[type='file']")) {
913
924
  const accept = (el.getAttribute('accept') || '').toLowerCase();
914
- if (!/image/.test(accept) || /video/.test(accept)) continue; // input của "Thêm file khác" nhận cả video
925
+ // Nhận cả dạng đuôi file (".png,.jpg,.jpeg" — đo thật 23/09); input của
926
+ // "Thêm file khác" nhận cả video nên bị loại.
927
+ if (!/image|\.png|\.jpe?g/.test(accept) || /video|\.mp4|\.mov/.test(accept)) continue;
915
928
  el.setAttribute('__fbpw_thumb_input__', '1');
916
929
  input = { accept: accept.slice(0, 60) };
917
930
  break;
@@ -993,13 +1006,17 @@ function describeThumbRow(snap) {
993
1006
  // tận mắt — chỉ bấm nút xác nhận của một dialog KHÁC composer mà trong chữ
994
1007
  // có nhắc ảnh bìa, để không bao giờ trúng nút "Lưu" (= lưu nháp) của composer.
995
1008
  async function confirmNewComposerThumbDialog(page, log) {
996
- const hit = await page.evaluate((compSrc) => {
1009
+ const hit = await page.evaluate(({ compSrc, pickSrc }) => {
997
1010
  const COMP = new RegExp(compSrc, 'i');
1011
+ const PICK = new RegExp(pickSrc, 'i');
998
1012
  for (const dlg of document.querySelectorAll("[role='dialog']")) {
999
1013
  const r = dlg.getBoundingClientRect();
1000
1014
  if (r.width < 8 || r.height < 8) continue;
1001
1015
  const head = `${dlg.getAttribute('aria-label') || ''} | ${(dlg.innerText || '').slice(0, 300)}`;
1002
1016
  if (COMP.test(head)) continue;
1017
+ // Hộp "Chọn hình thu nhỏ" có luồng riêng (chỉ Lưu khi ảnh xem trước đã
1018
+ // đổi) — bấm Lưu ở đây lúc ảnh chưa đổi là lưu khung tự động làm ảnh bìa.
1019
+ if (PICK.test((dlg.getAttribute('aria-label') || '').trim()) || dlg.closest('[__fbpw_thumb_modal__]')) continue;
1003
1020
  if (!/hình thu nhỏ|ảnh thu nhỏ|thumbnail|cắt ảnh|crop/i.test(head)) continue;
1004
1021
  for (const b of dlg.querySelectorAll("button, [role='button']")) {
1005
1022
  const t = (b.innerText || '').trim();
@@ -1011,7 +1028,7 @@ async function confirmNewComposerThumbDialog(page, log) {
1011
1028
  }
1012
1029
  }
1013
1030
  return null;
1014
- }, COMPOSER_SIGNATURE_RE.source).catch(() => null);
1031
+ }, { compSrc: COMPOSER_SIGNATURE_RE.source, pickSrc: THUMB_PICKER_TEXT_RE.source }).catch(() => null);
1015
1032
  if (!hit) return false;
1016
1033
  log('info', `[fb-pw] composer mới — hộp phụ ảnh bìa "${hit.head}" → bấm "${hit.verb}"`);
1017
1034
  await page.locator("[__fbpw_thumb_confirm__='1']").click({ timeout: 3000 }).catch(() => {});
@@ -1019,6 +1036,208 @@ async function confirmNewComposerThumbDialog(page, log) {
1019
1036
  return true;
1020
1037
  }
1021
1038
 
1039
+ // Hộp thoại "Chọn hình thu nhỏ" — thứ THẬT mở ra khi bấm ô chọn-ảnh. Đo trên
1040
+ // Facebook 23/09/2026 (lượt test page LACCA, lệnh 6ab39643): cú bấm KHÔNG mở
1041
+ // hộp chọn file mà mở hộp thoại 500x326 gồm ảnh xem trước 203x360, dải khung để
1042
+ // tua video (input range "Tua trên dòng thời gian…"), input file ẩn
1043
+ // accept=".png,.jpg,.jpeg", nút "Tải lên" và "Lưu". FB gắn role=dialog +
1044
+ // aria-label "Chọn hình thu nhỏ" lên PHẦN ĐẦU (500x60) chứ không lên cả hộp,
1045
+ // nên không dò theo role: leo từ chữ tiêu đề lên tới khối có cả "Tải lên" lẫn
1046
+ // "Lưu" (và chưa chạm nút "Đăng" của composer).
1047
+ // Gắn cờ: __fbpw_thumb_modal__ (khối), _upload__ (nút Tải lên), _save__ (mọi
1048
+ // nút Lưu, đánh số từ cuối), _input__ (input ảnh). Trả null nếu không thấy.
1049
+ async function findThumbPickerModal(page) {
1050
+ return page.evaluate(({ pickSrc }) => {
1051
+ const PICK = new RegExp(pickSrc, 'i');
1052
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1053
+ const vis = (el) => {
1054
+ const r = el.getBoundingClientRect();
1055
+ if (r.width < 8 || r.height < 8) return false;
1056
+ const cs = getComputedStyle(el);
1057
+ return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
1058
+ };
1059
+ for (const a of ['__fbpw_thumb_modal__', '__fbpw_thumb_modal_upload__', '__fbpw_thumb_modal_save__', '__fbpw_thumb_modal_input__']) {
1060
+ document.querySelectorAll(`[${a}]`).forEach((el) => el.removeAttribute(a));
1061
+ }
1062
+ const says = (el, re) => re.test(norm(el.getAttribute('aria-label'))) || re.test(norm(el.innerText));
1063
+ const UP = /^(Tải lên|Tải ảnh lên|Upload|Upload image)$/i;
1064
+ const SAVE = /^(Lưu|Save)$/i;
1065
+ const POST = /^(Đăng|Post|Share now)$/i;
1066
+ const heads = [...document.querySelectorAll('[aria-label]')].filter((el) => PICK.test(norm(el.getAttribute('aria-label'))) && vis(el));
1067
+ const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
1068
+ for (let n = tw.nextNode(); n; n = tw.nextNode()) {
1069
+ if (!/hình|thumbnail/i.test(n.data)) continue;
1070
+ const el = n.parentElement;
1071
+ if (el && vis(el) && PICK.test(norm(el.innerText))) heads.push(el);
1072
+ }
1073
+ for (const h of heads) {
1074
+ if (h.closest('[__fbpw_thumb_row__]')) continue; // chính ô chọn-ảnh trong hàng
1075
+ let box = h;
1076
+ for (let d = 0; d < 10 && box && box !== document.body; d++, box = box.parentElement) {
1077
+ const btns = [...box.querySelectorAll("[role='button'], button")].filter(vis);
1078
+ if (btns.some((b) => says(b, POST))) break; // đã leo tới composer
1079
+ const up = btns.find((b) => says(b, UP));
1080
+ const saves = btns.filter((b) => says(b, SAVE));
1081
+ if (!up || !saves.length) continue;
1082
+ box.setAttribute('__fbpw_thumb_modal__', '1');
1083
+ up.setAttribute('__fbpw_thumb_modal_upload__', '1');
1084
+ saves.reverse().forEach((b, i) => b.setAttribute('__fbpw_thumb_modal_save__', String(i)));
1085
+ const inp = [...box.querySelectorAll("input[type='file']")].find((i) => {
1086
+ const a = (i.getAttribute('accept') || '').toLowerCase();
1087
+ return /image|\.png|\.jpe?g/.test(a) && !/video|\.mp4|\.mov/.test(a);
1088
+ });
1089
+ if (inp) inp.setAttribute('__fbpw_thumb_modal_input__', '1');
1090
+ const big = [...box.querySelectorAll('img')].find((i) => {
1091
+ const ir = i.getBoundingClientRect();
1092
+ return ir.width >= 150 && ir.height >= 150;
1093
+ });
1094
+ const r = box.getBoundingClientRect();
1095
+ return {
1096
+ w: Math.round(r.width),
1097
+ h: Math.round(r.height),
1098
+ preview: big ? String(big.currentSrc || big.src || '').slice(0, 500) : '',
1099
+ input: inp ? (inp.getAttribute('accept') || '') : null,
1100
+ saves: saves.length,
1101
+ };
1102
+ }
1103
+ }
1104
+ return null;
1105
+ }, { pickSrc: THUMB_PICKER_TEXT_RE.source }).catch(() => null);
1106
+ }
1107
+
1108
+ // Ảnh xem trước trong hộp thoại có đúng là ảnh bìa của MÌNH không? Thu cả hai
1109
+ // về 16x16 rồi lấy độ lệch trung bình mỗi kênh màu (0-255). "Ảnh xem trước đổi
1110
+ // URL" chưa đủ: FB có thể tự thay bằng bản nét hơn của khung đang chọn, và Lưu
1111
+ // lúc đó là lưu khung tự động. Ảnh blob:/data: đọc được; ảnh CDN khác nguồn làm
1112
+ // canvas bị khoá → trả { err: 'tainted' } để người gọi tự quyết.
1113
+ const THUMB_PREVIEW_MAX_DIFF = 18;
1114
+ async function previewMatchesThumb(page, thumbDataUrl) {
1115
+ return page.evaluate(async ({ ours }) => {
1116
+ const box = document.querySelector('[__fbpw_thumb_modal__]');
1117
+ if (!box) return { err: 'no-modal' };
1118
+ const prev = [...box.querySelectorAll('img')].find((i) => {
1119
+ const r = i.getBoundingClientRect();
1120
+ return r.width >= 150 && r.height >= 150;
1121
+ });
1122
+ if (!prev || !prev.complete || !prev.naturalWidth) return { err: 'preview-not-loaded' };
1123
+ const sig = (img) => {
1124
+ const c = document.createElement('canvas');
1125
+ c.width = 16; c.height = 16;
1126
+ const x = c.getContext('2d');
1127
+ x.drawImage(img, 0, 0, 16, 16);
1128
+ return x.getImageData(0, 0, 16, 16).data;
1129
+ };
1130
+ let a;
1131
+ try { a = sig(prev); } catch { return { err: 'tainted' }; }
1132
+ const mine = await new Promise((res) => {
1133
+ const i = new Image();
1134
+ i.onload = () => res(i);
1135
+ i.onerror = () => res(null);
1136
+ i.src = ours;
1137
+ });
1138
+ if (!mine) return { err: 'thumb-not-decodable' };
1139
+ const b = sig(mine);
1140
+ let d = 0;
1141
+ for (let k = 0; k < a.length; k += 4) d += Math.abs(a[k] - b[k]) + Math.abs(a[k + 1] - b[k + 1]) + Math.abs(a[k + 2] - b[k + 2]);
1142
+ return { diff: Math.round(d / (256 * 3)) };
1143
+ }, { ours: thumbDataUrl }).catch((e) => ({ err: String(e && e.message || e).slice(0, 60) }));
1144
+ }
1145
+
1146
+ // Đưa ảnh bìa vào hộp thoại "Chọn hình thu nhỏ" rồi Lưu. Chỉ bấm Lưu khi ẢNH
1147
+ // XEM TRƯỚC ĐÃ ĐỔI: hộp này mở sẵn một khung cắt từ video, Lưu lúc ảnh chưa đổi
1148
+ // là lưu khung đó làm ảnh bìa — bài lên với ảnh FB tự chọn, đúng thứ cổng
1149
+ // trước Đăng sinh ra để chặn. Trả { ok, via, reason }.
1150
+ async function uploadViaThumbPickerModal(page, thumbPath, log, modal, { previewMs = 20_000, closeMs = 30_000 } = {}) {
1151
+ log('info', `[fb-pw] composer mới — ô "Chọn hình thu nhỏ" mở hộp thoại chọn ảnh bìa (${modal.w}x${modal.h}, input=${modal.input === null ? 'không' : modal.input || '?'}, nút Lưu=${modal.saves})`);
1152
+ // Mốc "trước" chỉ chốt khi ảnh xem trước ĐÃ ĐỨNG YÊN: chụp lúc nó còn trống
1153
+ // hay đang nạp thì lần nạp xong kế tiếp sẽ bị tưởng là ảnh của mình.
1154
+ let beforePreview = modal.preview;
1155
+ for (let i = 0; i < 5; i++) {
1156
+ await pause(page, 1000);
1157
+ const again = await findThumbPickerModal(page);
1158
+ if (!again) break;
1159
+ const stable = again.preview && again.preview === beforePreview;
1160
+ beforePreview = again.preview;
1161
+ if (stable) break;
1162
+ }
1163
+ let thumbDataUrl = '';
1164
+ try { thumbDataUrl = `data:image/png;base64,${fs.readFileSync(thumbPath).toString('base64')}`; } catch { /* so nội dung sẽ báo lỗi, rơi về luật đổi-URL */ }
1165
+ let via = '';
1166
+ try {
1167
+ const [chooser] = await Promise.all([
1168
+ page.waitForEvent('filechooser', { timeout: 8000 }),
1169
+ page.locator("[__fbpw_thumb_modal__='1'] [__fbpw_thumb_modal_upload__='1']").first().click({ timeout: 5000 }),
1170
+ ]);
1171
+ await chooser.setFiles(thumbPath);
1172
+ via = 'nút "Tải lên" trong hộp thoại';
1173
+ } catch (e) {
1174
+ log('info', `[fb-pw] composer mới — nút "Tải lên" không mở hộp chọn file: ${String(e && e.message || e).slice(0, 80)}`);
1175
+ }
1176
+ if (!via && await findThumbPickerModal(page)) {
1177
+ const inp = page.locator("[__fbpw_thumb_modal__='1'] [__fbpw_thumb_modal_input__='1']").first();
1178
+ if (await inp.count().catch(() => 0)) {
1179
+ try {
1180
+ await inp.setInputFiles(thumbPath);
1181
+ via = 'input ảnh ẩn trong hộp thoại';
1182
+ } catch (e) {
1183
+ log('info', `[fb-pw] composer mới — input ảnh trong hộp thoại từ chối file: ${String(e && e.message || e).slice(0, 80)}`);
1184
+ }
1185
+ }
1186
+ }
1187
+ if (!via) return { ok: false, reason: 'modal-no-upload' };
1188
+ log('info', `[fb-pw] composer mới — đã đưa file vào hộp thoại qua ${via}, chờ ảnh xem trước đổi…`);
1189
+
1190
+ const t0 = Date.now();
1191
+ let m = null;
1192
+ let changed = false;
1193
+ let lastCmp = null;
1194
+ let loggedMismatch = false;
1195
+ while (Date.now() - t0 < previewMs) {
1196
+ await pause(page, 1500);
1197
+ m = await findThumbPickerModal(page);
1198
+ if (!m) return { ok: true, via, reason: 'hộp thoại tự đóng sau khi nhận ảnh' };
1199
+ if (!m.preview || m.preview === beforePreview) continue;
1200
+ // URL đổi rồi — so NỘI DUNG. Lệch nhiều = FB đổi khung chứ chưa phải ảnh
1201
+ // của mình: chờ tiếp. Không so được (ảnh CDN khoá canvas) thì rơi về luật
1202
+ // đổi-URL, kèm ghi rõ vào log. Ảnh chưa nạp xong thì chờ nhịp sau.
1203
+ lastCmp = await previewMatchesThumb(page, thumbDataUrl);
1204
+ if (lastCmp.err === 'preview-not-loaded') continue;
1205
+ if (lastCmp.diff !== undefined && lastCmp.diff > THUMB_PREVIEW_MAX_DIFF) {
1206
+ if (!loggedMismatch) log('info', `[fb-pw] composer mới — ảnh xem trước đổi nhưng KHÔNG giống ảnh bìa của mình (lệch ${lastCmp.diff}/255) — chờ tiếp`);
1207
+ loggedMismatch = true;
1208
+ continue;
1209
+ }
1210
+ changed = true;
1211
+ break;
1212
+ }
1213
+ if (!changed) {
1214
+ log('warn', `[fb-pw] composer mới — ảnh xem trước chưa thành ảnh bìa của mình sau ${Math.round(previewMs / 1000)}s${lastCmp && lastCmp.diff !== undefined ? ` (lệch ${lastCmp.diff}/255)` : ''} → KHÔNG bấm Lưu (Lưu lúc này là lưu khung tự động làm ảnh bìa)`);
1215
+ return { ok: false, reason: 'modal-preview-unchanged' };
1216
+ }
1217
+ log('info', `[fb-pw] composer mới — ảnh xem trước đã thành ảnh bìa của mình sau ${Math.round((Date.now() - t0) / 1000)}s (${lastCmp && lastCmp.diff !== undefined ? `lệch ${lastCmp.diff}/255` : `không so được nội dung: ${lastCmp && lastCmp.err}`}), bấm Lưu…`);
1218
+ let saved = false;
1219
+ for (let i = 0; i < (m.saves || 1) && !saved; i++) {
1220
+ try {
1221
+ await page.locator(`[__fbpw_thumb_modal__='1'] [__fbpw_thumb_modal_save__='${i}']`).first().click({ timeout: 5000 });
1222
+ saved = true;
1223
+ } catch (e) {
1224
+ log('info', `[fb-pw] composer mới — nút Lưu #${i} không bấm được: ${String(e && e.message || e).slice(0, 80)}`);
1225
+ await findThumbPickerModal(page);
1226
+ }
1227
+ }
1228
+ if (!saved) return { ok: false, reason: 'modal-save-failed' };
1229
+ // Đòi hai nhịp liền không thấy hộp thoại — FB có thể gỡ rồi dựng lại khung.
1230
+ const t1 = Date.now();
1231
+ let gone = 0;
1232
+ while (Date.now() - t1 < closeMs) {
1233
+ await pause(page, 1500);
1234
+ if (await findThumbPickerModal(page)) { gone = 0; continue; }
1235
+ if (++gone >= 2) return { ok: true, via };
1236
+ }
1237
+ log('warn', `[fb-pw] composer mới — hộp thoại "Chọn hình thu nhỏ" chưa đóng ${Math.round(closeMs / 1000)}s sau khi bấm Lưu`);
1238
+ return { ok: false, reason: 'modal-not-closed' };
1239
+ }
1240
+
1022
1241
  // Dán ảnh bìa vào composer mới. Trả { applied, reason }. Không ném: thất bại
1023
1242
  // thì cờ customThumbDone không bật và cổng trước Đăng chặn như cũ.
1024
1243
  async function applyNewComposerThumb(page, thumbPath, log, { waitPickerMs = 20_000, verifyMs = 30_000 } = {}) {
@@ -1048,17 +1267,34 @@ async function applyNewComposerThumb(page, thumbPath, log, { waitPickerMs = 20_0
1048
1267
  const before = snap;
1049
1268
  log('info', `[fb-pw] composer mới — hàng ảnh bìa trước khi dán: ${describeThumbRow(before)}`);
1050
1269
 
1270
+ // Bấm ô chọn-ảnh rồi chờ MỘT trong hai: hộp chọn file (biến thể mở thẳng)
1271
+ // hoặc hộp thoại "Chọn hình thu nhỏ" (biến thể đo thật 23/09).
1051
1272
  let via = '';
1052
- try {
1053
- const [chooser] = await Promise.all([
1054
- page.waitForEvent('filechooser', { timeout: 8000 }),
1055
- page.locator("[__fbpw_thumb_pick__='1']").first().click({ timeout: 5000 }),
1056
- ]);
1057
- await chooser.setFiles(thumbPath);
1058
- via = 'hộp chọn file';
1059
- } catch (e) {
1060
- log('info', `[fb-pw] composer mới — bấm ô "Chọn hình thu nhỏ" không mở hộp chọn file: ${String(e && e.message || e).slice(0, 80)}`);
1273
+ let chooser = null;
1274
+ const chooserP = page.waitForEvent('filechooser', { timeout: 8000 }).then((c) => { chooser = c; }).catch(() => {});
1275
+ let clickErr = '';
1276
+ await page.locator("[__fbpw_thumb_pick__='1']").first().click({ timeout: 5000 }).catch((e) => { clickErr = String(e && e.message || e).slice(0, 80); });
1277
+ let modal = null;
1278
+ const tClick = Date.now();
1279
+ while (!chooser && !modal && Date.now() - tClick < 8000) {
1280
+ await pause(page, 800);
1281
+ modal = await findThumbPickerModal(page);
1282
+ }
1283
+ if (chooser) {
1284
+ await chooser.setFiles(thumbPath).then(() => { via = 'hộp chọn file'; })
1285
+ .catch((e) => log('info', `[fb-pw] composer mới — hộp chọn file từ chối ảnh: ${String(e && e.message || e).slice(0, 80)}`));
1286
+ } else if (modal) {
1287
+ const r = await uploadViaThumbPickerModal(page, thumbPath, log, modal);
1288
+ if (!r.ok) {
1289
+ const d = await readNewComposerThumbRow(page, { diag: true });
1290
+ log('warn', `[fb-pw] composer mới — dán qua hộp thoại "Chọn hình thu nhỏ" KHÔNG xong (${r.reason}) — hàng ô: ${describeThumbRow(d)}`);
1291
+ return { applied: false, reason: r.reason };
1292
+ }
1293
+ via = r.via + (r.reason ? ` (${r.reason})` : '');
1294
+ } else {
1295
+ log('info', `[fb-pw] composer mới — bấm ô "Chọn hình thu nhỏ" không mở hộp chọn file lẫn hộp thoại${clickErr ? `: ${clickErr}` : ''}`);
1061
1296
  }
1297
+ void chooserP;
1062
1298
  if (!via) {
1063
1299
  await readNewComposerThumbRow(page); // cú bấm có thể làm FB dựng lại DOM → gắn cờ lại
1064
1300
  const input = page.locator("[__fbpw_thumb_input__='1']").first();
@@ -1625,7 +1861,7 @@ async function runOnce({ page, payload, log }) {
1625
1861
  } = payload || {};
1626
1862
  if (!video_url) throw new Error('No video_url provided');
1627
1863
 
1628
- log('info', '[fb-pw] selectors version=2026.09.23-new-composer-thumb');
1864
+ log('info', '[fb-pw] selectors version=2026.09.23-thumb-picker-modal');
1629
1865
 
1630
1866
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
1631
1867
 
@@ -4100,6 +4336,10 @@ module.exports.__testables = {
4100
4336
  describeThumbRow,
4101
4337
  readNewComposerThumbRow,
4102
4338
  applyNewComposerThumb,
4339
+ findThumbPickerModal,
4340
+ uploadViaThumbPickerModal,
4341
+ previewMatchesThumb,
4342
+ THUMB_PREVIEW_MAX_DIFF,
4103
4343
  // Dùng chung cho comment_facebook: script nào trượt giữa chừng cũng cần chụp
4104
4344
  // lại màn hình, viết bản thứ hai là sớm muộn hai bên lệch nhau.
4105
4345
  dumpFailure,