Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/klarkxy/cutline/.minimax-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"name": "cutline",
"displayName": "Cutline",
"version": "0.1.1",
"version": "0.2.0",
"description": "浏览器里的视频剪辑台:时间线剪切、字幕、动效、调色与导出,聊天里一句话就能剪片。",
"author": "klarkxy",
"icon": "icon.png",
Expand Down
4 changes: 4 additions & 0 deletions plugins/klarkxy/cutline/PATCHES.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
6 changes: 6 additions & 0 deletions plugins/klarkxy/cutline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions plugins/klarkxy/cutline/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Cutline 是基于 [FableCut](https://github.com/ronak-create/FableCut) 的社区

手动编辑和 Agent 编辑使用同一个工程,你可以随时接管。

### 给 Agent 指一个位置

在片段、轨道空白处、时间标尺、标记或项目素材上点右键,选 **发送到对话**。MiniMax Code 弹出确认后,会在对话输入框里加一段简短引用:片段与素材 id、轨道、时间线秒数和帧号、当前序列与合成、工程修订号。接着写上要改什么,再按发送即可。片段引用还带着你右键的那个时间点,所以“从这里切开”“这个时间点的字幕”都能落在你指的位置。引用文字先进入草稿,发送前可以随意修改。

## 选择导出方式

![Cutline 中文导出面板:范围、预设、视频和音频设置](docs/export-zh-CN.png)
Expand All @@ -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` 执行仓库校验。
Expand All @@ -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 配置、凭据、私有日志、会话数据库和麦克风均位于访问范围之外。

## 上游与维护
Expand Down
106 changes: 99 additions & 7 deletions plugins/klarkxy/cutline/miniapp/client/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)), trackOrder: TRACKS.map((t) => t.id), ...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());
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading