// ==UserScript== // @name X66自动化 // @namespace https://x66.local/frameno // @version 1.4.5 // @description X66 点云标注平台:帧号显示、首末帧跳转、Alt+1/2 点大小、一键执行(框体点数更新→自动化质检→保存→可选提交) // @author Bro // @match http://data-label-lan.gacrnd.com/* // @grant none // @run-at document-idle // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; // 只在标注编辑器页生效 if (!/\/tool\/pc/.test(location.pathname + location.search)) return; const LOG = (...a) => console.log('%c[X66帧号]', 'color:#2b8;font-weight:bold', ...a); // ========== 取真帧列表(排除叠帧伪帧 superFrame)========== function realFrames() { const e = window.editor; if (!e || !e.state || !Array.isArray(e.state.frames)) return []; return e.state.frames.filter(f => f && String(f.id) !== 'superFrame'); } function isSuperMode() { const e = window.editor; if (!e || !e.state) return false; if (e.state.config && e.state.config.frameSuperPosition) return true; // 兜底:frames 里存在 superFrame 伪帧,也视为叠帧模式 const frames = e.state.frames || []; return frames.some(f => f && String(f.id) === 'superFrame'); } // 当前帧在真帧数组中的下标(-1 表示未定位) function currentIndexOf() { const e = window.editor; const o = realFrames(); if (!o.length) return -1; if (isSuperMode() && e.frameSuperPositionManager) { const cur = String(e.frameSuperPositionManager.currentViewFrameId); return o.findIndex(f => String(f.id) === cur); } return Number(e.state.frameIndex) || 0; } // ========== 读取当前帧信息 ========== function getFrameInfo() { const e = window.editor; if (!e || !e.state) return null; const st = e.state; const bsm = (e.bsState && e.bsState.stateMap) || null; const real = realFrames(); const total = real.length || (bsm ? Object.keys(bsm).length : 0); const isSuper = isSuperMode(); // ---- 叠帧模式:当前帧 = currentViewFrameId(缺失时逐级兜底)---- if (isSuper) { let cur = ''; try { const m = e.frameSuperPositionManager; if (m && m.currentViewFrameId != null) cur = String(m.currentViewFrameId); } catch (x) { } let idx = -1; if (cur) idx = real.findIndex(f => String(f.id) === cur); // 兜底1:用 stateMap 反查 if (idx < 0 && cur && bsm) { for (const k of Object.keys(bsm)) { if (String(bsm[k].id) === cur) { idx = Number(k); break; } } } // 兜底2:currentViewFrameId 还没赋值时,退回 frameIndex if (idx < 0) { const f0 = Number(st.frameIndex); if (f0 >= 0 && f0 < real.length) idx = f0; } let name = ''; if (idx >= 0 && real[idx] && real[idx].name) name = real[idx].name; else if (bsm && idx >= 0 && bsm[idx] && bsm[idx].name) name = bsm[idx].name; return { mode: '叠帧', isSuper: true, no: idx >= 0 ? idx + 1 : null, total, name, id: cur }; } // ---- 普通模式:当前帧 = stateMap[frameIndex] ---- const fi = Number(st.frameIndex) || 0; let id = '', name = ''; if (bsm && bsm[fi]) { id = String(bsm[fi].id); name = bsm[fi].name || ''; } else if (real[fi]) { id = String(real[fi].id); name = real[fi].name || ''; } return { mode: '普通', isSuper: false, no: fi + 1, total, name, id }; } // ========== 跳帧(绕开平台 bug)========== let jumping = false; async function doJump(target) { const e = window.editor; if (!e || !e.state) return; if (jumping) return; // 防连点:并发切换会让视图状态错乱(画面抖动) jumping = true; try { // 叠帧模式 if (isSuperMode() && e.frameSuperPositionManager) { const m = e.frameSuperPositionManager; const o = realFrames(); if (!o.length) return; let a = currentIndexOf(); if (a < 0) a = 0; let na; if (target === 'first') na = 0; else if (target === 'last') na = o.length - 1; else na = a + Number(target); na = Math.max(0, Math.min(o.length - 1, na)); if (na === a) return; m.setViewImgByFrameId(o[na].id); render(); return; } // 普通模式:完整复刻平台翻帧流程 // 平台原生顺序:before_frame → status=LoadFrame → await loadFrame → status=Default → after_frame // 缺了 status 和事件,视图/布局状态会不同步,表现就是"画面忽大忽小" const st = e.state; const total = (st.frames || []).length; if (!total) return; const fi = Number(st.frameIndex) || 0; let ni; if (target === 'first') ni = 0; else if (target === 'last') ni = total - 1; else ni = fi + Number(target); ni = Math.max(0, Math.min(total - 1, ni)); if (ni === fi) return; try { e.dispatchEvent({ type: 'before_frame' }); } catch (x) { } const prevStatus = st.status; try { st.status = 'LoadFrame'; } catch (x) { } try { if (typeof e.loadFrame === 'function') await e.loadFrame(ni); else st.frameIndex = ni; } catch (x) { LOG('跳帧失败', x); } finally { try { st.status = prevStatus || 'Default'; } catch (x) { } } try { e.dispatchEvent({ type: 'after_frame' }); } catch (x) { } render(); } finally { jumping = false; } } // ========== 修复平台 bug:叠帧模式下 ← 切不到第一帧 ========== // 平台 preFrame 判定为 `if (a > 1)`,应为 `a > 0`, // 导致第 2 帧(a === 1)按 ← 无反应,永远回不到第 1 帧。 function tryFixPrevFrame(ev) { if (ev.key !== 'ArrowLeft') return false; if (ev.ctrlKey || ev.altKey || ev.metaKey || ev.shiftKey) return false; const t = ev.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return false; const e = window.editor; if (!e || !isSuperMode() || !e.frameSuperPositionManager) return false; const o = realFrames(); const a = currentIndexOf(); if (a === 1 && o.length > 1) { ev.stopPropagation(); ev.preventDefault(); e.frameSuperPositionManager.setViewImgByFrameId(o[0].id); render(); LOG('平台 bug 修复:已切到第 1 帧'); return true; } return false; } document.addEventListener('keydown', tryFixPrevFrame, true); // ========== 点云点大小快捷键(Alt+1 / Alt+2)========== // 平台 config.pointSize 有 watch 监听,赋值后会自动同步 shader uniform 并重渲染 // (uniform.pointSize = 10 * config.pointSize) const PT_SIZES = { '1': 0.1, '2': 0.2 }; // 需要更多档位时在此扩展 const PT_SW_KEY = 'x66fn_pointsize_sw'; let ptSwitch = localStorage.getItem(PT_SW_KEY) !== '0'; // 默认开启 // 点云未就绪时的补设定时器 let ptRetryTimer = null; // 直接写 shader uniform。平台的 watch 回调里要访问 groupPoints.children[0].material, // 新包刚打开、点云还没进场景时这里会抛错 → watch 白跑一次就不会再触发(值没变), // 所以必须由脚本自己把 uniform 写进去,不能只改 config.pointSize。 function applyPointSizeToMaterial(v) { const e = window.editor; if (!e || !e.pc) return false; const gp = e.pc.groupPoints; const children = gp && gp.children; if (!children || !children.length) return false; let hit = false; for (const obj of children) { const mat = obj && obj.material; if (mat && mat.uniforms && mat.uniforms.pointSize) { mat.uniforms.pointSize.value = 10 * v; // 平台规则:uniform = 10 × pointSize mat.uniformsNeedUpdate = true; hit = true; } } if (hit) { try { if (typeof e.pc.render === 'function') e.pc.render(); } catch (x) { } } return hit; } function applyPointSize(v) { const e = window.editor; if (!e || !e.state || !e.state.config) return false; e.state.config.pointSize = v; // 兼容:视图配置对象存在时一并同步(保存视图配置时使用) try { if (e.state.viewPanelConfig) e.state.viewPanelConfig.pointSize = v; } catch (x) { } if (!applyPointSizeToMaterial(v)) { // 点云还没加载好,稍后补设(最多重试约 6 秒) if (ptRetryTimer) { clearTimeout(ptRetryTimer); ptRetryTimer = null; } let tries = 0; const retry = () => { if (applyPointSizeToMaterial(v)) { ptRetryTimer = null; LOG('点云就绪,点大小补设成功 → ' + v); return; } if (++tries < 12) ptRetryTimer = setTimeout(retry, 500); else ptRetryTimer = null; }; ptRetryTimer = setTimeout(retry, 300); } LOG('点大小 → ' + v); updatePtVal(); return true; } function sizeFromEvent(ev) { if (ev.key != null && PT_SIZES[ev.key] !== undefined) return PT_SIZES[ev.key]; if (ev.code === 'Digit1' || ev.code === 'Numpad1') return PT_SIZES['1']; if (ev.code === 'Digit2' || ev.code === 'Numpad2') return PT_SIZES['2']; return null; } document.addEventListener('keydown', ev => { if (!ptSwitch) return; // 开关关闭则不响应 if (!ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) return; const t = ev.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; const v = sizeFromEvent(ev); if (v === null) return; ev.preventDefault(); ev.stopPropagation(); applyPointSize(v); }, true); // ========== 一键执行:框体点数更新 → 自动化质检 → 保存(→ 可选提交)========== const ONEKEY_SW_KEY = 'x66fn_oneshot_submit'; let oneshotSubmit = localStorage.getItem(ONEKEY_SW_KEY) === '1'; // 默认不勾选 let oneRunning = false; const sleep = ms => new Promise(r => setTimeout(r, ms)); // antd 会把两个中文字的按钮渲染成「确 定」「关 闭」(中间有空格), // 所以按钮文本比较一律先去空白 const btnTxt = b => (b && b.textContent ? b.textContent : '').replace(/\s+/g, ''); // 取平台 token(cookie: "gacrnd.com-prod token"),格式为 "Bearer xxx" function getAuthToken() { const hosts = [location.hostname, (location.hostname || '').split('.').slice(1).join('.')]; for (const h of hosts) { if (!h) continue; const name = h + '-prod token'; for (const part of document.cookie.split(';')) { const s = part.trim(); if (s.startsWith(name + '=')) { return 'Bearer ' + decodeURIComponent(s.slice(name.length + 1)); } } } return ''; } function waitFor(cond, timeoutMs, label) { return new Promise((resolve, reject) => { const t0 = Date.now(); const tick = () => { let ok = false; try { ok = !!cond(); } catch (x) { } if (ok) return resolve(true); if (Date.now() - t0 > timeoutMs) return reject(new Error((label || '等待超时') + '(' + Math.round(timeoutMs / 1000) + 's)')); setTimeout(tick, 250); }; tick(); }); } function setOneStatus(txt) { const el = document.getElementById('x66fn-onestat'); if (el) el.textContent = txt || ''; LOG('[一键] ' + txt); } // 展开顶部「质检」下拉菜单(菜单项是懒渲染的,首次进图时 DOM 还不存在) async function openQualityMenu() { let trigger = document.querySelector('.toolbar-item .ant-dropdown-trigger'); if (!trigger) { const item = [...document.querySelectorAll('.toolbar-item')] .find(x => (x.textContent || '').trim().startsWith('质检')); if (item) trigger = item.querySelector('.ant-dropdown-trigger') || item; } if (!trigger) throw new Error('找不到顶部「质检」按钮'); // antd 默认 hover 触发,多种事件都发一遍,提高成功率 const opts = { bubbles: true, cancelable: true }; trigger.dispatchEvent(new MouseEvent('mouseenter', opts)); trigger.dispatchEvent(new MouseEvent('mouseover', opts)); trigger.dispatchEvent(new MouseEvent('mousemove', opts)); // 若仍未展开,再补一次 click await sleep(500); if (!document.querySelector('.ant-dropdown-menu-item[data-menu-id="compute-point"]')) { trigger.dispatchEvent(new MouseEvent('mousedown', opts)); trigger.dispatchEvent(new MouseEvent('mouseup', opts)); trigger.click(); } await waitFor( () => document.querySelector('.ant-dropdown-menu-item[data-menu-id="compute-point"]'), 4000, '「质检」下拉菜单未展开' ); await sleep(200); } // ① 框体点数更新(纯前端计算,必须走面板) async function stepComputePoint() { const e = window.editor; // 菜单项的本质就是置 showComputePoint,直接设状态最可靠 // (下拉菜单懒渲染,而且 compute-point 在「标注」菜单里、并不在「质检」里) try { e.state.showComputePoint = true; } catch (x) { } let panel = null; try { await waitFor(() => document.querySelector('.select-label-container'), 4000); panel = document.querySelector('.select-label-container'); } catch (x) { panel = null; } // 兜底:状态没生效时挨个翻工具栏菜单找 compute-point if (!panel) { let menu = document.querySelector('.ant-dropdown-menu-item[data-menu-id="compute-point"]'); if (!menu) { for (const tb of [...document.querySelectorAll('.toolbar-item')]) { const trg = tb.querySelector('.ant-dropdown-trigger') || tb; const opts = { bubbles: true, cancelable: true }; trg.dispatchEvent(new MouseEvent('mouseenter', opts)); trg.dispatchEvent(new MouseEvent('mouseover', opts)); trg.click(); await sleep(350); menu = document.querySelector('.ant-dropdown-menu-item[data-menu-id="compute-point"]'); if (menu) break; } } if (menu) { menu.click(); try { await waitFor(() => document.querySelector('.select-label-container'), 5000); panel = document.querySelector('.select-label-container'); } catch (x) { panel = null; } } } if (!panel) throw new Error('框体点数面板打不开'); await sleep(400); const p = document.querySelector('.select-label-container'); if (!p) throw new Error('面板消失了'); const cbs = [...p.querySelectorAll('input[type=checkbox]')]; if (cbs.length && !cbs[0].checked) { cbs[0].click(); await sleep(300); } const ok = [...p.querySelectorAll('button')].find(b => btnTxt(b) === '确定'); if (!ok) throw new Error('找不到「确定」按钮'); ok.click(); // 执行过程中平台会弹出「已完成」确认框,需要自动点掉 await waitComputeDone(); await sleep(400); } // 等框体点数更新跑完:期间自动点掉"已完成"弹窗 async function waitComputeDone() { const t0 = Date.now(); const LIMIT = 120000; // 最多 2 分钟(原 5 分钟,卡住时体验太差) let lastLog = 0; while (Date.now() - t0 < LIMIT) { const panel = document.querySelector('.select-label-container'); if (!panel) return true; // 选标签面板消失 = 结束 const cands = [...document.querySelectorAll('button')] .filter(b => btnTxt(b) === '确定' && b.getBoundingClientRect().width > 0 && !panel.contains(b)); for (const btn of cands) { let box = btn, depth = 0, hit = false; while (box && depth < 6) { const txt = box.innerText || ''; if (/已完成|完成/.test(txt) && txt.length < 400) { hit = true; break; } box = box.parentElement; depth++; } if (hit) { btn.click(); LOG('已自动点击「已完成」确认'); await sleep(600); break; } } if (Date.now() - lastLog > 2000) { lastLog = Date.now(); setOneStatus('① 框体点数更新中… ' + Math.round((Date.now() - t0) / 1000) + 's'); } await sleep(400); } throw new Error('框体点数更新超过 2 分钟,已中止'); } // ② 自动化质检(直接调后台接口 + 标记校验项) async function stepAutoInspection() { const e = window.editor; const fr = e.state.frames[e.state.frameIndex] || {}; const datasetId = Number(fr.datasetId || e.bsState.datasetId); const projectId = Number(e.bsState.query && e.bsState.query.projectId); if (!datasetId) throw new Error('取不到 datasetId'); const token = getAuthToken(); if (!token) throw new Error('取不到登录 token(cookie 缺失,请刷新页面)'); const resp = await fetch('/annotationV2/qualityInspection/run', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': token }, body: JSON.stringify({ datasetId, projectId }) }); let j = null; try { j = await resp.json(); } catch (x) { } if (!resp.ok) throw new Error('质检接口 HTTP ' + resp.status); if (j && j.code !== undefined && j.code !== 0 && j.code !== 200) throw new Error('质检接口返回 code=' + j.code); try { e.verificationManager.updateVerificationState('SUBMIT_VERIFICATION', 'inspect', true); } catch (x) { } await sleep(800); } // 保存结束后平台会弹「实例标注 / 保存完成」框,自动点「关闭」 async function closeSaveDialog() { for (let i = 0; i < 40; i++) { // 最多等 20 秒 const btns = [...document.querySelectorAll('button')] .filter(b => /^(关闭|确定|确认|知道了)$/.test(btnTxt(b)) && b.getBoundingClientRect().width > 0); for (const btn of btns) { let box = btn, depth = 0, hit = false; while (box && depth < 12) { // 层级放宽到 12 层 const txt = box.innerText || ''; if (/保存完成|实例标注|保存失败|保存成功/.test(txt) && txt.length < 900) { hit = true; break; } box = box.parentElement; depth++; } if (hit) { btn.click(); LOG('已自动关闭保存弹窗'); await sleep(500); return true; } } await sleep(500); } return false; } // ③ 保存 async function stepSave() { const e = window.editor; if (e.bsState.saving) await waitFor(() => !e.bsState.saving, 120000, '上一次保存未结束'); if (typeof e.saveObject === 'function') { await e.saveObject(); } else { const btn = document.querySelector('.save-warp button.ant-btn-icon-only'); if (!btn) throw new Error('找不到保存按钮'); btn.click(); } await waitFor(() => !e.bsState.saving, 120000, '保存未完成'); await sleep(600); await closeSaveDialog(); // 自动点掉"保存完成"弹窗 await sleep(300); } // ④ 提交(可选) async function stepSubmit() { const e = window.editor; if (e.verificationManager && typeof e.verificationManager.validate === 'function') { let pass = true; try { pass = e.verificationManager.validate('SUBMIT_VERIFICATION'); } catch (x) { } if (!pass) throw new Error('提交前校验未通过(有校验项未执行)'); } const btn = [...document.querySelectorAll('.save-warp button')] .find(b => btnTxt(b) === '提交'); if (!btn) throw new Error('找不到「提交」按钮'); btn.click(); // 提交后弹「提示 / 任务提交,请确认」,轮询等待并自动点确定(最多 20 秒) for (let i = 0; i < 40; i++) { await sleep(500); const cands = [...document.querySelectorAll('button')] .filter(b => /^(确定|确认)$/.test(btnTxt(b)) && b.getBoundingClientRect().width > 0); let done = false; for (const c of cands) { let box = c, depth = 0, hit = false; while (box && depth < 12) { const txt = box.innerText || ''; if (/任务提交|请确认/.test(txt) && txt.length < 400) { hit = true; break; } box = box.parentElement; depth++; } if (hit) { c.click(); LOG('已自动确认提交'); await sleep(900); done = true; break; } } if (done) return; } LOG('未出现提交确认框(可能已直接提交)'); } // 叠帧状态下「框体点数更新」和「保存」都会出问题,执行前必须先退出叠帧 // 正确方式(实测有效):走平台 action 系统,内部会 cmdManager.reset / selectObject / 派发事件 async function ensureExitSuperFrame() { const e = window.editor; const isSuper = () => !!(e.state.config && e.state.config.frameSuperPosition) || (e.state.frames || []).some(f => f && String(f.id) === 'superFrame'); if (!isSuper()) return false; setOneStatus('检测到叠帧,正在退出…'); LOG('检测到叠帧状态,先退出叠帧'); // 方式 1:平台 action(与 Shift+加号 同一条路径) try { if (e.actionManager && typeof e.actionManager.execute === 'function') { await e.actionManager.execute('exitFrameSuperPosition', { exitFrameSuperPositionWith: 'end' }); } } catch (x) { LOG('action 退出异常:' + x.message); } for (let i = 0; i < 12; i++) { if (!isSuper()) { LOG('已退出叠帧'); return true; } await sleep(500); } // 方式 2:直接调管理器 try { const m = e.frameSuperPositionManager; if (m && typeof m.exitFrameSuperPositionWithEnd === 'function') { await m.exitFrameSuperPositionWithEnd(); for (let i = 0; i < 12; i++) { if (!isSuper()) { LOG('已通过 exitFrameSuperPositionWithEnd 退出叠帧'); return true; } await sleep(500); } } } catch (x) { LOG('备用退出异常:' + x.message); } throw new Error('叠帧退出失败,请手动按 Shift+加号 退出后再执行'); } async function runOneKey() { if (oneRunning) { setOneStatus('正在执行中…'); return; } const e = window.editor; if (!e || !e.state) { setOneStatus('页面未就绪'); return; } oneRunning = true; const btn = document.getElementById('x66fn-onebtn'); if (btn) { btn.disabled = true; btn.style.opacity = '.55'; } const t0 = Date.now(); try { await ensureExitSuperFrame(); // 叠帧下更新/保存会出 bug,先退出 setOneStatus('① 框体点数更新…'); await stepComputePoint(); setOneStatus('② 自动化质检…'); await stepAutoInspection(); setOneStatus('③ 保存中…'); await stepSave(); const sec = Math.round((Date.now() - t0) / 1000); if (oneshotSubmit) { setOneStatus('④ 提交中…'); await stepSubmit(); setOneStatus('✓ 全部完成(含提交)' + sec + 's'); } else { setOneStatus('✓ 三步完成 ' + sec + 's'); } } catch (err) { setOneStatus('✗ ' + (err && err.message ? err.message : err)); } finally { oneRunning = false; if (btn) { btn.disabled = false; btn.style.opacity = ''; } } } // ========== UI ========== let box = null, mainEl = null, subEl = null, btnRow = null, ptValEl = null, lastKey = ''; let collapsed = false; // 折叠状态(独立记录,render 不得覆盖) // 应用折叠状态 function applyCollapse() { if (!box) return; const hide = collapsed; if (subEl) subEl.style.display = hide ? 'none' : ''; if (btnRow) btnRow.style.display = hide ? 'none' : ''; const pr = box.querySelector('#x66fn-ptrow'); if (pr) pr.style.display = hide ? 'none' : ''; const oner = box.querySelector('#x66fn-onerow'); if (oner) oner.style.display = hide ? 'none' : ''; box.style.width = hide ? '116px' : '228px'; box.style.padding = hide ? '7px 9px' : '8px 10px'; } function updatePtVal() { if (!ptValEl) return; const e = window.editor; const v = e && e.state && e.state.config ? e.state.config.pointSize : null; ptValEl.textContent = v == null ? '—' : String(v); } function buildUI() { if (box) return; box = document.createElement('div'); box.id = 'x66-frame-no'; box.style.cssText = [ 'position:fixed', 'top:52px', 'right:16px', 'z-index:2147483645', 'width:228px', 'box-sizing:border-box', 'background:rgba(20,22,26,.86)', 'color:#fff', 'border:1px solid rgba(255,255,255,.18)', 'border-radius:9px', 'padding:8px 10px', 'font:13px/1.35 -apple-system,"Segoe UI",sans-serif', 'text-align:center', 'box-shadow:0 3px 14px rgba(0,0,0,.3)', 'backdrop-filter:blur(4px)', 'cursor:move', 'user-select:none' ].join(';'); const btnCss = 'flex:1;padding:3px 0;border:1px solid rgba(255,255,255,.22);background:rgba(255,255,255,.07);color:#fff;border-radius:4px;cursor:pointer;font-size:12px;line-height:1.4;'; box.innerHTML = '
— / —
' + '
' + '
' + '' + '' + '' + '' + '
' + '
' + '' + '—' + '
' + '
' + '' + '' + '
' + '
'; document.body.appendChild(box); mainEl = box.querySelector('#x66fn-main'); subEl = box.querySelector('#x66fn-sub'); btnRow = box.querySelector('#x66fn-btns'); ptValEl = box.querySelector('#x66fn-ptval'); // 翻帧按钮 btnRow.addEventListener('mousedown', ev => ev.stopPropagation()); // 防止触发拖拽 btnRow.addEventListener('click', ev => { const v = ev.target && ev.target.getAttribute && ev.target.getAttribute('data-j'); if (!v) return; doJump(v); }); // 点大小快捷键开关 const sw = box.querySelector('#x66fn-ptsw'); if (sw) { sw.checked = ptSwitch; sw.parentElement.addEventListener('mousedown', ev => ev.stopPropagation()); sw.addEventListener('change', ev => { ptSwitch = ev.target.checked; localStorage.setItem(PT_SW_KEY, ptSwitch ? '1' : '0'); LOG('点大小快捷键 ' + (ptSwitch ? '开启' : '关闭')); }); } // 一键:完成后是否提交(默认不勾选) const sw2 = box.querySelector('#x66fn-onesw'); if (sw2) { sw2.checked = oneshotSubmit; sw2.parentElement.addEventListener('mousedown', ev => ev.stopPropagation()); sw2.addEventListener('change', ev => { oneshotSubmit = ev.target.checked; localStorage.setItem(ONEKEY_SW_KEY, oneshotSubmit ? '1' : '0'); LOG('完成后提交:' + (oneshotSubmit ? '开' : '关')); }); } // 一键执行按钮 const oneBtn = box.querySelector('#x66fn-onebtn'); if (oneBtn) { oneBtn.addEventListener('mousedown', ev => ev.stopPropagation()); oneBtn.addEventListener('click', ev => { ev.stopPropagation(); runOneKey(); }); } // 拖拽 let sx = 0, sy = 0, ox = 0, oy = 0, moving = false; box.addEventListener('mousedown', ev => { if (ev.button !== 0) return; if (ev.target && (ev.target.tagName === 'BUTTON' || ev.target.tagName === 'INPUT')) return; moving = true; sx = ev.clientX; sy = ev.clientY; const r = box.getBoundingClientRect(); ox = r.left; oy = r.top; ev.preventDefault(); }); document.addEventListener('mousemove', ev => { if (!moving) return; box.style.left = (ox + ev.clientX - sx) + 'px'; box.style.top = (oy + ev.clientY - sy) + 'px'; box.style.right = 'auto'; }); document.addEventListener('mouseup', () => { moving = false; }); // 折叠:只有双击「帧号文字」才触发(连点按钮不会误折叠) mainEl.addEventListener('dblclick', (ev) => { ev.stopPropagation(); collapsed = !collapsed; applyCollapse(); }); } function render() { if (!box) return; const info = getFrameInfo(); if (!info) { if (lastKey !== 'wait') { lastKey = 'wait'; mainEl.textContent = '等待中…'; subEl.textContent = '未检测到编辑器'; } updatePtVal(); return; } // 按钮始终显示:doJump 已兼容叠帧/普通两种翻帧方式。 // (原来按 isSuper 开关,判断一抖动整行按钮就消失,而且会覆盖折叠状态) const key = info.mode + '|' + info.no + '|' + info.total + '|' + info.id; if (key !== lastKey) { lastKey = key; mainEl.textContent = info.no ? '第 ' + info.no + ' / ' + info.total + ' 帧' : '— / ' + info.total + ' 帧'; const parts = []; if (info.isSuper) parts.push('叠帧'); if (info.name) parts.push(info.name); subEl.textContent = parts.join(' · ') || (info.id ? String(info.id) : ''); subEl.title = subEl.textContent; } updatePtVal(); } // ========== 启动 ========== function boot() { buildUI(); render(); setInterval(render, 150); // 帧事件触发时立即刷新 const hook = setInterval(() => { const e = window.editor; if (e && e.addEventListener && !e.__x66fnHooked) { e.__x66fnHooked = true; ['frame_change', 'before_frame', 'after_frame', 'resource_load_complete'].forEach(t => { try { e.addEventListener(t, () => setTimeout(render, 0)); } catch (x) { } }); LOG('已挂载帧事件监听'); clearInterval(hook); } }, 1000); setTimeout(() => clearInterval(hook), 120000); LOG('v1.4.5 已加载(叠帧用 action 系统退出,已实测)'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();