@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.
Files changed (2) hide show
  1. package/cutroom.mjs +56 -17
  2. 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) => spawnSync("ffmpeg", ["-nostdin", "-hide_banner", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).stderr ?? "";
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
- return { duration: Number(info.format.duration), width: v.width, height: v.height, has_audio: info.streams.some((s) => s.codec_type === "audio") };
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: "brew install ffmpeg" },
21676
- { name: "Node.js (for rendering)", ok: has("npx"), fix: "brew install node" }
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 = spawnSync2("npx", ["--yes", "hyperframes@0.8.99", "transcribe", file, "--json", "-d", dir], { cwd: dir, encoding: "utf8", maxBuffer: 1 << 28 });
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) => spawnSync3(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
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 said = (from, to) => words.filter((w) => w.t >= from - 0.05 && w.t < to).map((w) => w.w).join(" ");
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: plainAt, end: Number(effects[0].start), what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
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
- ...moments.map((m) => `| ${m.t.toFixed(1)} to ${m.end.toFixed(1)}s | ${said(m.t, m.end).replace(/\|/g, "/") || "(pause)"} | ${m.what.replace(/\|/g, "/")} |`),
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 "brew install ffmpeg" or "brew install node". Key problem: reconnect Cutroom with their key from the Cutroom key space on Whop; never ask them to paste the key into the chat.
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: "0.5.4" }, { instructions: INSTRUCTIONS });
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.4",
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": {