From 9030fa6a00f0822845654e4e3fc98afe06964427 Mon Sep 17 00:00:00 2001 From: klarkxy <278370456@qq.com> Date: Sat, 31 Oct 2026 23:17:46 +0800 Subject: [PATCH 1/3] feat(cutline): send a clip, moment, marker or media reference to the chat Right-click a clip, an empty spot on a track, the ruler, a marker or a file in the Project bin and choose "Send to chat". The Host shows its own confirmation and appends a plain-text reference to the chat draft: clip, track and media ids, timeline seconds and frame, the open sequence and composition, and the project revision, so the Agent can act on "here" without guessing. - chat-context.js builds the reference from pure functions; labels follow the interface language, ids, seconds and user text stay as stored, user text is reduced to one bounded line and the whole text stays under 8,000 characters. - Right-clicking a marker now opens "Edit marker..." / "Send marker to chat"; double-click still opens the editor. - The items stay visible and disabled when the client has no message.append. - Unit tests for the text and frame snapping at seven frame rates; a Chromium test drives every menu with the Host bridge stubbed. - Checked on MiniMax Code 3.1.9 for Windows. Version 0.2.0. --- .../cutline/.minimax-plugin/plugin.json | 2 +- plugins/klarkxy/cutline/PATCHES.md | 4 + plugins/klarkxy/cutline/README.md | 6 + plugins/klarkxy/cutline/README.zh-CN.md | 6 + plugins/klarkxy/cutline/miniapp/client/app.js | 106 ++++++- .../cutline/miniapp/client/chat-context.js | 253 ++++++++++++++++ .../klarkxy/cutline/miniapp/client/i18n-en.js | 31 ++ .../cutline/miniapp/client/i18n-zh-CN.js | 31 ++ .../klarkxy/cutline/miniapp/client/index.html | 1 + plugins/klarkxy/cutline/package.json | 2 +- .../klarkxy/cutline/skills/cutline/SKILL.md | 17 ++ .../cutline/tests/chat-context.test.mjs | 144 +++++++++ plugins/klarkxy/cutline/tests/i18n.test.mjs | 2 +- .../cutline/tests/send-to-chat.test.mjs | 281 ++++++++++++++++++ 14 files changed, 876 insertions(+), 10 deletions(-) create mode 100644 plugins/klarkxy/cutline/miniapp/client/chat-context.js create mode 100644 plugins/klarkxy/cutline/tests/chat-context.test.mjs create mode 100644 plugins/klarkxy/cutline/tests/send-to-chat.test.mjs diff --git a/plugins/klarkxy/cutline/.minimax-plugin/plugin.json b/plugins/klarkxy/cutline/.minimax-plugin/plugin.json index 4ab526b..778389f 100644 --- a/plugins/klarkxy/cutline/.minimax-plugin/plugin.json +++ b/plugins/klarkxy/cutline/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "cutline", "displayName": "Cutline", - "version": "0.1.1", + "version": "0.2.0", "description": "浏览器里的视频剪辑台:时间线剪切、字幕、动效、调色与导出,聊天里一句话就能剪片。", "author": "klarkxy", "icon": "icon.png", diff --git a/plugins/klarkxy/cutline/PATCHES.md b/plugins/klarkxy/cutline/PATCHES.md index 7ad42f4..d74b641 100644 --- a/plugins/klarkxy/cutline/PATCHES.md +++ b/plugins/klarkxy/cutline/PATCHES.md @@ -1,5 +1,9 @@ # Patches to the vendored FableCut source +## Send to chat + +Right-click menus can hand the conversation a plain-text reference to what the user points at. The clip menu ends with **Send to chat** (the selection plus the right-clicked moment); an empty spot on a track and the ruler offer **Send this moment to chat**; the media menu offers **Send to chat** after Open in Source. A right-click on a marker now opens a two-item menu, **Edit marker…** and **Send marker to chat**; double-click still opens the editor directly. The new `miniapp/client/chat-context.js` (global `FableCutChat`, loaded before `app.js`) builds the text from pure functions: ids, track ids and timeline seconds as the MCP tools address them, timecode in the editor's own `MM:SS:FF`, frame numbers, the open sequence, the composition path and the project revision. User text is reduced to one bounded line, lists are capped and the whole reference stays under 8,000 characters. Labels go through `T()`; ids, seconds and user text do not. The page calls `window.mcode.miniapp.message.append`, which needs no manifest declaration and shows the Host's own confirmation; the items stay visible and disabled with a tooltip when the bridge is missing, and each rejection code gets its own toast. Thirty dictionary keys were added to both languages. `tests/chat-context.test.mjs` covers the text, frame snapping at seven frame rates, hostile names and the size budget; `tests/send-to-chat.test.mjs` drives every menu in Chromium with the bridge stubbed, a cancelled confirmation, Chinese labels and a client without the bridge. + ## Compact monitor and localized menus The monitor header keeps one row of tabs and icon controls. Canvas aspect, frame rate, export framing and resolution move into a keyboard-accessible popover; the original controls and stored values remain unchanged. Snap targets and sequence actions follow the selected language while user sequence names stay untouched and escaped. Playback controls wrap when the monitor is narrow, avoiding overlap with timecodes. Six browser tests cover menu localization, raw values, escaped names, popover operation and layout at five viewport widths. diff --git a/plugins/klarkxy/cutline/README.md b/plugins/klarkxy/cutline/README.md index 6e344d8..9f6726c 100644 --- a/plugins/klarkxy/cutline/README.md +++ b/plugins/klarkxy/cutline/README.md @@ -33,6 +33,10 @@ You can also ask in chat: Manual and Agent-driven edits operate on the same project, so you can take over at any point. +### Point the Agent at a spot + +Right-click a clip, an empty spot on a track, the ruler, a marker or a file in the Project bin and choose **Send to chat**. MiniMax Code asks you to confirm, then adds a short reference to the chat input: clip and media ids, tracks, timeline seconds and frame, the open sequence and composition, and the project revision. Type what you want changed after it and press Send. A clip reference also carries the moment you right-clicked, so "split here" or "the caption at this point" lands where you meant. The text stays in the draft until you send it, and you can edit it first. + ## Choose an export method ![Cutline export dialog in English: range, presets, video and audio settings](docs/export-en.png) @@ -54,6 +58,7 @@ Standalone export needs a local Chrome or Edge installation and uses the project ## Tested environment - Target: MiniMax Code 3.1.2 or later. Installation, media import, timeline editing and standalone export were exercised on Windows 11 with Google Chrome, including a 180-second render. +- **Send to chat** was checked on MiniMax Code 3.1.9 for Windows: all five right-click entries appear, the Host shows its confirmation, and the confirmed reference lands in the chat draft with correct ids, times and frames at 24 fps. A Chromium browser test with the Host bridge stubbed covers the text for each menu, a cancelled confirmation, both interface languages and a client without the API. - macOS, every effect combination and sustained long-form or high-resolution workloads remain on the validation roadmap. Current records cover the tested scenarios. Run `npm test --prefix plugins/klarkxy/cutline` from the repository root for plugin tests, and `npm run check` for repository validation. @@ -65,6 +70,7 @@ Run `npm test --prefix plugins/klarkxy/cutline` from the repository root for plu - **Network:** the editor and MCP endpoint use the Host-assigned local address. HTTPS imports contact the supplied URL; optional font loading contacts `fonts.googleapis.com` and `fonts.gstatic.com`, and optional background removal contacts `cdn.jsdelivr.net`. Standalone export is isolated from the external network. - **Processes:** media operations invoke `ffmpeg` and `ffprobe`; the Agent starts and manages Node plus Chrome or Edge for standalone export. - **Agent turns and conversations:** Agent turns start from your chat. The plugin exposes MCP tools, leaves `agentAccess` undeclared and does not read conversation content or other sessions. All conversations use the same open project. +- **Chat input:** **Send to chat** calls the page API `message.append`, which needs no declaration. Each use shows the Host's confirmation with the exact text; after you confirm, the text joins the unsent draft of the conversation the Mini App is open in. The reference holds ids, times, track names, media file names, clip names, the first line of a title, marker labels and the project name. The page sends nothing on its own and reads no chat history. On a client without the API the menu items stay visible and disabled. - **Host data and devices:** Host configuration, credentials, private logs, session databases and the microphone sit outside the plugin's access. ## Upstream and maintenance diff --git a/plugins/klarkxy/cutline/README.zh-CN.md b/plugins/klarkxy/cutline/README.zh-CN.md index 1f5d7aa..f854288 100644 --- a/plugins/klarkxy/cutline/README.zh-CN.md +++ b/plugins/klarkxy/cutline/README.zh-CN.md @@ -33,6 +33,10 @@ Cutline 是基于 [FableCut](https://github.com/ronak-create/FableCut) 的社区 手动编辑和 Agent 编辑使用同一个工程,你可以随时接管。 +### 给 Agent 指一个位置 + +在片段、轨道空白处、时间标尺、标记或项目素材上点右键,选 **发送到对话**。MiniMax Code 弹出确认后,会在对话输入框里加一段简短引用:片段与素材 id、轨道、时间线秒数和帧号、当前序列与合成、工程修订号。接着写上要改什么,再按发送即可。片段引用还带着你右键的那个时间点,所以“从这里切开”“这个时间点的字幕”都能落在你指的位置。引用文字先进入草稿,发送前可以随意修改。 + ## 选择导出方式 ![Cutline 中文导出面板:范围、预设、视频和音频设置](docs/export-zh-CN.png) @@ -54,6 +58,7 @@ Cutline 是基于 [FableCut](https://github.com/ronak-create/FableCut) 的社区 ## Tested environment - 目标客户端:MiniMax Code 3.1.2 或更高版本。已在 Windows 11 与 Google Chrome 环境中验证安装、素材导入、时间线编辑和独立导出,包括一次 180 秒渲染。 +- **发送到对话** 已在 Windows 版 MiniMax Code 3.1.9 上实测:五个右键入口都会出现,Host 会弹出确认框,确认后引用写入对话草稿,24 fps 下的 id、时间和帧号均正确。另有 Chromium 浏览器测试(Host 桥接为测试替身),覆盖各菜单生成的文字、取消确认、中英两种界面语言,以及不支持该 API 的客户端。 - macOS、全部效果组合及长片/高分辨率负载仍待完整验证。当前记录代表已实测场景。 在仓库根目录运行 `npm test --prefix plugins/klarkxy/cutline` 执行插件测试,运行 `npm run check` 执行仓库校验。 @@ -65,6 +70,7 @@ Cutline 是基于 [FableCut](https://github.com/ronak-create/FableCut) 的社区 - **网络:** 编辑器和 MCP 使用 Host 分配的本机地址。HTTPS 导入访问你提供的 URL;可选字体功能访问 `fonts.googleapis.com`、`fonts.gstatic.com`,可选背景移除访问 `cdn.jsdelivr.net`。独立导出与外部网络隔离。 - **进程:** 媒体处理调用 `ffmpeg`、`ffprobe`;独立导出由 Agent 启动并管理 Node、Chrome 或 Edge 后台任务。 - **Agent 与会话:** Agent 回合由你在聊天中发起。插件提供 MCP 工具,未声明 `agentAccess`,不会读取聊天内容或其他会话;所有会话共用当前打开的工程。 +- **对话输入框:** **发送到对话** 调用页面 API `message.append`,该 API 无需声明。每次使用都会弹出 Host 自己的确认框并显示完整文字;确认后文字追加到 Mini App 所在对话的未发送草稿。引用内容包括 id、时间、轨道名、素材文件名、片段名、字幕首行、标记名和工程名。页面不会自行发送消息,也不读取聊天记录。客户端不支持该 API 时,菜单项保持可见并置灰。 - **Host 数据与设备:** Host 配置、凭据、私有日志、会话数据库和麦克风均位于访问范围之外。 ## 上游与维护 diff --git a/plugins/klarkxy/cutline/miniapp/client/app.js b/plugins/klarkxy/cutline/miniapp/client/app.js index d3590b5..e868daf 100644 --- a/plugins/klarkxy/cutline/miniapp/client/app.js +++ b/plugins/klarkxy/cutline/miniapp/client/app.js @@ -406,8 +406,76 @@ function showTrackCtxMenu(clientX, clientY, track) { menu.style.top = Math.max(pad, y) + "px"; btn.focus(); } +/* ── Send to chat: hand the conversation a reference to a clip, a moment, a + marker or a media file (chat-context.js builds the text). The Host shows its + own confirmation and appends the text to the chat input; nothing is sent + until the user presses Send. ── */ +const ChatRef = self.FableCutChat; +function chatAppendApi() { + const message = window.mcode?.miniapp?.message; + return message && typeof message.append === "function" ? message : null; +} +/** Why the menu item is disabled, or null when the chat input can take text. */ +function chatUnavailable() { + return chatAppendApi() ? null : T("This version of MiniMax Code cannot add text to the chat input"); +} +/** Where the user is: canvas fps, project, revision, open sequence, composition path. */ +function chatContext(extra = {}) { + const root = seqRoot(); + const seqId = SeqLib.activeId(root); + const seq = SeqLib.find(root, seqId) || SeqLib.MAIN; + const compPath = state.compCtx + ? state.compCtx.path.map((id) => ({ id, name: (CompLib.getComp(state.compCtx.root, id) || {}).name || id })) + : []; + return { + fps: projectFps(), projectName: root.name || "", revision: project.revision ?? null, + sequence: { id: seq.id || seqId, name: seq.name || seqId }, compPath, + getMedia, getComp, clips: project.clips.filter((c) => trackOf(c)), ...extra, + }; +} +let chatSending = false; +async function sendToChat(text) { + const message = chatAppendApi(); + if (!message) { toast(chatUnavailable()); return; } + if (chatSending) return; + chatSending = true; + try { + await message.append(text); + toast(T("Added to the chat input — add your request and press Send")); + } catch (err) { + const code = err && err.message; + if (code === "user_cancelled") toast(T("Not added to the chat")); + else if (code === "target_unavailable") toast(T("No conversation to add to — open Cutline from a chat")); + else if (code === "result_unknown") toast(T("It may already be in the chat input — check there before trying again")); + else toast(T("Could not add to the chat ({code})", { code: code || "error" })); + } finally { + chatSending = false; + } +} +/** A small right-click menu of [label, run, disabledReason] rows, placed at (x, y). */ +function showChatMenu(clientX, clientY, rows) { + hideTrackCtxMenu(); + const menu = document.createElement("div"); + menu.className = "ctx-menu"; + menu.id = "trackCtxMenu"; + for (const [label, run, why] of rows) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ctx-item"; + btn.textContent = label; + if (why) { btn.disabled = true; btn.title = why; } + else btn.addEventListener("click", () => { hideTrackCtxMenu(); run(); }); + menu.appendChild(btn); + } + document.body.appendChild(menu); + trackCtxMenu = menu; + const pad = 6; + menu.style.left = Math.max(pad, Math.min(clientX, window.innerWidth - menu.offsetWidth - pad)) + "px"; + menu.style.top = Math.max(pad, Math.min(clientY, window.innerHeight - menu.offsetHeight - pad)) + "px"; + menu.querySelector(".ctx-item:not(:disabled)")?.focus(); +} /* Clip right-click menu: enable / lock / link for the clip's selection. */ -function showClipCtxMenu(clientX, clientY, c) { +function showClipCtxMenu(clientX, clientY, c, atTime = null) { hideTrackCtxMenu(); if (!state.selIds.has(c.id)) selectClip(c.id); const group = withLinked(selectedClips()); @@ -447,6 +515,11 @@ function showClipCtxMenu(clientX, clientY, c) { const why = linkRefusal(selectedClips()); item(T("Link audio / video"), "Ctrl+L", toggleLinkSelected, why); } + const sep = document.createElement("div"); + sep.className = "ctx-sep"; + menu.appendChild(sep); + const picked = selectedClips(); + item(T("Send to chat"), "", () => sendToChat(ChatRef.clipsReference(picked, chatContext({ at: atTime }), T)), chatUnavailable()); document.body.appendChild(menu); trackCtxMenu = menu; const pad = 6; @@ -2806,6 +2879,7 @@ function showMediaMenu(x, y, m) { }; const sep = () => { const d = document.createElement("div"); d.className = "ctx-sep"; menu.appendChild(d); }; item(T("Open in Source"), () => loadSourceFromMedia(m)); + item(T("Send to chat"), () => sendToChat(ChatRef.mediaReference(m, MediaLib.placements(usageDoc(), m.id), chatContext(), T)), chatUnavailable()); item(T("Find timeline instances ({uses})", { uses }), () => showMediaInstances(m, x, y), uses ? null : "Not used on the timeline"); item(T("Media info…"), () => showMediaInfo(m, x, y)); item(isMediaOffline(m.id) ? T("Relink…") : T("Relink to another copy…"), () => openRelinkDialog([m]), state.connected ? null : T("Needs the Cutline server")); @@ -5960,14 +6034,23 @@ function startTrimToolGesture(e, c, mode) { window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); } -/* Right-click a clip: enable / lock / link menu. */ +/* Right-click a clip: enable / lock / link menu. Right-click an empty spot on a + track: send that moment to the chat. */ els.tracksContent.addEventListener("contextmenu", (e) => { + const at = ChatRef.snapToFrame(timeAtEvent(e), projectFps()); const clipDiv = e.target.closest(".clip"); - if (!clipDiv) return; + if (!clipDiv) { + const track = trackAtEvent(e); + if (!track) return; + e.preventDefault(); + showChatMenu(e.clientX, e.clientY, [[T("Send this moment to chat"), + () => sendToChat(ChatRef.timeReference(at, chatContext({ track }), T)), chatUnavailable()]]); + return; + } const c = getClip(clipDiv.dataset.id); if (!c) return; e.preventDefault(); - showClipCtxMenu(e.clientX, e.clientY, c); + showClipCtxMenu(e.clientX, e.clientY, c, at); }); els.tracksContent.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; @@ -6315,10 +6398,19 @@ els.ruler.addEventListener("dblclick", (e) => { if (mk) openMarkerEditor(mk, e.clientX - 20, e.clientY + 10); }); els.ruler.addEventListener("contextmenu", (e) => { - const mk = markerAtRulerEvent(e); - if (!mk) return; e.preventDefault(); - openMarkerEditor(mk, e.clientX, e.clientY); + const mk = markerAtRulerEvent(e); + const x = e.clientX, y = e.clientY; + if (mk) { + showChatMenu(x, y, [ + [T("Edit marker…"), () => openMarkerEditor(mk, x, y), null], + [T("Send marker to chat"), () => sendToChat(ChatRef.markerReference(mk, chatContext(), T)), chatUnavailable()], + ]); + return; + } + const at = ChatRef.snapToFrame(timeAtEvent(e), projectFps()); + showChatMenu(x, y, [[T("Send this moment to chat"), + () => sendToChat(ChatRef.timeReference(at, chatContext(), T)), chatUnavailable()]]); }); els.ruler.addEventListener("pointermove", (e) => { if (state.gesture) return; diff --git a/plugins/klarkxy/cutline/miniapp/client/chat-context.js b/plugins/klarkxy/cutline/miniapp/client/chat-context.js new file mode 100644 index 0000000..3852113 --- /dev/null +++ b/plugins/klarkxy/cutline/miniapp/client/chat-context.js @@ -0,0 +1,253 @@ +/* Cutline — "Send to chat" references. + * + * The editor's right-click menus can hand the conversation a short, plain-text + * reference to what the user is pointing at: a clip, a moment on the + * timeline, a marker or a media file. The Host appends the text to the chat + * input after its own confirmation; the user adds an instruction and sends. + * + * The reference names things the way the MCP tools address them — clip ids, + * track ids, media ids, timeline seconds, the open sequence and composition, + * the project revision — so the Agent can act on it without guessing. Labels + * follow the interface language through the `T` the editor passes in; ids, + * seconds, file names and user text are written exactly as stored. + * + * Pure functions, zero dependencies. Loaded by the editor as a plain script + * (global `FableCutChat`) and required by the tests. */ +(function (root, factory) { + const node = typeof module === "object" && module.exports; + const api = factory(); + if (node) module.exports = api; + else root.FableCutChat = api; +})(typeof self !== "undefined" ? self : this, function () { + "use strict"; + + /** Clips listed in one reference; the rest are counted. */ + const MAX_CLIPS = 20; + /** Timeline placements listed for one media file; the rest are counted. */ + const MAX_USES = 12; + /** Longest single value (a name, a caption line) kept in a reference. */ + const MAX_VALUE = 120; + /** The Host accepts up to 100,000 UTF-16 code units; stay well inside it. */ + const MAX_TEXT = 8000; + + const identity = (s, vars) => (vars ? String(s).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : String(s)); + + /** One line of user text: no line breaks, no control characters, bounded. */ + function oneLine(value, max = MAX_VALUE) { + // eslint-disable-next-line no-control-regex + const s = String(value ?? "").replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim(); + return s.length > max ? s.slice(0, max - 1) + "…" : s; + } + + function validFps(fps) { + const n = Number(fps); + return Number.isFinite(n) && n > 0 ? n : 30; + } + + /** Whole frames at `fps`. A thousandth of a frame of tolerance keeps a + frame start that was rounded a hair low (2 s → 59.99999 frames) on its + own frame instead of the one before. */ + function frameOf(t, fps) { + return Math.floor(Math.max(0, Number(t) || 0) * validFps(fps) + 1e-3); + } + + /** The time a frame starts at. Rounded to the microsecond, not the + millisecond: at 29.97 fps frame 30 starts at 1.001001 s, and 1.001 would + read back as frame 29. */ + function snapToFrame(t, fps) { + const f = validFps(fps); + return Math.round((frameOf(t, f) / f) * 1e6) / 1e6; + } + + /** "MM:SS:FF" — the same formula as the editor's `fmt()`, so the reference + reads exactly like the timecode on screen. */ + function timecode(t, fps) { + const f = validFps(fps); + const x = Math.max(0, Number(t) || 0) + 1e-6; + const m = Math.floor(x / 60), s = Math.floor(x % 60), ff = Math.floor((x % 1) * f); + const p = (n) => String(n).padStart(2, "0"); + return `${p(m)}:${p(s)}:${p(ff)}`; + } + + /** Seconds with at most three decimals and no trailing zeros: "2", "2.5", "2.033". */ + function seconds(t) { + const n = Math.round((Number(t) || 0) * 1000) / 1000; + return String(Object.is(n, -0) ? 0 : n); + } + + /** "00:02:00 (2s)" */ + function at(t, fps) { + return `${timecode(t, fps)} (${seconds(t)}s)`; + } + + /** "00:02:00 → 00:05:15 (2s → 5.5s, 3.5s)" */ + function span(start, duration, fps) { + const s = Number(start) || 0, d = Math.max(0, Number(duration) || 0); + return `${timecode(s, fps)} → ${timecode(s + d, fps)} (${seconds(s)}s → ${seconds(s + d)}s, ${seconds(d)}s)`; + } + + /** The file name a media src points at, decoded, without query or folders. */ + function fileName(src) { + const raw = String(src ?? "").split(/[?#]/)[0]; + const last = raw.split("/").pop() || ""; + try { return decodeURIComponent(last); } catch { return last; } + } + + /** What a clip is called on the timeline. */ + function clipName(c, ctx) { + if (!c) return ""; + if (c.kind === "text") return oneLine(String(c.props?.text ?? "").split("\n")[0]); + if (c.kind === "comp") { + const comp = ctx?.getComp ? ctx.getComp(c.compId) : null; + return oneLine((comp && comp.name) || c.name || ""); + } + return oneLine(c.name || ""); + } + + function header(T) { + return [`[${T("Cutline reference")}]`]; + } + + /** The where-am-I lines every reference ends with. */ + function footer(ctx, T) { + const out = []; + if (ctx.sequence) out.push(`- ${T("Sequence")}: ${oneLine(ctx.sequence.name || ctx.sequence.id)} (id ${ctx.sequence.id})`); + if (ctx.compPath && ctx.compPath.length) { + const names = ctx.compPath.map((p) => `${oneLine(p.name || p.id)} (id ${p.id})`).join(" › "); + out.push(`- ${T("Inside composition")}: ${names}`); + } + const proj = [oneLine(ctx.projectName || "")].filter(Boolean); + if (ctx.revision != null) proj.push(T("revision {n}", { n: ctx.revision })); + if (ctx.fps) proj.push(`${seconds(ctx.fps)} fps`); + if (proj.length) out.push(`- ${T("Project")}: ${proj.join(" · ")}`); + return out; + } + + function clipLines(c, ctx, T) { + const fps = ctx.fps; + const name = clipName(c, ctx); + const head = `${c.id} · ${c.kind || "clip"} · ${T("track")} ${c.track}` + (name ? ` · “${name}”` : ""); + const lines = [head, ` ${T("Timeline")}: ${span(c.start, c.duration, fps)}`]; + if (c.mediaId) { + const m = ctx.getMedia ? ctx.getMedia(c.mediaId) : null; + const file = m ? oneLine(m.name || fileName(m.src)) : ""; + lines.push(` ${T("Source")}: ${file ? `“${file}” · ` : ""}media ${c.mediaId} · ${T("in point")} ${seconds(c.in || 0)}s`); + } + if (c.kind === "comp" && c.compId) lines.push(` ${T("Composition")}: id ${c.compId}`); + const flags = []; + if (c.locked === true) flags.push(T("locked")); + if (c.disabled === true) flags.push(T("disabled")); + if (flags.length) lines.push(` ${flags.join(" · ")}`); + return lines; + } + + /** Trim a finished reference to the size budget, on a line boundary. */ + function bounded(lines) { + let text = lines.join("\n"); + if (text.length <= MAX_TEXT) return text + "\n"; + text = text.slice(0, MAX_TEXT); + const cut = text.lastIndexOf("\n"); + return (cut > 0 ? text.slice(0, cut) : text) + "\n…\n"; + } + + /** + * One or more clips, plus the moment the user right-clicked (optional). + * @param {object[]} clips + * @param {object} ctx {fps, projectName, revision, sequence, compPath, getMedia, getComp, at?} + * @param {Function} [T] + */ + function clipsReference(clips, ctx, T = identity) { + const list = (clips || []).filter(Boolean).slice().sort((a, b) => (a.start - b.start) || String(a.track).localeCompare(String(b.track))); + const lines = header(T); + lines.push(list.length === 1 ? `- ${T("Clip")}: ` + clipLines(list[0], ctx, T).join("\n") : + `- ${T("Clips ({n})", { n: list.length })}:`); + if (list.length !== 1) { + for (const c of list.slice(0, MAX_CLIPS)) { + const [head, ...rest] = clipLines(c, ctx, T); + lines.push(` - ${head}`, ...rest.map((l) => " " + l)); + } + if (list.length > MAX_CLIPS) lines.push(` - ${T("…and {n} more", { n: list.length - MAX_CLIPS })}`); + } + if (ctx.at != null && Number.isFinite(+ctx.at)) lines.push(`- ${T("Pointed at")}: ${at(ctx.at, ctx.fps)} · ${T("frame")} ${frameOf(ctx.at, ctx.fps)}`); + lines.push(...footer(ctx, T)); + return bounded(lines); + } + + /** + * A moment on the timeline: the clips under it, and the track it was taken on. + * @param {number} t + * @param {object} ctx {fps, …, clips, track?} + * @param {Function} [T] + */ + function timeReference(t, ctx, T = identity) { + const lines = header(T); + lines.push(`- ${T("Time")}: ${at(t, ctx.fps)} · ${T("frame")} ${frameOf(t, ctx.fps)}`); + if (ctx.track) lines.push(`- ${T("Track")}: ${ctx.track}`); + const under = (ctx.clips || []).filter((c) => t >= c.start && t < c.start + c.duration); + if (under.length) { + lines.push(`- ${T("Clips here")}:`); + for (const c of under.slice(0, MAX_CLIPS)) { + const name = clipName(c, ctx); + lines.push(` - ${c.id} · ${c.kind || "clip"} · ${T("track")} ${c.track}` + (name ? ` · “${name}”` : "") + + ` · ${seconds(c.start)}s → ${seconds(c.start + c.duration)}s`); + } + if (under.length > MAX_CLIPS) lines.push(` - ${T("…and {n} more", { n: under.length - MAX_CLIPS })}`); + } else lines.push(`- ${T("Clips here")}: ${T("none")}`); + lines.push(...footer(ctx, T)); + return bounded(lines); + } + + /** + * A marker on the ruler. + * @param {{t:number, label?:string, color?:string}} mk + * @param {object} ctx + * @param {Function} [T] + */ + function markerReference(mk, ctx, T = identity) { + const lines = header(T); + const label = oneLine(mk.label || ""); + lines.push(`- ${T("Marker")}: ${at(mk.t, ctx.fps)} · ${T("frame")} ${frameOf(mk.t, ctx.fps)}` + + (label ? ` · “${label}”` : "") + ` · ${mk.color || "gold"}`); + lines.push(...footer(ctx, T)); + return bounded(lines); + } + + /** + * A file in the project bin, with where the timeline uses it. + * @param {object} m the media entry + * @param {object[]} uses MediaLib.placements(doc, m.id) + * @param {object} ctx + * @param {Function} [T] + */ + function mediaReference(m, uses, ctx, T = identity) { + const lines = header(T); + const name = oneLine(m.name || fileName(m.src)); + lines.push(`- ${T("Media")}: ${m.id} · ${m.kind || "media"}` + (name ? ` · “${name}”` : "")); + const file = oneLine(fileName(m.src), 200); + if (file) lines.push(` ${T("File")}: ${file}`); + const facts = []; + if (Number(m.duration) > 0) facts.push(`${T("duration")} ${at(m.duration, m.fps || ctx.fps)}`); + if (m.width && m.height) facts.push(`${m.width}×${m.height}`); + if (Number(m.fps) > 0) facts.push(`${seconds(m.fps)} fps`); + if (facts.length) lines.push(` ${facts.join(" · ")}`); + const list = uses || []; + if (!list.length) lines.push(` ${T("On the timeline")}: ${T("not used")}`); + else { + lines.push(` ${T("On the timeline ({n})", { n: list.length })}:`); + for (const u of list.slice(0, MAX_USES)) { + const where = u.sequence ? ` · ${T("sequence")} ${oneLine(u.sequenceName || u.sequence)} (id ${u.sequence})` + : u.comp ? ` · ${T("composition")} ${oneLine(u.compName || u.comp)} (id ${u.comp})` : ""; + lines.push(` - ${u.clipId} · ${T("track")} ${u.track} · ${span(u.start, u.duration, ctx.fps)}${where}`); + } + if (list.length > MAX_USES) lines.push(` - ${T("…and {n} more", { n: list.length - MAX_USES })}`); + } + lines.push(...footer(ctx, T)); + return bounded(lines); + } + + return { + MAX_CLIPS, MAX_USES, MAX_VALUE, MAX_TEXT, + oneLine, frameOf, snapToFrame, timecode, seconds, fileName, clipName, + clipsReference, timeReference, markerReference, mediaReference, + }; +}); diff --git a/plugins/klarkxy/cutline/miniapp/client/i18n-en.js b/plugins/klarkxy/cutline/miniapp/client/i18n-en.js index bdb31b6..d7f5ca3 100644 --- a/plugins/klarkxy/cutline/miniapp/client/i18n-en.js +++ b/plugins/klarkxy/cutline/miniapp/client/i18n-en.js @@ -1878,6 +1878,37 @@ "Selected hue {h}° — turn on Matte to check": "Selected hue {h}° — turn on Matte to check", "1 silence, {s} s — marked on the timeline": "1 silence, {s} s — marked on the timeline", "{n} silences, {s} s — marked on the timeline": "{n} silences, {s} s — marked on the timeline", + // Send to chat (chat-context.js) + "Send to chat": "Send to chat", + "Send this moment to chat": "Send this moment to chat", + "Send marker to chat": "Send marker to chat", + "Edit marker…": "Edit marker…", + "This version of MiniMax Code cannot add text to the chat input": "This version of MiniMax Code cannot add text to the chat input", + "Added to the chat input — add your request and press Send": "Added to the chat input — add your request and press Send", + "Not added to the chat": "Not added to the chat", + "No conversation to add to — open Cutline from a chat": "No conversation to add to — open Cutline from a chat", + "It may already be in the chat input — check there before trying again": "It may already be in the chat input — check there before trying again", + "Could not add to the chat ({code})": "Could not add to the chat ({code})", + "Cutline reference": "Cutline reference", + "Sequence": "Sequence", + "Inside composition": "Inside composition", + "revision {n}": "revision {n}", + "track": "track", + "Timeline": "Timeline", + "in point": "in point", + "locked": "locked", + "disabled": "disabled", + "Clip": "Clip", + "Clips ({n})": "Clips ({n})", + "…and {n} more": "…and {n} more", + "Pointed at": "Pointed at", + "frame": "frame", + "Clips here": "Clips here", + "duration": "duration", + "On the timeline": "On the timeline", + "On the timeline ({n})": "On the timeline ({n})", + "sequence": "sequence", + "composition": "composition", }; diff --git a/plugins/klarkxy/cutline/miniapp/client/i18n-zh-CN.js b/plugins/klarkxy/cutline/miniapp/client/i18n-zh-CN.js index 3951338..1fdaa51 100644 --- a/plugins/klarkxy/cutline/miniapp/client/i18n-zh-CN.js +++ b/plugins/klarkxy/cutline/miniapp/client/i18n-zh-CN.js @@ -1960,6 +1960,37 @@ "Selected hue {h}° — turn on Matte to check": "已选中色相 {h}° — 开启蒙版以查看", "1 silence, {s} s — marked on the timeline": "已标记 1 段静音,共 {s} 秒 — 在时间线上", "{n} silences, {s} s — marked on the timeline": "已标记 {n} 段静音,共 {s} 秒 — 在时间线上", + // Send to chat (chat-context.js) + "Send to chat": "发送到对话", + "Send this moment to chat": "把这个时间点发送到对话", + "Send marker to chat": "把标记发送到对话", + "Edit marker…": "编辑标记…", + "This version of MiniMax Code cannot add text to the chat input": "当前版本的 MiniMax Code 无法向对话输入框添加文字", + "Added to the chat input — add your request and press Send": "已添加到对话输入框 — 写上你的要求再按发送", + "Not added to the chat": "已取消,未添加到对话", + "No conversation to add to — open Cutline from a chat": "没有可添加的对话 — 请从对话中打开 Cutline", + "It may already be in the chat input — check there before trying again": "可能已经在对话输入框里了 — 先去看一眼再重试", + "Could not add to the chat ({code})": "未能添加到对话({code})", + "Cutline reference": "Cutline 引用", + "Sequence": "序列", + "Inside composition": "所在合成", + "revision {n}": "修订 {n}", + "track": "轨道", + "Timeline": "时间线", + "in point": "入点", + "locked": "已锁定", + "disabled": "已停用", + "Clip": "片段", + "Clips ({n})": "片段({n} 个)", + "…and {n} more": "…另有 {n} 个", + "Pointed at": "右键位置", + "frame": "帧", + "Clips here": "此处的片段", + "duration": "时长", + "On the timeline": "时间线上的使用", + "On the timeline ({n})": "时间线上的使用({n} 处)", + "sequence": "序列", + "composition": "合成", }; diff --git a/plugins/klarkxy/cutline/miniapp/client/index.html b/plugins/klarkxy/cutline/miniapp/client/index.html index c05ff6b..776c227 100644 --- a/plugins/klarkxy/cutline/miniapp/client/index.html +++ b/plugins/klarkxy/cutline/miniapp/client/index.html @@ -729,6 +729,7 @@

Tracking + diff --git a/plugins/klarkxy/cutline/package.json b/plugins/klarkxy/cutline/package.json index b548cca..e4afc42 100644 --- a/plugins/klarkxy/cutline/package.json +++ b/plugins/klarkxy/cutline/package.json @@ -1,6 +1,6 @@ { "name": "cutline", - "version": "0.1.1", + "version": "0.2.0", "private": true, "type": "commonjs", "description": "Cutline as a MiniMax Code Mini App: the FableCut web editor plus 21 Agent-facing MCP tools (19 upstream fablecut_* tools, a project-folder import tool and the standalone cutline_export tool) on one Host-assigned port.", diff --git a/plugins/klarkxy/cutline/skills/cutline/SKILL.md b/plugins/klarkxy/cutline/skills/cutline/SKILL.md index de06eec..b36a1fe 100644 --- a/plugins/klarkxy/cutline/skills/cutline/SKILL.md +++ b/plugins/klarkxy/cutline/skills/cutline/SKILL.md @@ -51,6 +51,23 @@ bin. The status call reports; the Host's open action is the way in. `fablecut_patch_project` is the default way to change anything. `fablecut_set_project` replaces the whole document and is reserved for structural changes - see [editing.md](references/editing.md). +## References the user sends from the editor + +A message can start with a block the editor wrote when the user chose **Send to chat**: +`[Cutline reference]` (`[Cutline 引用]` in Chinese), then lines for a clip, clips, a time, a marker +or a media file. Treat it as the user pointing at the timeline and their own words after it as the +request. + +- Ids are the ones the tools take: `c_…` clip ids, `V1`/`A1` track ids, media ids, comp ids, + sequence ids. Times are timeline seconds in parentheses; the timecode beside them is `MM:SS:FF` + at the project frame rate. +- "Pointed at" is the moment the user right-clicked on that clip: use it for "here", "this + point", a split or a keyframe. +- "Inside composition" means the clip ids belong to that comp: wrap clip ops in + `{op:'inComp', comp, ops:[…]}`. A sequence other than the open one needs `inSequence`. +- The revision is the one the page showed; the page saves a moment after each edit, so re-read + the compact project and plan against its current revision before you patch. + ## The project is shared The editor service is shared across pages, so the open project can change under you and another diff --git a/plugins/klarkxy/cutline/tests/chat-context.test.mjs b/plugins/klarkxy/cutline/tests/chat-context.test.mjs new file mode 100644 index 0000000..2903c05 --- /dev/null +++ b/plugins/klarkxy/cutline/tests/chat-context.test.mjs @@ -0,0 +1,144 @@ +// @ts-check +// The text "Send to chat" hands the conversation: what each reference says, +// that ids and seconds are written as the MCP tools address them, that user +// text cannot break the layout, and that the size stays bounded. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(import.meta.url); +const Chat = require(path.join(PACKAGE_ROOT, 'miniapp', 'client', 'chat-context.js')); + +const media = { + m_1: { id: 'm_1', name: 'interview.mp4', src: '/media/interview%20A.mp4?v=2', kind: 'video', duration: 12.5, width: 1920, height: 1080, fps: 29.97 }, +}; +/** @param {object} [extra] */ +const ctx = (extra = {}) => ({ + fps: 30, projectName: 'Demo', revision: 42, + sequence: { id: 'main', name: 'Main' }, compPath: [], + getMedia: (/** @type {string} */ id) => media[/** @type {'m_1'} */ (id)], getComp: () => null, + ...extra, +}); +const video = { id: 'c_1', kind: 'video', track: 'V1', mediaId: 'm_1', start: 2, duration: 3.5, in: 1.25 }; +const title = { id: 'c_2', kind: 'text', track: 'V2', start: 0, duration: 4, props: { text: 'Hello\nworld' }, locked: true }; + +test('a clip reference names the clip, its time, its source and where the user is', () => { + const text = Chat.clipsReference([video], ctx({ at: 3.2 })); + assert.equal(text, [ + '[Cutline reference]', + '- Clip: c_1 · video · track V1', + ' Timeline: 00:02:00 → 00:05:15 (2s → 5.5s, 3.5s)', + ' Source: “interview.mp4” · media m_1 · in point 1.25s', + '- Pointed at: 00:03:06 (3.2s) · frame 96', + '- Sequence: Main (id main)', + '- Project: Demo · revision 42 · 30 fps', + '', + ].join('\n')); +}); + +test('several clips are listed, and the composition path is named', () => { + const text = Chat.clipsReference([video, title], ctx({ compPath: [{ id: 'comp_1', name: 'Intro' }] })); + assert.match(text, /^- Clips \(2\):$/m); + assert.match(text, /^ {2}- c_2 · text · track V2 · “Hello”$/m, 'a title is named by its first line'); + assert.match(text, /^ {4}locked$/m); + assert.match(text, /^ {2}- c_1 · video · track V1$/m); + assert.match(text, /^- Inside composition: Intro \(id comp_1\)$/m); + assert.doesNotMatch(text, /Pointed at/, 'no moment was given'); +}); + +test('a long selection is cut at MAX_CLIPS and the rest is counted', () => { + const many = Array.from({ length: Chat.MAX_CLIPS + 5 }, (_, i) => ({ id: `c_${i}`, kind: 'shape', track: 'V1', start: i, duration: 1 })); + const text = Chat.clipsReference(many, ctx()); + assert.equal((text.match(/^ {2}- c_\d+/gm) || []).length, Chat.MAX_CLIPS); + assert.match(text, /…and 5 more/); +}); + +test('a moment lists the clips under it and the track it was taken on', () => { + const text = Chat.timeReference(3.2, ctx({ clips: [video, title, { ...video, id: 'c_3', start: 9 }], track: 'V1' })); + assert.match(text, /^- Time: 00:03:06 \(3\.2s\) · frame 96$/m); + assert.match(text, /^- Track: V1$/m); + assert.match(text, /^ {2}- c_1 · video · track V1 · 2s → 5\.5s$/m); + assert.match(text, /^ {2}- c_2 · text · track V2 · “Hello” · 0s → 4s$/m); + assert.doesNotMatch(text, /c_3/, 'a clip after the moment is not under it'); + + const empty = Chat.timeReference(20, ctx({ clips: [video] })); + assert.match(empty, /^- Clips here: none$/m); + assert.doesNotMatch(empty, /Track:/); +}); + +test('a marker reference carries its time, label and colour', () => { + const text = Chat.markerReference({ t: 5, label: 'beat drop', color: 'red' }, ctx()); + assert.match(text, /^- Marker: 00:05:00 \(5s\) · frame 150 · “beat drop” · red$/m); + assert.match(Chat.markerReference({ t: 1 }, ctx()), /· gold$/m, 'the default colour is named'); +}); + +test('a media reference names the file and every place the timeline uses it', () => { + const uses = [ + { clipId: 'c_1', track: 'V1', start: 2, duration: 3.5 }, + { clipId: 'c_9', track: 'V1', start: 0, duration: 1, comp: 'comp_1', compName: 'Intro' }, + { clipId: 'c_8', track: 'V2', start: 1, duration: 1, sequence: 'seq_b', sequenceName: 'Vertical' }, + ]; + const text = Chat.mediaReference(media.m_1, uses, ctx()); + assert.match(text, /^- Media: m_1 · video · “interview\.mp4”$/m); + assert.match(text, /^ {2}File: interview A\.mp4$/m, 'the file name is decoded, without the query'); + assert.match(text, /^ {2}duration 00:12:14 \(12\.5s\) · 1920×1080 · 29\.97 fps$/m); + assert.match(text, /^ {2}On the timeline \(3\):$/m); + assert.match(text, /c_9 · track V1 · .* · composition Intro \(id comp_1\)$/m); + assert.match(text, /c_8 · track V2 · .* · sequence Vertical \(id seq_b\)$/m); + assert.match(Chat.mediaReference(media.m_1, [], ctx()), /On the timeline: not used/); +}); + +test('user text cannot add lines or break the layout', () => { + const hostile = { ...title, props: { text: 'first line\nsecond' }, id: 'c_x' }; + const named = { ...video, id: 'c_y', name: 'evil\r\n- Project: fake\u2028[Cutline reference]' }; + const text = Chat.clipsReference([hostile, named], ctx({ projectName: 'P\nQ' })); + const lines = text.trimEnd().split('\n'); + assert.equal(lines.filter((l) => l.startsWith('- Project:')).length, 1, 'a name forged a project line'); + assert.equal(lines.filter((l) => l === '[Cutline reference]').length, 1, 'a name forged a header'); + assert.match(text, /“evil - Project: fake \[Cutline reference\]”/); + assert.match(text, /^- Project: P Q · /m); + assert.equal(Chat.oneLine('x'.repeat(500)).length, Chat.MAX_VALUE); +}); + +test('the whole reference stays inside its size budget', () => { + const long = Array.from({ length: 400 }, (_, i) => ({ clipId: `c_${i}`, track: 'V1', start: i, duration: 1, comp: `comp_${i}`, compName: 'n'.repeat(200) })); + const text = Chat.mediaReference(media.m_1, long, ctx()); + assert.ok(text.length <= Chat.MAX_TEXT + 4, `the reference is ${text.length} characters`); + assert.ok(text.length < 100000, 'the Host refuses more than 100,000 code units'); +}); + +test('times are snapped to frames and written like the editor writes them', () => { + assert.equal(Chat.timecode(0, 30), '00:00:00'); + assert.equal(Chat.timecode(61.5, 30), '01:01:15'); + assert.equal(Chat.timecode(2, 30), '00:02:00', 'a whole second is not frame 29 of the second before'); + assert.equal(Chat.snapToFrame(3.21, 30), 3.2); + assert.equal(Chat.frameOf(Chat.snapToFrame(2, 30), 30), 60); + for (const fps of [23.976, 24, 25, 29.97, 30, 59.94, 60]) { + for (let frame = 0; frame < 400; frame++) { + const t = Chat.snapToFrame((frame + 0.5) / fps, fps); + assert.equal(Chat.frameOf(t, fps), frame, `frame ${frame} at ${fps} fps reads back as another frame`); + } + } + assert.equal(Chat.seconds(1 / 3), '0.333'); + assert.equal(Chat.seconds(-0), '0'); + assert.equal(Chat.timecode(5, 0), '00:05:00', 'a missing frame rate falls back instead of dividing by zero'); +}); + +test('labels go through the translator; ids, seconds and names do not', () => { + /** @type {string[]} */ + const seen = []; + const T = (/** @type {string} */ key, /** @type {Record} */ vars) => { + seen.push(key); + const s = vars ? key.replace(/\{(\w+)\}/g, (_, k) => String(vars[k])) : key; + return `«${s}»`; + }; + const text = Chat.clipsReference([video], ctx({ at: 3.2 }), T); + assert.match(text, /^\[«Cutline reference»\]$/m); + assert.match(text, /- «Clip»: c_1 · video · «track» V1$/m); + assert.match(text, /«revision 42»/); + assert.ok(!seen.some((k) => /c_1|m_1|interview|V1|Main|Demo/.test(k)), `user values reached the translator: ${seen.join(', ')}`); +}); diff --git a/plugins/klarkxy/cutline/tests/i18n.test.mjs b/plugins/klarkxy/cutline/tests/i18n.test.mjs index bf5dc0c..878ef70 100644 --- a/plugins/klarkxy/cutline/tests/i18n.test.mjs +++ b/plugins/klarkxy/cutline/tests/i18n.test.mjs @@ -380,7 +380,7 @@ test('every T() key the editor uses exists in the dictionary', () => { const known = new Set(Object.keys(sandbox.I18n.dictionary('en'))); /** @type {string[]} */ const missing = []; - for (const file of ['app.js', 'audio-fx.js', 'color.js', 'delivery.js', 'templates.js']) { + for (const file of ['app.js', 'audio-fx.js', 'chat-context.js', 'color.js', 'delivery.js', 'templates.js']) { const src = read(file); for (const m of src.matchAll(/\bT\(\s*"((?:[^"\\]|\\.)*)"/g)) { const key = decodeEntities(JSON.parse(`"${m[1]}"`)); diff --git a/plugins/klarkxy/cutline/tests/send-to-chat.test.mjs b/plugins/klarkxy/cutline/tests/send-to-chat.test.mjs new file mode 100644 index 0000000..6a9a7d3 --- /dev/null +++ b/plugins/klarkxy/cutline/tests/send-to-chat.test.mjs @@ -0,0 +1,281 @@ +// @ts-check +// A browser test for "Send to chat": right-click a clip, an empty spot on a +// track, the ruler, a marker or a media file, pick the item, and the text the +// Host is asked to append names what was pointed at. +// +// The Host's `window.mcode.miniapp.message.append` is replaced by a recorder +// before the page loads, so the test sees exactly the text the editor hands +// over. A second page with no bridge checks that the items stay visible but +// disabled, with the reason as the tooltip. +// +// The test skips itself when the machine has no Chromium-based browser. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(import.meta.url); + +/* The upstream modules read their data directory from the environment the + first time they are loaded: point it at a scratch folder first. */ +const SCRATCH_DIR = path.resolve(PACKAGE_ROOT, '../../../.fablecut-acceptance/send-to-chat'); +fs.mkdirSync(SCRATCH_DIR, { recursive: true }); +process.env.FABLECUT_DATA_DIR = SCRATCH_DIR; + +const { startApp, mcpSession, mcpTool } = await import('./helpers.mjs'); + +/** @returns {string|null} */ +function findBrowser() { + const vendored = path.join(PACKAGE_ROOT, 'miniapp', 'node', 'vendor', 'fablecut', 'export-jobs.js'); + if (fs.existsSync(vendored)) { + try { + const { findBrowser: find } = require(vendored); + const found = find(); + if (found) return found; + } catch { /* fall through to the manual search */ } + } + const candidates = []; + for (const root of [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA]) { + if (!root) continue; + candidates.push(path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe')); + candidates.push(path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe')); + } + return candidates.find((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) ?? null; +} + +const executablePath = findBrowser(); +const puppeteerPath = path.join(PACKAGE_ROOT, 'miniapp', 'node', 'vendor', 'puppeteer', 'puppeteer-core.cjs'); +const skip = !executablePath || !fs.existsSync(puppeteerPath) ? 'no Chromium-based browser on this machine' : false; + +/** A small timeline: a title on V2, a shape on V1 after a gap, a marker, an unused still. */ +async function seedProject(base) { + const { sessionId } = await mcpSession(base); + /* The data folder outlives one test: start every test from the same document. */ + const current = JSON.parse((await mcpTool(base, sessionId, 'fablecut_get_project')).text); + const { reply, text } = await mcpTool(base, sessionId, 'fablecut_patch_project', { + ops: [ + ...(current.clips || []).map((/** @type {any} */ c) => ({ op: 'removeClip', id: c.id })), + ...(current.media || []).map((/** @type {any} */ m) => ({ op: 'removeMedia', id: m.id })), + { op: 'setProject', set: { name: 'Chat Test', fps: 30, markers: [{ t: 8, label: 'drop', color: 'red' }] } }, + { op: 'addMedia', media: { id: 'm_still', name: 'poster.png', kind: 'image', src: '/media/poster.png', width: 640, height: 360 } }, + { op: 'addClip', clip: { id: 'c_title', kind: 'text', track: 'V2', start: 0, duration: 2, props: { text: 'Opening line\nsecond line' } } }, + { op: 'addClip', clip: { id: 'c_shape', kind: 'shape', track: 'V1', start: 4, duration: 3, name: 'Box', props: { shapes: [{ type: 'rect', w: 100, h: 100, fill: '#ff0000' }] } } }, + ], + }); + assert.equal(reply.json?.result?.isError, undefined, text); +} + +/** + * @param {import('node:test').TestContext} t + * @param {{ bridge: 'record' | 'cancel' | 'none' }} options + */ +async function openEditor(t, { bridge }) { + /* One runtime for the whole file: a disposed runtime stays stopped in this + process (the media-process adapter refuses new work after shutdown). */ + const app = await startApp(); + await seedProject(app.base); + const puppeteer = require(puppeteerPath); + const api = puppeteer.default && typeof puppeteer.default.launch === 'function' ? puppeteer.default : puppeteer; + const browser = await api.launch({ executablePath, headless: true, pipe: true, args: ['--no-sandbox'] }); + t.after(async () => { await browser.close(); }); + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 900 }); + /** @type {string[]} */ + const errors = []; + page.on('pageerror', (/** @type {Error} */ err) => { errors.push(String(err)); }); + page.on('console', (/** @type {any} */ msg) => { if (msg.type() === 'error' && !/404|Failed to load resource/.test(msg.text())) errors.push(msg.text()); }); + await page.evaluateOnNewDocument((/** @type {string} */ mode) => { + localStorage.setItem('fablecut-locale', 'en'); + if (mode === 'none') return; + /** @type {string[]} */ + const sent = []; + Object.defineProperty(window, '__sent', { value: sent }); + Object.defineProperty(window, 'mcode', { + value: { + miniapp: { + message: { + append: async (/** @type {string} */ text) => { + sent.push(text); + if (mode === 'cancel') throw new Error('user_cancelled'); + }, + }, + }, + }, + }); + }, bridge); + await page.goto(`${app.base}${app.surfacePath}`, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForFunction(() => document.querySelector('.clip[data-id="c_shape"]') && document.querySelector('.clip[data-id="c_title"]'), { timeout: 20000 }); + return { page, errors }; +} + +/** + * Right-click a point of the page, at fractions of an element's box. + * @param {any} page + * @param {string} selector + * @param {number} fx + * @param {number} fy + */ +async function rightClick(page, selector, fx = 0.5, fy = 0.5) { + await page.evaluate((/** @type {string} */ sel, /** @type {number} */ x, /** @type {number} */ y) => { + const el = document.querySelector(sel); + if (!el) throw new Error(`nothing matches ${sel}`); + const r = el.getBoundingClientRect(); + const clientX = r.left + r.width * x, clientY = r.top + r.height * y; + const target = document.elementFromPoint(clientX, clientY) || el; + target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY, button: 2 })); + }, selector, fx, fy); + await page.waitForSelector('#trackCtxMenu', { timeout: 10000 }); +} + +/** @param {any} page */ +const menuItems = (page) => page.evaluate(() => [...document.querySelectorAll('#trackCtxMenu .ctx-item')].map((b) => ({ + text: (b.textContent ?? '').trim(), disabled: /** @type {HTMLButtonElement} */ (b).disabled, title: b.getAttribute('title') ?? '', +}))); + +/** + * Click the menu item whose text starts with `label` and wait for one more recorded text. + * @param {any} page + * @param {string} label + */ +async function pick(page, label) { + const before = await page.evaluate(() => /** @type {any} */ (window).__sent.length); + await page.evaluate((/** @type {string} */ l) => { + const item = [...document.querySelectorAll('#trackCtxMenu .ctx-item')].find((b) => (b.textContent ?? '').trim().startsWith(l)); + if (!item) throw new Error(`no menu item ${l}`); + /** @type {HTMLButtonElement} */ (item).click(); + }, label); + await page.waitForFunction((/** @type {number} */ n) => /** @type {any} */ (window).__sent.length > n, { timeout: 10000 }, before); + return page.evaluate(() => { const s = /** @type {any} */ (window).__sent; return s[s.length - 1]; }); +} + +test.after(async () => { if (!skip) await (await startApp()).dispose(); }); + +test('a clip, a moment, a marker and a media file can be sent to the chat', { skip }, async (t) => { + const { page, errors } = await openEditor(t, { bridge: 'record' }); + + /* A clip: its id, kind, track, time and the moment it was right-clicked. */ + await rightClick(page, '.clip[data-id="c_shape"]'); + const clipMenu = await menuItems(page); + const send = clipMenu.find((i) => i.text === 'Send to chat'); + assert.ok(send, `the clip menu has no Send to chat: ${clipMenu.map((i) => i.text).join(' | ')}`); + assert.equal(send.disabled, false); + assert.equal(clipMenu[clipMenu.length - 1].text, 'Send to chat', 'Send to chat closes the clip menu'); + const clipText = await pick(page, 'Send to chat'); + assert.match(clipText, /^\[Cutline reference\]\n- Clip: c_shape · shape · track V1 · “Box”\n/); + assert.match(clipText, /^ {2}Timeline: 00:04:00 → 00:07:00 \(4s → 7s, 3s\)$/m); + const pointed = /^- Pointed at: \d\d:\d\d:\d\d \(([\d.]+)s\) · frame (\d+)$/m.exec(clipText); + assert.ok(pointed, `no moment in ${clipText}`); + assert.ok(+pointed[1] >= 4 && +pointed[1] < 7, `the moment ${pointed[1]} s is not on the clip`); + assert.equal(Math.round(+pointed[1] * 30), +pointed[2], 'the moment is not on a frame'); + assert.match(clipText, /^- Sequence: Main \(id main\)$/m); + assert.match(clipText, /^- Project: Chat Test · revision \d+ · 30 fps$/m); + const toast = await page.$eval('#toast', (/** @type {Element} */ el) => el.textContent); + assert.match(toast ?? '', /Added to the chat input/); + + /* An empty spot on V1 before the shape: the moment, the track, what plays there. */ + await page.evaluate(() => { + const row = document.querySelector('.track[data-track="V1"]'); + const shape = document.querySelector('.clip[data-id="c_shape"]'); + if (!row || !shape) throw new Error('no V1 row'); + const r = row.getBoundingClientRect(), s = shape.getBoundingClientRect(); + const clientX = s.left - (s.width / 3) * 2.5; // 2.5 s before the shape: inside the title's span on V2 + const clientY = r.top + r.height / 2; + (document.elementFromPoint(clientX, clientY) || row).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY, button: 2 })); + }); + await page.waitForSelector('#trackCtxMenu', { timeout: 10000 }); + assert.deepEqual((await menuItems(page)).map((i) => i.text), ['Send this moment to chat']); + const momentText = await pick(page, 'Send this moment to chat'); + assert.match(momentText, /^- Time: 00:01:\d\d \(1(\.\d+)?s\) · frame \d+$/m); + assert.match(momentText, /^- Track: V1$/m); + assert.match(momentText, /^ {2}- c_title · text · track V2 · “Opening line” · 0s → 2s$/m, 'the title plays at that moment'); + assert.doesNotMatch(momentText, /c_shape/, 'the shape does not play at that moment'); + + /* The ruler away from any marker: the moment, no track. */ + await rightClick(page, '#ruler', 0.02, 0.3); + assert.deepEqual((await menuItems(page)).map((i) => i.text), ['Send this moment to chat']); + const rulerText = await pick(page, 'Send this moment to chat'); + assert.match(rulerText, /^- Time: /m); + assert.doesNotMatch(rulerText, /^- Track:/m); + + /* A marker: the editor stays one click away, the marker can be sent. */ + await page.evaluate(() => { + const ruler = document.getElementById('ruler'); + const scroll = document.getElementById('timelineScroll'); + const shape = document.querySelector('.clip[data-id="c_shape"]'); + if (!ruler || !scroll || !shape) throw new Error('no ruler'); + const r = ruler.getBoundingClientRect(), s = shape.getBoundingClientRect(); + const clientX = s.left + (s.width / 3) * 4; // 8 s: the marker + const clientY = r.bottom - 4; + ruler.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY, button: 2 })); + }); + await page.waitForSelector('#trackCtxMenu', { timeout: 10000 }); + assert.deepEqual((await menuItems(page)).map((i) => i.text), ['Edit marker…', 'Send marker to chat']); + const markerText = await pick(page, 'Send marker to chat'); + assert.match(markerText, /^- Marker: 00:08:00 \(8s\) · frame 240 · “drop” · red$/m); + + /* A media file in the bin, used nowhere. */ + await rightClick(page, '.bin-item'); + const mediaMenu = await menuItems(page); + assert.equal(mediaMenu[1]?.text, 'Send to chat', 'Send to chat follows Open in Source'); + const mediaText = await pick(page, 'Send to chat'); + assert.match(mediaText, /^- Media: m_still · image · “poster\.png”$/m); + assert.match(mediaText, /^ {2}File: poster\.png$/m); + assert.match(mediaText, /^ {2}On the timeline: not used$/m); + + /* Sending never changed the project. */ + const doc = await page.evaluate(async () => (await fetch('/api/project')).json()); + assert.deepEqual(doc.clips.map((/** @type {any} */ c) => c.id).sort(), ['c_shape', 'c_title']); + assert.deepEqual(doc.markers, [{ t: 8, label: 'drop', color: 'red' }]); + + assert.deepEqual(errors, [], 'the page logged errors'); +}); + +test('the menu follows the interface language; the reference keeps raw ids', { skip }, async (t) => { + const { page, errors } = await openEditor(t, { bridge: 'record' }); + await page.evaluate(() => { + const sel = /** @type {HTMLSelectElement} */ (document.getElementById('setLang')); + sel.value = 'zh-CN'; + sel.dispatchEvent(new Event('change', { bubbles: true })); + }); + await page.waitForFunction(() => document.documentElement.lang === 'zh-CN', { timeout: 15000 }); + await rightClick(page, '.clip[data-id="c_title"]'); + const items = await menuItems(page); + assert.ok(items.some((i) => i.text === '发送到对话'), `no Chinese item: ${items.map((i) => i.text).join(' | ')}`); + const text = await pick(page, '发送到对话'); + assert.match(text, /^\[Cutline 引用\]$/m); + assert.match(text, /^- 片段: c_title · text · 轨道 V2 · “Opening line”$/m, 'ids, kinds and user text stay as stored'); + assert.deepEqual(errors, [], 'the page logged errors'); +}); + +test('a cancelled confirmation adds nothing and says so', { skip }, async (t) => { + const { page, errors } = await openEditor(t, { bridge: 'cancel' }); + await rightClick(page, '.clip[data-id="c_title"]'); + await pick(page, 'Send to chat'); + await page.waitForFunction(() => /Not added/.test(document.getElementById('toast')?.textContent ?? ''), { timeout: 10000 }); + /* The editor keeps working: the menu opens again. */ + await rightClick(page, '.clip[data-id="c_title"]'); + assert.ok((await menuItems(page)).some((i) => i.text === 'Send to chat' && !i.disabled)); + assert.deepEqual(errors, [], 'the page logged errors'); +}); + +test('without the Host bridge the items are disabled and say why', { skip }, async (t) => { + const { page, errors } = await openEditor(t, { bridge: 'none' }); + for (const [selector, label, fx, fy] of /** @type {[string, string, number, number][]} */ ([ + ['.clip[data-id="c_title"]', 'Send to chat', 0.5, 0.5], + ['#ruler', 'Send this moment to chat', 0.02, 0.3], + ['.bin-item', 'Send to chat', 0.5, 0.5], + ])) { + await rightClick(page, selector, fx, fy); + const item = (await menuItems(page)).find((i) => i.text === label); + assert.ok(item, `${selector}: no ${label}`); + assert.equal(item.disabled, true, `${selector}: ${label} is enabled without the bridge`); + assert.match(item.title, /cannot add text to the chat input/); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => !document.getElementById('trackCtxMenu'), { timeout: 5000 }); + } + assert.deepEqual(errors, [], 'the page logged errors'); +}); From 008f1e088ec2fce59f7da85e82e80ddc9dce009c Mon Sep 17 00:00:00 2001 From: klarkxy <278370456@qq.com> Date: Sat, 31 Oct 2026 23:42:01 +0800 Subject: [PATCH 2/3] fix(cutline): list referenced clips top to bottom and label the media id as "id" "Clips here" and multi-clip references now follow the timeline's lane order (V3, V2, V1, A1, A2...) instead of the order the clips were added. The editor passes its live track order; without one, video lanes run from the highest number down and audio lanes from A1 up. A clip's source line reads "id m_..." instead of an untranslated "media m_...". --- plugins/klarkxy/cutline/miniapp/client/app.js | 2 +- .../cutline/miniapp/client/chat-context.js | 25 +++++++++++++++--- .../cutline/tests/chat-context.test.mjs | 26 ++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/plugins/klarkxy/cutline/miniapp/client/app.js b/plugins/klarkxy/cutline/miniapp/client/app.js index e868daf..0ca380f 100644 --- a/plugins/klarkxy/cutline/miniapp/client/app.js +++ b/plugins/klarkxy/cutline/miniapp/client/app.js @@ -430,7 +430,7 @@ function chatContext(extra = {}) { return { fps: projectFps(), projectName: root.name || "", revision: project.revision ?? null, sequence: { id: seq.id || seqId, name: seq.name || seqId }, compPath, - getMedia, getComp, clips: project.clips.filter((c) => trackOf(c)), ...extra, + getMedia, getComp, clips: project.clips.filter((c) => trackOf(c)), trackOrder: TRACKS.map((t) => t.id), ...extra, }; } let chatSending = false; diff --git a/plugins/klarkxy/cutline/miniapp/client/chat-context.js b/plugins/klarkxy/cutline/miniapp/client/chat-context.js index 3852113..1dfb902 100644 --- a/plugins/klarkxy/cutline/miniapp/client/chat-context.js +++ b/plugins/klarkxy/cutline/miniapp/client/chat-context.js @@ -104,6 +104,22 @@ return oneLine(c.name || ""); } + /** Where a clip's track sits on the timeline, top to bottom (`ctx.trackOrder`, + e.g. ["V3","V2","V1","A1",…]). Without an order: video lanes from the + highest number down, then audio lanes from A1 up — the default layout. */ + function trackRank(ctx) { + const order = Array.isArray(ctx && ctx.trackOrder) ? ctx.trackOrder : null; + if (order && order.length) { + const at = new Map(order.map((id, i) => [String(id), i])); + return (c) => (at.has(String(c.track)) ? at.get(String(c.track)) : order.length); + } + return (c) => { + const m = /^([VA])(\d+)$/.exec(String(c.track)); + if (!m) return 1e6; + return m[1] === "V" ? -Number(m[2]) : 1e3 + Number(m[2]); + }; + } + function header(T) { return [`[${T("Cutline reference")}]`]; } @@ -131,7 +147,7 @@ if (c.mediaId) { const m = ctx.getMedia ? ctx.getMedia(c.mediaId) : null; const file = m ? oneLine(m.name || fileName(m.src)) : ""; - lines.push(` ${T("Source")}: ${file ? `“${file}” · ` : ""}media ${c.mediaId} · ${T("in point")} ${seconds(c.in || 0)}s`); + lines.push(` ${T("Source")}: ${file ? `“${file}” · ` : ""}id ${c.mediaId} · ${T("in point")} ${seconds(c.in || 0)}s`); } if (c.kind === "comp" && c.compId) lines.push(` ${T("Composition")}: id ${c.compId}`); const flags = []; @@ -157,7 +173,8 @@ * @param {Function} [T] */ function clipsReference(clips, ctx, T = identity) { - const list = (clips || []).filter(Boolean).slice().sort((a, b) => (a.start - b.start) || String(a.track).localeCompare(String(b.track))); + const rank = trackRank(ctx); + const list = (clips || []).filter(Boolean).slice().sort((a, b) => (a.start - b.start) || rank(a) - rank(b)); const lines = header(T); lines.push(list.length === 1 ? `- ${T("Clip")}: ` + clipLines(list[0], ctx, T).join("\n") : `- ${T("Clips ({n})", { n: list.length })}:`); @@ -183,7 +200,9 @@ const lines = header(T); lines.push(`- ${T("Time")}: ${at(t, ctx.fps)} · ${T("frame")} ${frameOf(t, ctx.fps)}`); if (ctx.track) lines.push(`- ${T("Track")}: ${ctx.track}`); - const under = (ctx.clips || []).filter((c) => t >= c.start && t < c.start + c.duration); + const rank = trackRank(ctx); + const under = (ctx.clips || []).filter((c) => t >= c.start && t < c.start + c.duration) + .sort((a, b) => rank(a) - rank(b) || a.start - b.start); if (under.length) { lines.push(`- ${T("Clips here")}:`); for (const c of under.slice(0, MAX_CLIPS)) { diff --git a/plugins/klarkxy/cutline/tests/chat-context.test.mjs b/plugins/klarkxy/cutline/tests/chat-context.test.mjs index 2903c05..b266dfe 100644 --- a/plugins/klarkxy/cutline/tests/chat-context.test.mjs +++ b/plugins/klarkxy/cutline/tests/chat-context.test.mjs @@ -32,7 +32,7 @@ test('a clip reference names the clip, its time, its source and where the user i '[Cutline reference]', '- Clip: c_1 · video · track V1', ' Timeline: 00:02:00 → 00:05:15 (2s → 5.5s, 3.5s)', - ' Source: “interview.mp4” · media m_1 · in point 1.25s', + ' Source: “interview.mp4” · id m_1 · in point 1.25s', '- Pointed at: 00:03:06 (3.2s) · frame 96', '- Sequence: Main (id main)', '- Project: Demo · revision 42 · 30 fps', @@ -61,8 +61,8 @@ test('a moment lists the clips under it and the track it was taken on', () => { const text = Chat.timeReference(3.2, ctx({ clips: [video, title, { ...video, id: 'c_3', start: 9 }], track: 'V1' })); assert.match(text, /^- Time: 00:03:06 \(3\.2s\) · frame 96$/m); assert.match(text, /^- Track: V1$/m); - assert.match(text, /^ {2}- c_1 · video · track V1 · 2s → 5\.5s$/m); - assert.match(text, /^ {2}- c_2 · text · track V2 · “Hello” · 0s → 4s$/m); + assert.match(text, /^ {2}- c_2 · text · track V2 · “Hello” · 0s → 4s\n {2}- c_1 · video · track V1 · 2s → 5\.5s$/m, + 'V2 sits above V1 on the timeline'); assert.doesNotMatch(text, /c_3/, 'a clip after the moment is not under it'); const empty = Chat.timeReference(20, ctx({ clips: [video] })); @@ -70,6 +70,26 @@ test('a moment lists the clips under it and the track it was taken on', () => { assert.doesNotMatch(empty, /Track:/); }); +test('clips are listed top to bottom, the way the timeline shows the lanes', () => { + const at = (id, track) => ({ id, kind: 'shape', track, start: 0, duration: 5 }); + const clips = [at('c_a3', 'A3'), at('c_v1', 'V1'), at('c_a1', 'A1'), at('c_v2', 'V2'), at('c_v10', 'V10')]; + const order = (text) => [...text.matchAll(/c_(?:a3|v1|a1|v2|v10)\b/g)].map((m) => m[0]); + + /* The editor passes its own lane order: it wins, even when it is unusual. */ + const custom = ctx({ clips, trackOrder: ['V10', 'V2', 'V1', 'A1', 'A3'] }); + assert.deepEqual(order(Chat.timeReference(1, custom)), ['c_v10', 'c_v2', 'c_v1', 'c_a1', 'c_a3']); + assert.deepEqual(order(Chat.clipsReference(clips, custom)), ['c_v10', 'c_v2', 'c_v1', 'c_a1', 'c_a3'], + 'clips that start together are listed lane by lane'); + + /* Without one: video from the highest number down, then audio from A1 up, + with V10 above V2 (numbers, not text). */ + assert.deepEqual(order(Chat.timeReference(1, ctx({ clips }))), ['c_v10', 'c_v2', 'c_v1', 'c_a1', 'c_a3']); + + /* Time still comes first in a clip list. */ + const later = [{ ...at('c_v2', 'V2'), start: 3 }, at('c_a1', 'A1')]; + assert.deepEqual(order(Chat.clipsReference(later, ctx())), ['c_a1', 'c_v2']); +}); + test('a marker reference carries its time, label and colour', () => { const text = Chat.markerReference({ t: 5, label: 'beat drop', color: 'red' }, ctx()); assert.match(text, /^- Marker: 00:05:00 \(5s\) · frame 150 · “beat drop” · red$/m); From 166b1bcb2fa86997cf7b8455f034cc02bd7557d4 Mon Sep 17 00:00:00 2001 From: klarkxy <278370456@qq.com> Date: Sat, 31 Oct 2026 23:42:01 +0800 Subject: [PATCH 3/3] test(cutline): retry an MCP request once when its reused socket was reset fetch keeps an idle socket for 4 s and the server closes it after 5 s. When a synchronous test step blocks the process long enough, both timers fire together and the reused socket is reset before the request is read, which made "the tool answers prepare, status and cancel" fail under full-suite load. Only ECONNRESET / UND_ERR_SOCKET is retried, once. --- plugins/klarkxy/cutline/tests/helpers.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/klarkxy/cutline/tests/helpers.mjs b/plugins/klarkxy/cutline/tests/helpers.mjs index 22b979d..11164bd 100644 --- a/plugins/klarkxy/cutline/tests/helpers.mjs +++ b/plugins/klarkxy/cutline/tests/helpers.mjs @@ -145,11 +145,23 @@ export async function getJson(base, pathname) { */ export async function mcpRaw(base, body, options = {}) { const method = options.method ?? 'POST'; - const response = await fetch(`${base}/mcp/cutline`, { + const send = () => fetch(`${base}/mcp/cutline`, { method, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) }, body: method === 'POST' ? JSON.stringify(body) : undefined, }); + /* fetch keeps an idle socket for 4 s and the server closes it after 5 s. + When a synchronous test step blocks this process long enough, both timers + fire together and the reused socket is reset before the request is read. + A reset is the only failure retried, once, on a fresh socket. */ + let response; + try { + response = await send(); + } catch (err) { + const code = /** @type {any} */ (err)?.cause?.code; + if (code !== 'ECONNRESET' && code !== 'UND_ERR_SOCKET') throw err; + response = await send(); + } const text = await response.text(); let json = null; try {