@keepylabs/cutroom 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cutroom.mjs +56 -17
- package/package.json +1 -1
package/cutroom.mjs
CHANGED
|
@@ -8,7 +8,19 @@ var __export = (target, all) => {
|
|
|
8
8
|
|
|
9
9
|
// src/community/local/server.ts
|
|
10
10
|
import { homedir as homedir3 } from "node:os";
|
|
11
|
-
import { join as join4 } from "node:path";
|
|
11
|
+
import { delimiter, join as join4 } from "node:path";
|
|
12
|
+
|
|
13
|
+
// src/community/local/proc.ts
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
var WIN = process.platform === "win32";
|
|
16
|
+
var quote = (a) => /[\s"&|<>^()%!,;=']/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a;
|
|
17
|
+
function run(cmd, args, opt = {}) {
|
|
18
|
+
const o = { encoding: "utf8", ...opt };
|
|
19
|
+
if (WIN && cmd === "npx") return spawnSync(["npx", ...args].map(quote).join(" "), { ...o, shell: true });
|
|
20
|
+
return spawnSync(cmd, args, o);
|
|
21
|
+
}
|
|
22
|
+
var has = (bin) => spawnSync(WIN ? "where" : "which", [bin], { stdio: "ignore" }).status === 0;
|
|
23
|
+
var installLine = (tool) => WIN ? tool === "ffmpeg" ? "winget install --id Gyan.FFmpeg -e" : "winget install --id OpenJS.NodeJS.LTS -e" : `brew install ${tool}`;
|
|
12
24
|
|
|
13
25
|
// node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
|
|
14
26
|
var __create = Object.create;
|
|
@@ -21541,15 +21553,17 @@ import { homedir as homedir2 } from "node:os";
|
|
|
21541
21553
|
import { basename, extname, join as join3, resolve as resolve2 } from "node:path";
|
|
21542
21554
|
|
|
21543
21555
|
// src/community/local/measure.ts
|
|
21544
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
21556
|
+
import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
|
|
21545
21557
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
21546
21558
|
import { join } from "node:path";
|
|
21547
|
-
var ffmpegErr = (args) =>
|
|
21559
|
+
var ffmpegErr = (args) => spawnSync2("ffmpeg", ["-nostdin", "-hide_banner", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).stderr ?? "";
|
|
21548
21560
|
function probe(file) {
|
|
21549
21561
|
const info = JSON.parse(execFileSync("ffprobe", ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", file], { encoding: "utf8" }));
|
|
21550
21562
|
const v = info.streams.find((s) => s.codec_type === "video");
|
|
21551
21563
|
if (!v) throw new Error("That file has no video.");
|
|
21552
|
-
|
|
21564
|
+
const rot = Math.abs(Number(v.side_data_list?.find((d) => d.rotation !== void 0)?.rotation ?? v.tags?.rotate ?? 0)) % 180;
|
|
21565
|
+
const [width, height] = rot === 90 ? [v.height, v.width] : [v.width, v.height];
|
|
21566
|
+
return { duration: Number(info.format.duration), width, height, has_audio: info.streams.some((s) => s.codec_type === "audio") };
|
|
21553
21567
|
}
|
|
21554
21568
|
function parseCuts(log) {
|
|
21555
21569
|
return [...log.matchAll(/pts_time:([\d.]+)/g)].map((m) => Math.round(Number(m[1]) * 100) / 100).filter((t, i, a) => i === 0 || t - a[i - 1] > 0.3);
|
|
@@ -21633,12 +21647,12 @@ function technicalChecks(file) {
|
|
|
21633
21647
|
}
|
|
21634
21648
|
|
|
21635
21649
|
// src/community/local/tools.ts
|
|
21636
|
-
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
21637
21650
|
import { createWriteStream, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
21638
21651
|
import { homedir } from "node:os";
|
|
21639
21652
|
import { join as join2, resolve } from "node:path";
|
|
21640
21653
|
import { Readable } from "node:stream";
|
|
21641
21654
|
import { pipeline } from "node:stream/promises";
|
|
21655
|
+
var VERSION = "0.5.6";
|
|
21642
21656
|
var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
|
|
21643
21657
|
var HOME = () => process.env.CUTROOM_HOME || join2(homedir(), "Cutroom");
|
|
21644
21658
|
async function call(op, body = {}, fetchImpl = fetch) {
|
|
@@ -21660,7 +21674,6 @@ async function call(op, body = {}, fetchImpl = fetch) {
|
|
|
21660
21674
|
if (!res.ok) throw new Error(json?.error ?? `Cutroom error (HTTP ${res.status}).`);
|
|
21661
21675
|
return json;
|
|
21662
21676
|
}
|
|
21663
|
-
var has = (bin) => spawnSync2("which", [bin]).status === 0;
|
|
21664
21677
|
var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
21665
21678
|
var day = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
21666
21679
|
var fmt = (t) => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, "0")}`;
|
|
@@ -21672,8 +21685,8 @@ async function download(url2, target) {
|
|
|
21672
21685
|
}
|
|
21673
21686
|
async function doctor() {
|
|
21674
21687
|
const checks = [
|
|
21675
|
-
{ name: "ffmpeg", ok: has("ffmpeg") && has("ffprobe"), fix: "
|
|
21676
|
-
{ name: "Node.js (for rendering)", ok: has("npx"), fix: "
|
|
21688
|
+
{ name: "ffmpeg", ok: has("ffmpeg") && has("ffprobe"), fix: installLine("ffmpeg") },
|
|
21689
|
+
{ name: "Node.js (for rendering)", ok: has("npx"), fix: installLine("node") }
|
|
21677
21690
|
];
|
|
21678
21691
|
let member = null;
|
|
21679
21692
|
try {
|
|
@@ -21682,7 +21695,7 @@ async function doctor() {
|
|
|
21682
21695
|
} catch (e) {
|
|
21683
21696
|
checks.push({ name: "Cutroom membership key", ok: false, fix: e.message });
|
|
21684
21697
|
}
|
|
21685
|
-
return { ready: checks.every((c) => c.ok), checks, member, results_folder: HOME() };
|
|
21698
|
+
return { ready: checks.every((c) => c.ok), helper_version: VERSION, checks, member, results_folder: HOME() };
|
|
21686
21699
|
}
|
|
21687
21700
|
function approxWords(text, duration3) {
|
|
21688
21701
|
const words = (text ?? "").split(/\s+/).filter(Boolean);
|
|
@@ -21697,7 +21710,7 @@ function beatBounds(duration3, cuts, words) {
|
|
|
21697
21710
|
return [...out, duration3];
|
|
21698
21711
|
}
|
|
21699
21712
|
function localWords(file, dir) {
|
|
21700
|
-
const r =
|
|
21713
|
+
const r = run("npx", ["--yes", "hyperframes@0.8.99", "transcribe", file, "--json", "-d", dir], { cwd: dir, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
21701
21714
|
try {
|
|
21702
21715
|
const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
|
|
21703
21716
|
if (r.status !== 0 || !meta2.ok) return null;
|
|
@@ -21877,7 +21890,7 @@ async function creatorTeardown(input) {
|
|
|
21877
21890
|
var HF = "hyperframes@0.8.99";
|
|
21878
21891
|
var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
21879
21892
|
var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
|
|
21880
|
-
var sh = (cmd, args, cwd) =>
|
|
21893
|
+
var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
21881
21894
|
var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
|
|
21882
21895
|
var projectFile = (folder) => join3(folder, ".cutroom", "project.json");
|
|
21883
21896
|
var resolveProject = (p) => {
|
|
@@ -21929,10 +21942,12 @@ ${meta2.error ?? tail(t.stderr)}`);
|
|
|
21929
21942
|
if (!looksLikeSpeech(words, duration3)) throw new Error(lang === "en" ? "I couldn't hear anyone talking in this video (it sounds like music or silence). Cutroom finishing needs you talking to camera. If you speak another language, tell me which one and I'll try again." : `I couldn't make out the speech in this video, even in that language. Check the sound is you talking to camera.`);
|
|
21930
21943
|
const project = { name, folder, source, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
|
|
21931
21944
|
writeFileSync2(projectFile(folder), JSON.stringify(project, null, 2));
|
|
21945
|
+
const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
|
|
21932
21946
|
return {
|
|
21933
21947
|
project: folder,
|
|
21934
21948
|
duration: project.duration,
|
|
21935
21949
|
pauses_removed_s: project.removed_s,
|
|
21950
|
+
...landscape ? { warning: "This video is landscape (wide). Cutroom crops the middle of it to make it vertical, so anything off-center gets cut, and an already-edited video (split screen, titles, captions burned in) will look wrong. Tell the user plainly and ask for the raw vertical take of them talking; continue only if they say this one is fine." } : {},
|
|
21936
21951
|
transcript: words.map((w) => `${w.t.toFixed(2)} ${w.w}`).join("\n"),
|
|
21937
21952
|
next: "Check the transcript for misheard words (pass them as fixes). Then plan: list_styles, get_method('edit_plan') and follow it, then plan_reel to show the storyboard. Nothing renders until the user says go."
|
|
21938
21953
|
};
|
|
@@ -22080,10 +22095,34 @@ async function planReel(input) {
|
|
|
22080
22095
|
const plainAt = effects.length && Number(effects[0].start) > 2 ? 1.2 : null;
|
|
22081
22096
|
const styles = await call("styles");
|
|
22082
22097
|
const title = (list, slug3) => (list.find((x) => x.slug === slug3)?.title ?? String(slug3)).replace(/\s*\(.*\)$/, "");
|
|
22083
|
-
const
|
|
22098
|
+
const sentences = words.reduce((out, w, k) => {
|
|
22099
|
+
if (!out.length || /[.!?]["”']?$/.test(words[k - 1]?.w ?? "")) out.push({ t: w.t, text: [] });
|
|
22100
|
+
out[out.length - 1].text.push(w.w);
|
|
22101
|
+
return out;
|
|
22102
|
+
}, []);
|
|
22103
|
+
const table = () => {
|
|
22104
|
+
const rows = [];
|
|
22105
|
+
let at = 0;
|
|
22106
|
+
for (const m of [...moments].sort((x, y) => x.t - y.t)) {
|
|
22107
|
+
if (m.t - at > 1.5) rows.push({ t: at, end: m.t, what: "just you and the captions" });
|
|
22108
|
+
rows.push(m);
|
|
22109
|
+
at = Math.max(at, m.end);
|
|
22110
|
+
}
|
|
22111
|
+
if (pj.duration - at > 1.5) rows.push({ t: at, end: pj.duration, what: "just you and the captions" });
|
|
22112
|
+
const merged = rows.filter((r2, k) => !(r2.what === "just you and the captions" && rows[k - 1]?.what === r2.what));
|
|
22113
|
+
rowStarts = merged.map((r2) => r2.t);
|
|
22114
|
+
return merged;
|
|
22115
|
+
};
|
|
22116
|
+
let rowStarts = [];
|
|
22117
|
+
const said = (from) => {
|
|
22118
|
+
const own2 = sentences.filter((x) => (rowStarts.filter((st) => st <= x.t + 0.3).pop() ?? rowStarts[0]) === from);
|
|
22119
|
+
if (own2.length) return own2.map((x) => x.text.join(" ")).join(" ");
|
|
22120
|
+
const cur = sentences.filter((x) => x.t <= from).slice(-1);
|
|
22121
|
+
return cur.length ? `(\u2026${cur[0].text.join(" ")})` : "";
|
|
22122
|
+
};
|
|
22084
22123
|
const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) + Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5)));
|
|
22085
22124
|
const moments = [
|
|
22086
|
-
...plainAt !== null ? [{ t:
|
|
22125
|
+
...plainAt !== null ? [{ t: 0, end: Number(effects[0].start), what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
|
|
22087
22126
|
...effects.map((f, k) => {
|
|
22088
22127
|
const name = title(styles.effects, f.slug);
|
|
22089
22128
|
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
|
|
@@ -22119,7 +22158,7 @@ ${tail(t.stderr)}`);
|
|
|
22119
22158
|
"",
|
|
22120
22159
|
"| Time | What you say | What shows on screen |",
|
|
22121
22160
|
"| - | - | - |",
|
|
22122
|
-
...
|
|
22161
|
+
...table().map((m) => `| ${m.t.toFixed(1)} to ${m.end.toFixed(1)}s | ${said(m.t).replace(/\|/g, "/") || "(pause)"} | ${m.what.replace(/\|/g, "/")} |`),
|
|
22123
22162
|
"",
|
|
22124
22163
|
`Storyboard: ${board}`
|
|
22125
22164
|
].join("\n");
|
|
@@ -22194,13 +22233,13 @@ async function qaVideo(input) {
|
|
|
22194
22233
|
}
|
|
22195
22234
|
|
|
22196
22235
|
// src/community/local/server.ts
|
|
22197
|
-
process.env.PATH = [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join4(homedir3(), ".local", "bin")].filter(Boolean).join(
|
|
22236
|
+
process.env.PATH = (WIN ? [process.env.PATH, join4(process.env.LOCALAPPDATA ?? join4(homedir3(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join4(homedir3(), ".local", "bin")]).filter(Boolean).join(delimiter);
|
|
22198
22237
|
var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
22199
22238
|
var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
|
|
22200
22239
|
var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
|
|
22201
22240
|
var INSTRUCTIONS = `Cutroom: short-form video help. Speak plainly; the user is not technical. Never show tool or effect codes; use plain names.
|
|
22202
22241
|
|
|
22203
|
-
Setup or any failure: cutroom_doctor, then a short checklist. Missing ffmpeg or Node.js: ask, then run
|
|
22242
|
+
Setup or any failure: cutroom_doctor, then a short checklist. Missing ffmpeg or Node.js: ask, then run the fix line cutroom_doctor gives (brew on a Mac, winget on Windows). Key problem: reconnect Cutroom with their key from the Cutroom key space on Whop; never ask them to paste the key into the chat.
|
|
22204
22243
|
|
|
22205
22244
|
Finish a video (a raw video of them talking: edit it, captions, effects, make it a reel): get_method('finish_video') first and follow it step by step. Nothing renders until they approve the plan.
|
|
22206
22245
|
|
|
@@ -22214,7 +22253,7 @@ QA flags are spots to check: look at each flagged frame yourself and say which a
|
|
|
22214
22253
|
|
|
22215
22254
|
Results are saved in ~/Cutroom. Other creators' reels are for study only: never reuse their words, footage or branding.`;
|
|
22216
22255
|
await serveStdio(() => {
|
|
22217
|
-
const server = new McpServer({ name: "cutroom", version:
|
|
22256
|
+
const server = new McpServer({ name: "cutroom", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
22218
22257
|
server.registerTool("cutroom_doctor", {
|
|
22219
22258
|
description: "Check Cutroom is ready: ffmpeg on this computer and an active membership key. Returns each check with the exact fix. Run it first, and whenever a tool fails.",
|
|
22220
22259
|
inputSchema: object({})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepylabs/cutroom",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"description": "Cutroom for Claude Code: hooks and scripts with scoring, reel breakdowns, creator teardowns, and finishing your own videos with QA. Needs a Keepy Labs membership key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|