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  @@ -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、轨道、时间线秒数和帧号、当前序列与合成、工程修订号。接着写上要改什么,再按发送即可。片段引用还带着你右键的那个时间点,所以“从这里切开”“这个时间点的字幕”都能落在你指的位置。引用文字先进入草稿,发送前可以随意修改。 + ## 选择导出方式  @@ -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..0ca380f 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)), 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()); @@ -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..1dfb902 --- /dev/null +++ b/plugins/klarkxy/cutline/miniapp/client/chat-context.js @@ -0,0 +1,272 @@ +/* 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 || ""); + } + + /** 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")}]`]; + } + + /** 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}” · ` : ""}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 = []; + 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 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 })}:`); + 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 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)) { + 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 @@