@keepylabs/cutroom 0.5.6 → 0.7.0

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 (3) hide show
  1. package/README.md +26 -8
  2. package/cutroom.mjs +443 -182
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -3,25 +3,43 @@
3
3
  Write hooks and scripts with scoring. Break down any reel. Tear down a creator's reels against yours. Finish your own talking-head videos with captions, layouts and effects, checked before you post.
4
4
 
5
5
  ## Install (one command)
6
- In your terminal, with your key from the welcome email:
6
+ In your terminal:
7
7
 
8
- claude mcp add cutroom -s user -e CUTROOM_KEY=ck_yourkey -- npx -y @keepylabs/cutroom@latest
8
+ claude mcp add cutroom -s user -- npx -y @keepylabs/cutroom@latest
9
9
 
10
- Restart Claude Code and say "check Cutroom is ready". It checks ffmpeg and Node and installs them with your OK.
10
+ Restart Claude Code and say "check my cutroom setup". Your browser opens Sign in with Whop: sign in with the Whop account you joined with. No key to copy. The check also makes your studio folders and tells you if ffmpeg, whisper or Node is missing.
11
+
12
+ ## Your studio
13
+ Everything lives in the Cutroom folder in your home folder. Open Claude Code there.
14
+
15
+ Cutroom/
16
+ CLAUDE.md how your studio works: approvals, rules
17
+ brand/ your colors, logo, frames you love
18
+ references/ reel breakdowns and creator teardowns
19
+ inbox/ drop new raw videos here
20
+ videos/
21
+ 2026-11-01-topic/
22
+ raw/ this video's footage
23
+ script.md what you say, word for word
24
+ facts.md every number on screen, with its source
25
+ plan.md the edit plan and its status
26
+ renders/ previews and the final export
27
+ checks.md what QA found and what got fixed
11
28
 
12
29
  ## Use
13
30
  - "give me hooks for <topic>"
14
31
  - "write a short script about <idea>"
15
32
  - "break down this reel: <link>"
16
33
  - "tear down @creator and compare to @me"
17
- - "finish this video: ~/Desktop/take3.mov"
34
+ - "finish the video in my inbox"
35
+ - "what videos are unfinished?"
18
36
 
19
- Results are saved in ~/Cutroom. The first video you finish downloads the free HyperFrames renderer and a speech-to-text model (a few hundred MB, once).
37
+ The first video you finish downloads the free HyperFrames renderer and a speech-to-text model (a few hundred MB, once).
20
38
 
21
39
  ## What you need
22
- - Claude Code, macOS with Homebrew, Node 20 or newer, ffmpeg
23
- - A Keepy Labs membership key. No other account or API key.
40
+ - Claude Code on macOS or Windows, Node 22 or newer, ffmpeg, whisper-cli
41
+ - A Keepy Labs membership on Whop. No other account or API key.
24
42
 
25
43
  ## Privacy
26
- - Your membership key is the only thing we keep about you, to know your membership is active.
44
+ - Signing in gives Cutroom your Whop name and the key that comes with your membership. The key is saved in your Cutroom folder, readable only by you.
27
45
  - Your videos stay on your computer and are rendered there. For breakdowns and QA we receive sampled still frames, and for finishing the transcript words; we use them for that request and keep nothing.
package/cutroom.mjs CHANGED
@@ -8,7 +8,7 @@ var __export = (target, all) => {
8
8
 
9
9
  // src/community/local/server.ts
10
10
  import { homedir as homedir3 } from "node:os";
11
- import { delimiter, join as join4 } from "node:path";
11
+ import { delimiter, join as join6 } from "node:path";
12
12
 
13
13
  // src/community/local/proc.ts
14
14
  import { spawnSync } from "node:child_process";
@@ -20,7 +20,18 @@ function run(cmd, args, opt = {}) {
20
20
  return spawnSync(cmd, args, o);
21
21
  }
22
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}`;
23
+ var installLine = (tool) => WIN ? {
24
+ ffmpeg: "winget install --id Gyan.FFmpeg -e",
25
+ node: "winget install --id OpenJS.NodeJS.LTS -e",
26
+ "node-update": "winget upgrade --id OpenJS.NodeJS.LTS -e",
27
+ whisper: "Download whisper-bin-x64.zip from the latest release at github.com/ggml-org/whisper.cpp/releases, unzip its Release folder into a Cutroom\\whisper folder in the user's home folder, and add that folder to the user PATH"
28
+ }[tool] : { ffmpeg: "brew install ffmpeg", node: "brew install node", "node-update": "brew upgrade node", whisper: "brew install whisper-cpp" }[tool];
29
+ var NODE_MIN = 22;
30
+ function nodeMajor() {
31
+ const r = spawnSync("node", ["--version"], { encoding: "utf8", shell: WIN });
32
+ const m = (r.stdout ?? "").match(/v(\d+)/);
33
+ return m ? Number(m[1]) : 0;
34
+ }
24
35
 
25
36
  // node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
26
37
  var __create = Object.create;
@@ -3461,7 +3472,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
3461
3472
  var NONE = 0;
3462
3473
  var ASSUMED = 1;
3463
3474
  var PROVEN = 2;
3464
- function isRecursive(inst, stack, resolve3) {
3475
+ function isRecursive(inst, stack, resolve4) {
3465
3476
  const cached2 = recursive.get(inst);
3466
3477
  if (cached2 !== void 0)
3467
3478
  return cached2 ? PROVEN : NONE;
@@ -3471,7 +3482,7 @@ function isRecursive(inst, stack, resolve3) {
3471
3482
  let result2 = NONE;
3472
3483
  const check = (child) => {
3473
3484
  if (result2 !== PROVEN && child?._zod) {
3474
- const answer = isRecursive(child, stack, resolve3);
3485
+ const answer = isRecursive(child, stack, resolve4);
3475
3486
  if (answer > result2)
3476
3487
  result2 = answer;
3477
3488
  }
@@ -3482,7 +3493,7 @@ function isRecursive(inst, stack, resolve3) {
3482
3493
  const desc = Object.getOwnPropertyDescriptor(sh2, key);
3483
3494
  if (spread && !desc.enumerable)
3484
3495
  continue;
3485
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve3) : NONE;
3496
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve4) : NONE;
3486
3497
  if (child > answer)
3487
3498
  answer = child;
3488
3499
  }
@@ -3546,7 +3557,7 @@ function isRecursive(inst, stack, resolve3) {
3546
3557
  break;
3547
3558
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
3548
3559
  case "lazy": {
3549
- const inner = def._cachedInner ?? (resolve3 ? inst._zod.innerType : void 0);
3560
+ const inner = def._cachedInner ?? (resolve4 ? inst._zod.innerType : void 0);
3550
3561
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
3551
3562
  break;
3552
3563
  }
@@ -11238,14 +11249,14 @@ function inputRequiredRoundsExceededMessage(method, maxRounds) {
11238
11249
  return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
11239
11250
  }
11240
11251
  function sleep(ms, signal) {
11241
- return new Promise((resolve3, reject) => {
11252
+ return new Promise((resolve4, reject) => {
11242
11253
  if (signal?.aborted) {
11243
11254
  reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason)));
11244
11255
  return;
11245
11256
  }
11246
11257
  const timer = setTimeout(() => {
11247
11258
  signal?.removeEventListener("abort", onAbort);
11248
- resolve3();
11259
+ resolve4();
11249
11260
  }, ms);
11250
11261
  const onAbort = () => {
11251
11262
  clearTimeout(timer);
@@ -12039,7 +12050,7 @@ var Protocol = class {
12039
12050
  const flowStartedAt = Date.now();
12040
12051
  let onAbort;
12041
12052
  let cleanupMessageId;
12042
- return new Promise((resolve3, reject) => {
12053
+ return new Promise((resolve4, reject) => {
12043
12054
  const earlyReject = (error2) => {
12044
12055
  reject(error2);
12045
12056
  };
@@ -12108,7 +12119,7 @@ var Protocol = class {
12108
12119
  }
12109
12120
  if (decoded.kind === "invalid") return reject(decoded.error);
12110
12121
  if (decoded.kind === "input_required") {
12111
- if (options?.allowInputRequired === true) return resolve3(manualInputRequiredValue(decoded));
12122
+ if (options?.allowInputRequired === true) return resolve4(manualInputRequiredValue(decoded));
12112
12123
  const flow = {
12113
12124
  codec,
12114
12125
  request,
@@ -12120,11 +12131,11 @@ var Protocol = class {
12120
12131
  params
12121
12132
  }, resultSchema, legOptions)
12122
12133
  };
12123
- return resolve3(this._resolveNonCompleteResult(decoded, flow));
12134
+ return resolve4(this._resolveNonCompleteResult(decoded, flow));
12124
12135
  }
12125
12136
  const result2 = decoded.result;
12126
12137
  validateStandardSchema(resultSchema, result2).then((parseResult) => {
12127
- if (parseResult.success) resolve3(parseResult.data);
12138
+ if (parseResult.success) resolve4(parseResult.data);
12128
12139
  else reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`));
12129
12140
  }, reject);
12130
12141
  });
@@ -15036,7 +15047,7 @@ var require_compile = /* @__PURE__ */ __commonJSMin(((exports) => {
15036
15047
  ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, ref);
15037
15048
  const schOrFunc = root.refs[ref];
15038
15049
  if (schOrFunc) return schOrFunc;
15039
- let _sch = resolve3.call(this, root, ref);
15050
+ let _sch = resolve4.call(this, root, ref);
15040
15051
  if (_sch === void 0) {
15041
15052
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
15042
15053
  const { schemaId } = this.opts;
@@ -15062,7 +15073,7 @@ var require_compile = /* @__PURE__ */ __commonJSMin(((exports) => {
15062
15073
  function sameSchemaEnv(s1, s2) {
15063
15074
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
15064
15075
  }
15065
- function resolve3(root, ref) {
15076
+ function resolve4(root, ref) {
15066
15077
  let sch;
15067
15078
  while (typeof (sch = this.refs[ref]) == "string") ref = sch;
15068
15079
  return sch || this.schemas[ref] || resolveSchema.call(this, root, ref);
@@ -15512,47 +15523,47 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
15512
15523
  else if (typeof uri === "object") uri = parse2(serialize(uri, options), options);
15513
15524
  return uri;
15514
15525
  }
15515
- function resolve3(baseURI, relativeURI, options) {
15526
+ function resolve4(baseURI, relativeURI, options) {
15516
15527
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
15517
15528
  const resolved = resolveComponent(parse2(baseURI, schemelessOptions), parse2(relativeURI, schemelessOptions), schemelessOptions, true);
15518
15529
  schemelessOptions.skipEscape = true;
15519
15530
  return serialize(resolved, schemelessOptions);
15520
15531
  }
15521
- function resolveComponent(base, relative, options, skipNormalization) {
15532
+ function resolveComponent(base, relative2, options, skipNormalization) {
15522
15533
  const target = {};
15523
15534
  if (!skipNormalization) {
15524
15535
  base = parse2(serialize(base, options), options);
15525
- relative = parse2(serialize(relative, options), options);
15536
+ relative2 = parse2(serialize(relative2, options), options);
15526
15537
  }
15527
15538
  options = options || {};
15528
- if (!options.tolerant && relative.scheme) {
15529
- target.scheme = relative.scheme;
15530
- target.userinfo = relative.userinfo;
15531
- target.host = relative.host;
15532
- target.port = relative.port;
15533
- target.path = removeDotSegments(relative.path || "");
15534
- target.query = relative.query;
15539
+ if (!options.tolerant && relative2.scheme) {
15540
+ target.scheme = relative2.scheme;
15541
+ target.userinfo = relative2.userinfo;
15542
+ target.host = relative2.host;
15543
+ target.port = relative2.port;
15544
+ target.path = removeDotSegments(relative2.path || "");
15545
+ target.query = relative2.query;
15535
15546
  } else {
15536
- if (relative.userinfo !== void 0 || relative.host !== void 0 || relative.port !== void 0) {
15537
- target.userinfo = relative.userinfo;
15538
- target.host = relative.host;
15539
- target.port = relative.port;
15540
- target.path = removeDotSegments(relative.path || "");
15541
- target.query = relative.query;
15547
+ if (relative2.userinfo !== void 0 || relative2.host !== void 0 || relative2.port !== void 0) {
15548
+ target.userinfo = relative2.userinfo;
15549
+ target.host = relative2.host;
15550
+ target.port = relative2.port;
15551
+ target.path = removeDotSegments(relative2.path || "");
15552
+ target.query = relative2.query;
15542
15553
  } else {
15543
- if (!relative.path) {
15554
+ if (!relative2.path) {
15544
15555
  target.path = base.path;
15545
- if (relative.query !== void 0) target.query = relative.query;
15556
+ if (relative2.query !== void 0) target.query = relative2.query;
15546
15557
  else target.query = base.query;
15547
15558
  } else {
15548
- if (relative.path[0] === "/") target.path = removeDotSegments(relative.path);
15559
+ if (relative2.path[0] === "/") target.path = removeDotSegments(relative2.path);
15549
15560
  else {
15550
- if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) target.path = "/" + relative.path;
15551
- else if (!base.path) target.path = relative.path;
15552
- else target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative.path;
15561
+ if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) target.path = "/" + relative2.path;
15562
+ else if (!base.path) target.path = relative2.path;
15563
+ else target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative2.path;
15553
15564
  target.path = removeDotSegments(target.path);
15554
15565
  }
15555
- target.query = relative.query;
15566
+ target.query = relative2.query;
15556
15567
  }
15557
15568
  target.userinfo = base.userinfo;
15558
15569
  target.host = base.host;
@@ -15560,7 +15571,7 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
15560
15571
  }
15561
15572
  target.scheme = base.scheme;
15562
15573
  }
15563
- target.fragment = relative.fragment;
15574
+ target.fragment = relative2.fragment;
15564
15575
  return target;
15565
15576
  }
15566
15577
  function equal(uriA, uriB, options) {
@@ -15686,7 +15697,7 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
15686
15697
  const fastUri = {
15687
15698
  SCHEMES,
15688
15699
  normalize,
15689
- resolve: resolve3,
15700
+ resolve: resolve4,
15690
15701
  resolveComponent,
15691
15702
  equal,
15692
15703
  serialize,
@@ -21119,7 +21130,7 @@ var StdioServerTransport = class {
21119
21130
  }
21120
21131
  send(message) {
21121
21132
  if (this._closed) return Promise.reject(/* @__PURE__ */ new Error("StdioServerTransport is closed"));
21122
- return new Promise((resolve3, reject) => {
21133
+ return new Promise((resolve4, reject) => {
21123
21134
  const json = serializeMessage(message);
21124
21135
  let settled = false;
21125
21136
  const onError = (error2) => {
@@ -21134,14 +21145,14 @@ var StdioServerTransport = class {
21134
21145
  settled = true;
21135
21146
  this._stdout.off("error", onError);
21136
21147
  this._stdout.off("drain", onDrain);
21137
- resolve3();
21148
+ resolve4();
21138
21149
  };
21139
21150
  this._stdout.once("error", onError);
21140
21151
  if (this._stdout.write(json)) {
21141
21152
  if (settled) return;
21142
21153
  settled = true;
21143
21154
  this._stdout.off("error", onError);
21144
- resolve3();
21155
+ resolve4();
21145
21156
  } else if (!settled) this._stdout.once("drain", onDrain);
21146
21157
  });
21147
21158
  }
@@ -21195,14 +21206,14 @@ var StdioConnectionChannel = class {
21195
21206
  */
21196
21207
  async whenRequestsAnswered(timeoutMs) {
21197
21208
  if (this._closed || this._pendingRequests.size === 0) return true;
21198
- return await new Promise((resolve3) => {
21209
+ return await new Promise((resolve4) => {
21199
21210
  const waiter = () => {
21200
21211
  clearTimeout(timer);
21201
- resolve3(true);
21212
+ resolve4(true);
21202
21213
  };
21203
21214
  const timer = setTimeout(() => {
21204
21215
  this._drainWaiters = this._drainWaiters.filter((pending) => pending !== waiter);
21205
- resolve3(false);
21216
+ resolve4(false);
21206
21217
  }, timeoutMs);
21207
21218
  this._drainWaiters.push(waiter);
21208
21219
  });
@@ -21544,13 +21555,13 @@ function toError(value) {
21544
21555
  }
21545
21556
 
21546
21557
  // src/community/local/server.ts
21547
- import { readFileSync as readFileSync4 } from "node:fs";
21558
+ import { readFileSync as readFileSync6 } from "node:fs";
21548
21559
 
21549
21560
  // src/community/local/render.ts
21550
21561
  import { spawnSync as spawnSync3 } from "node:child_process";
21551
- import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, renameSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
21552
- import { homedir as homedir2 } from "node:os";
21553
- import { basename, extname, join as join3, resolve as resolve2 } from "node:path";
21562
+ import { copyFileSync as copyFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync5, mkdtempSync, readdirSync, readFileSync as readFileSync5, renameSync as renameSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync4 } from "node:fs";
21563
+ import { homedir as homedir2, tmpdir } from "node:os";
21564
+ import { basename as basename2, extname, join as join5, resolve as resolve3 } from "node:path";
21554
21565
 
21555
21566
  // src/community/local/measure.ts
21556
21567
  import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
@@ -21646,18 +21657,143 @@ function technicalChecks(file) {
21646
21657
  return { ...p, lufs, black, freeze, silences, issues };
21647
21658
  }
21648
21659
 
21660
+ // src/community/local/studio.ts
21661
+ import { appendFileSync, copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync, writeFileSync as writeFileSync3 } from "node:fs";
21662
+ import { basename, isAbsolute, join as join4, relative, resolve as resolve2 } from "node:path";
21663
+
21649
21664
  // src/community/local/tools.ts
21650
- import { createWriteStream, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "node:fs";
21665
+ import { createWriteStream, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
21651
21666
  import { homedir } from "node:os";
21652
- import { join as join2, resolve } from "node:path";
21667
+ import { join as join3, resolve } from "node:path";
21653
21668
  import { Readable } from "node:stream";
21654
21669
  import { pipeline } from "node:stream/promises";
21655
- var VERSION = "0.5.6";
21670
+
21671
+ // src/community/local/signin.ts
21672
+ import { spawn } from "node:child_process";
21673
+ import { createHash, randomBytes } from "node:crypto";
21674
+ import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
21675
+ import { createServer } from "node:http";
21676
+ import { join as join2 } from "node:path";
21677
+ var WHOP_APP = "app_bFhJFdBJn9jZ0v";
21678
+ var PORTS = [53171, 53172, 53173];
21679
+ var WAIT_MS = 5 * 6e4;
21680
+ var keyFile = () => join2(HOME(), ".cutroom-key");
21681
+ function currentKey() {
21682
+ try {
21683
+ const k = readFileSync2(keyFile(), "utf8").trim();
21684
+ if (k) return k;
21685
+ } catch {
21686
+ }
21687
+ const env = process.env.CUTROOM_KEY;
21688
+ return env && !env.includes("${") ? env : null;
21689
+ }
21690
+ function saveKey(key) {
21691
+ mkdirSync2(HOME(), { recursive: true });
21692
+ writeFileSync(keyFile(), key, { mode: 384 });
21693
+ try {
21694
+ chmodSync(keyFile(), 384);
21695
+ } catch {
21696
+ }
21697
+ }
21698
+ var forgetKey = () => rmSync(keyFile(), { force: true });
21699
+ function openBrowser(url2) {
21700
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url2]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url2]] : ["xdg-open", [url2]];
21701
+ try {
21702
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {
21703
+ }).unref();
21704
+ } catch {
21705
+ }
21706
+ }
21707
+ var page = (title, line) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
21708
+ <body style="margin:0;min-height:100vh;display:grid;place-items:center;background:#0A0A0A;color:#F7F3E8;font:16px/1.5 -apple-system,Segoe UI,sans-serif">
21709
+ <div style="max-width:420px;padding:24px;text-align:center"><h1 style="font-size:24px;margin:0 0 8px">${title}</h1><p style="color:#A39E93;margin:0">${line}</p></div>`;
21710
+ async function listen(handler) {
21711
+ for (const port of PORTS) {
21712
+ const servers = [];
21713
+ try {
21714
+ for (const host of ["127.0.0.1", "::1"]) {
21715
+ const s = createServer(handler);
21716
+ await new Promise((ok, fail) => {
21717
+ s.once("error", fail);
21718
+ s.listen(port, host, () => ok());
21719
+ }).then(() => servers.push(s), (e) => {
21720
+ if (host === "::1" && e?.code !== "EADDRINUSE") return;
21721
+ throw e;
21722
+ });
21723
+ }
21724
+ return { port, close: () => servers.forEach((s) => s.close()) };
21725
+ } catch {
21726
+ servers.forEach((s) => s.close());
21727
+ }
21728
+ }
21729
+ throw new Error("Couldn't start the sign-in (ports 53171 to 53173 are busy). Close other apps and try again.");
21730
+ }
21731
+ async function signIn(deps = {}) {
21732
+ const fetchImpl = deps.fetchImpl ?? fetch;
21733
+ const verifier = randomBytes(32).toString("base64url"), state = randomBytes(16).toString("base64url");
21734
+ let done;
21735
+ const result2 = new Promise((r) => done = r);
21736
+ let redirect = "";
21737
+ const server = await listen((req, res) => {
21738
+ const url2 = new URL(req.url ?? "/", "http://localhost");
21739
+ if (url2.pathname !== "/callback") {
21740
+ res.writeHead(404).end();
21741
+ return;
21742
+ }
21743
+ if (url2.searchParams.get("state") !== state) {
21744
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }).end(page("That link is old", "Go back to Claude and ask it to sign in to Cutroom again."));
21745
+ return;
21746
+ }
21747
+ const error2 = url2.searchParams.get("error");
21748
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(error2 ? page("Sign-in cancelled", "Go back to Claude. You can sign in again any time.") : page("Signed in to Cutroom", "You can close this tab and go back to Claude."));
21749
+ done(error2 ? { error: url2.searchParams.get("error_description") ?? error2 } : { code: url2.searchParams.get("code") ?? "" });
21750
+ });
21751
+ redirect = `http://localhost:${server.port}/callback`;
21752
+ const authorize = `https://api.whop.com/oauth/authorize?${new URLSearchParams({
21753
+ response_type: "code",
21754
+ client_id: WHOP_APP,
21755
+ redirect_uri: redirect,
21756
+ scope: "openid profile email",
21757
+ state,
21758
+ nonce: randomBytes(16).toString("base64url"),
21759
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
21760
+ code_challenge_method: "S256"
21761
+ })}`;
21762
+ process.stderr.write(`Cutroom: sign in with Whop in your browser. If it didn't open, go to:
21763
+ ${authorize}
21764
+ `);
21765
+ (deps.open ?? openBrowser)(authorize);
21766
+ let timer;
21767
+ try {
21768
+ const got = await Promise.race([result2, new Promise((r) => {
21769
+ timer = setTimeout(() => r({ error: "timeout" }), deps.waitMs ?? WAIT_MS);
21770
+ })]);
21771
+ if (got.error === "timeout") throw new Error("The Whop sign-in wasn't finished in 5 minutes. Run the setup check again to get a new sign-in page.");
21772
+ if (got.error || !got.code) throw new Error(`Whop sign-in didn't finish (${got.error ?? "no code"}). Run the setup check again to retry.`);
21773
+ const post = (url2, body2, offline) => fetchImpl(url2, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body2) }).catch(() => {
21774
+ throw new Error(offline);
21775
+ });
21776
+ const tok = await post("https://api.whop.com/oauth/token", { grant_type: "authorization_code", code: got.code, redirect_uri: redirect, client_id: WHOP_APP, code_verifier: verifier }, "Can't reach Whop right now. Check your internet connection and run the setup check again.");
21777
+ const t = await tok.json().catch(() => ({}));
21778
+ if (!tok.ok || !t.access_token) throw new Error("Whop didn't accept the sign-in. Run the setup check again to retry.");
21779
+ const res = await post(`${GATEWAY()}/signin`, { access_token: t.access_token }, "Can't reach Cutroom right now. Check your internet connection and run the setup check again.");
21780
+ const body = await res.json().catch(() => ({}));
21781
+ if (!res.ok || !body.key) throw new Error(body?.error ?? `Cutroom sign-in failed (HTTP ${res.status}).`);
21782
+ saveKey(String(body.key));
21783
+ return { name: String(body.name ?? "member") };
21784
+ } finally {
21785
+ clearTimeout(timer);
21786
+ server.close();
21787
+ }
21788
+ }
21789
+
21790
+ // src/community/local/tools.ts
21791
+ var VERSION = "0.7.0";
21656
21792
  var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
21657
- var HOME = () => process.env.CUTROOM_HOME || join2(homedir(), "Cutroom");
21793
+ var HOME = () => process.env.CUTROOM_HOME || join3(homedir(), "Cutroom");
21658
21794
  async function call(op, body = {}, fetchImpl = fetch) {
21659
- const key = process.env.CUTROOM_KEY;
21660
- if (!key || key.includes("${")) throw new Error("No Cutroom key yet. Connect Cutroom with the install command from your welcome email (it includes your key).");
21795
+ const key = currentKey();
21796
+ if (!key) throw Object.assign(new Error("Not signed in to Cutroom yet. Run the setup check (cutroom_doctor): it opens the Whop sign-in."), { status: 401 });
21661
21797
  const send = () => fetchImpl(`${GATEWAY()}/v1/${op}`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify(body) });
21662
21798
  let res;
21663
21799
  try {
@@ -21671,7 +21807,7 @@ async function call(op, body = {}, fetchImpl = fetch) {
21671
21807
  }
21672
21808
  }
21673
21809
  const json = await res.json().catch(() => ({}));
21674
- if (!res.ok) throw new Error(json?.error ?? `Cutroom error (HTTP ${res.status}).`);
21810
+ if (!res.ok) throw Object.assign(new Error(json?.error ?? `Cutroom error (HTTP ${res.status}).`), { status: res.status });
21675
21811
  return json;
21676
21812
  }
21677
21813
  var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
@@ -21683,19 +21819,39 @@ async function download(url2, target) {
21683
21819
  if (!res.ok || !res.body) throw new Error(`Couldn't download the video (HTTP ${res.status}).`);
21684
21820
  await pipeline(Readable.fromWeb(res.body), createWriteStream(target));
21685
21821
  }
21686
- async function doctor() {
21822
+ async function doctor(opts = {}) {
21823
+ const studioFolder = ensureStudio();
21687
21824
  const checks = [
21688
21825
  { name: "ffmpeg", ok: has("ffmpeg") && has("ffprobe"), fix: installLine("ffmpeg") },
21689
- { name: "Node.js (for rendering)", ok: has("npx"), fix: installLine("node") }
21826
+ { name: "whisper (captions)", ok: has("whisper-cli"), fix: installLine("whisper") },
21827
+ (() => {
21828
+ const v = has("npx") ? nodeMajor() : 0;
21829
+ if (!v) return { name: "Node.js (for rendering)", ok: false, fix: installLine("node") };
21830
+ return v >= NODE_MIN ? { name: `Node.js ${v} (for rendering)`, ok: true } : { name: `Node.js ${v} is too old (rendering needs ${NODE_MIN} or newer)`, ok: false, fix: installLine("node-update") };
21831
+ })()
21690
21832
  ];
21691
- let member = null;
21833
+ let member = null, signed_in_now = false;
21692
21834
  try {
21693
- member = await call("me");
21694
- checks.push({ name: "Cutroom membership key", ok: true });
21835
+ const again = async () => {
21836
+ forgetKey();
21837
+ await signIn();
21838
+ signed_in_now = true;
21839
+ return call("me");
21840
+ };
21841
+ if (opts.fresh) member = await again();
21842
+ else {
21843
+ try {
21844
+ member = await call("me");
21845
+ } catch (e) {
21846
+ if (e?.status !== 401) throw e;
21847
+ member = await again();
21848
+ }
21849
+ }
21850
+ checks.push({ name: "Cutroom membership (signed in with Whop)", ok: true });
21695
21851
  } catch (e) {
21696
- checks.push({ name: "Cutroom membership key", ok: false, fix: e.message });
21852
+ checks.push({ name: "Cutroom membership (signed in with Whop)", ok: false, fix: e.message });
21697
21853
  }
21698
- return { ready: checks.every((c) => c.ok), helper_version: VERSION, checks, member, results_folder: HOME() };
21854
+ return { ready: checks.every((c) => c.ok), helper_version: VERSION, checks, member, ...signed_in_now ? { signed_in_now } : {}, studio: studioFolder };
21699
21855
  }
21700
21856
  function approxWords(text, duration3) {
21701
21857
  const words = (text ?? "").split(/\s+/).filter(Boolean);
@@ -21714,7 +21870,7 @@ function localWords(file, dir) {
21714
21870
  try {
21715
21871
  const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
21716
21872
  if (r.status !== 0 || !meta2.ok) return null;
21717
- const raw = JSON.parse(readFileSync2(meta2.transcriptPath ?? join2(dir, "transcript.json"), "utf8"));
21873
+ const raw = JSON.parse(readFileSync3(meta2.transcriptPath ?? join3(dir, "transcript.json"), "utf8"));
21718
21874
  const words = raw.map((w) => ({ t: Math.round(w.start * 100) / 100, e: Math.round(w.end * 100) / 100, w: w.text.trim() })).filter((w) => w.w);
21719
21875
  return words.length ? words : null;
21720
21876
  } catch {
@@ -21738,18 +21894,18 @@ async function breakdownReel(input) {
21738
21894
  const r = await call("reel", { url: src });
21739
21895
  meta2 = r.meta;
21740
21896
  transcript = r.transcript;
21741
- const dir2 = join2(HOME(), "breakdowns", `${day()}-${slug(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
21742
- mkdirSync2(dir2, { recursive: true });
21743
- file = join2(dir2, "source.mp4");
21744
- if (!existsSync(file)) await download(meta2.videoUrl, file);
21897
+ const dir2 = join3(studio.references(), `${day()}-${slug(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
21898
+ mkdirSync3(dir2, { recursive: true });
21899
+ file = join3(dir2, "source.mp4");
21900
+ if (!existsSync2(file)) await download(meta2.videoUrl, file);
21745
21901
  } else {
21746
21902
  file = resolve(src.replace(/^~/, homedir()));
21747
- if (!existsSync(file)) throw new Error(`No such file: ${file}`);
21903
+ if (!existsSync2(file)) throw new Error(`No such file: ${file}`);
21748
21904
  }
21749
- const dir = /^https?:/i.test(src) ? join2(file, "..") : join2(HOME(), "breakdowns", `${day()}-${slug(file.split("/").pop() ?? "video")}`);
21750
- mkdirSync2(dir, { recursive: true });
21905
+ const dir = /^https?:/i.test(src) ? join3(file, "..") : join3(studio.references(), `${day()}-${slug(file.split(/[\\/]/).pop() ?? "video")}`);
21906
+ mkdirSync3(dir, { recursive: true });
21751
21907
  const m = measure(file);
21752
- const { frames, interval } = sampleFrames(file, join2(dir, "frames"), m.duration, 1, 40);
21908
+ const { frames, interval } = sampleFrames(file, join3(dir, "frames"), m.duration, 1, 40);
21753
21909
  const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
21754
21910
  const timed = localWords(file, dir);
21755
21911
  const words = timed ?? approxWords(transcript, m.duration);
@@ -21789,9 +21945,9 @@ async function breakdownReel(input) {
21789
21945
  transcript ?? "(none)",
21790
21946
  ""
21791
21947
  ].join("\n");
21792
- writeFileSync(join2(dir, "breakdown.md"), md);
21793
- const out = { folder: dir, report: join2(dir, "breakdown.md"), hook, measure: m, look: s, beats: bs, words_timing: timed ? "measured" : transcript ? "approximate" : "none", transcript, meta: meta2, next: "Get the 'breakdown_writeup' method with get_method and follow it." };
21794
- writeFileSync(join2(dir, "breakdown.json"), JSON.stringify(out, null, 2));
21948
+ writeFileSync2(join3(dir, "breakdown.md"), md);
21949
+ const out = { folder: dir, report: join3(dir, "breakdown.md"), hook, measure: m, look: s, beats: bs, words_timing: timed ? "measured" : transcript ? "approximate" : "none", transcript, meta: meta2, next: "Get the 'breakdown_writeup' method with get_method and follow it." };
21950
+ writeFileSync2(join3(dir, "breakdown.json"), JSON.stringify(out, null, 2));
21795
21951
  return out;
21796
21952
  }
21797
21953
  function ctaKeyword(caption) {
@@ -21800,15 +21956,15 @@ function ctaKeyword(caption) {
21800
21956
  }
21801
21957
  async function teardownOne(handle, limit) {
21802
21958
  const { reels } = await call("reels", { handle, limit });
21803
- const dir = join2(HOME(), "teardowns", `${day()}-${slug(handle)}`);
21804
- mkdirSync2(join2(dir, "raw"), { recursive: true });
21959
+ const dir = join3(studio.references(), `${day()}-${slug(handle)}-creator`);
21960
+ mkdirSync3(join3(dir, "raw"), { recursive: true });
21805
21961
  const rows = [];
21806
21962
  for (const [i, r] of reels.entries()) {
21807
21963
  try {
21808
- const file = join2(dir, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
21809
- if (!existsSync(file)) await download(r.videoUrl, file);
21964
+ const file = join3(dir, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
21965
+ if (!existsSync2(file)) await download(r.videoUrl, file);
21810
21966
  const m = measure(file);
21811
- const { frames, interval } = sampleFrames(file, join2(dir, "frames", r.shortCode), m.duration, 2, 20);
21967
+ const { frames, interval } = sampleFrames(file, join3(dir, "frames", r.shortCode), m.duration, 2, 20);
21812
21968
  const [match, tr] = await Promise.all([call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval }), call("transcript", { url: r.url }).catch(() => ({ transcript: null }))]);
21813
21969
  const top = (o) => Object.keys(o)[0] ?? null;
21814
21970
  rows.push({ code: r.shortCode, url: r.url, posted: String(r.timestamp ?? "").slice(0, 10), plays: r.videoPlayCount ?? null, duration_s: Math.round(m.duration * 10) / 10, cuts_per_min: m.cuts_per_min, layout: top(match.summary.layout_share), caption: top(match.summary.caption_share), effects: match.summary.effects.filter((e) => e.share >= 0.1 || e.frames >= 2).map((e) => e.title), off_menu: match.summary.effects.filter((e) => e.in_menu === false).map((e) => e.title), hook: tr.transcript ? String(tr.transcript).split(/(?<=[.!?])\s/)[0] : null, cta: ctaKeyword(r.caption) });
@@ -21880,24 +22036,118 @@ async function creatorTeardown(input) {
21880
22036
  ...a.rows.map((x) => x.error ? `| ${x.posted} | ${x.plays ?? ""} | | | failed: ${String(x.error).slice(0, 60)} | | |` : `| [${x.posted}](${x.url}) | ${x.plays ?? ""} | ${x.duration_s} | ${x.cuts_per_min} | ${x.layout ?? ""} | ${x.effects.join(", ")} | ${(x.hook ?? "").replace(/\|/g, "/").slice(0, 90)} |`),
21881
22037
  ""
21882
22038
  ].join("\n");
21883
- writeFileSync(join2(a.dir, "teardown.md"), md);
21884
- const out = { folder: a.dir, report: join2(a.dir, "teardown.md"), rollup: r, compare: cmp, rows: a.rows, next: "Get the 'teardown_writeup' method with get_method and follow it." };
21885
- writeFileSync(join2(a.dir, "teardown.json"), JSON.stringify(out, null, 2));
22039
+ writeFileSync2(join3(a.dir, "teardown.md"), md);
22040
+ const out = { folder: a.dir, report: join3(a.dir, "teardown.md"), rollup: r, compare: cmp, rows: a.rows, next: "Get the 'teardown_writeup' method with get_method and follow it." };
22041
+ writeFileSync2(join3(a.dir, "teardown.json"), JSON.stringify(out, null, 2));
21886
22042
  return out;
21887
22043
  }
21888
22044
 
22045
+ // src/community/local/studio.ts
22046
+ var studio = {
22047
+ brand: () => join4(HOME(), "brand"),
22048
+ references: () => join4(HOME(), "references"),
22049
+ inbox: () => join4(HOME(), "inbox"),
22050
+ videos: () => join4(HOME(), "videos")
22051
+ };
22052
+ var CLAUDE_MD = `# My Cutroom studio
22053
+
22054
+ Everything for my videos lives in this folder. Open Claude Code here so it can see it all.
22055
+
22056
+ ## Where things go
22057
+ - inbox/: drop new raw videos here. When a video starts, its clip moves into that video's raw/ folder.
22058
+ - videos/<date>-<topic>/: one folder per video, with everything for it:
22059
+ - raw/: the footage
22060
+ - script.md: what I say, word for word
22061
+ - facts.md: every number or claim on screen, with its source
22062
+ - plan.md: the edit plan and a status line (footage ready, plan ready, preview, final, posted)
22063
+ - renders/: previews and the final export
22064
+ - checks.md: what QA found and what got fixed
22065
+ - brand/: my colors (brand.json), logo and a few frames I love.
22066
+ - references/: reels and creators I study, each with a breakdown of why it works.
22067
+
22068
+ ## Rules
22069
+ - Three stops: I approve the script, then the plan, then the preview. Nothing final renders before that.
22070
+ - Never invent a number. Every figure on screen goes in facts.md with its source.
22071
+ - Other creators' reels are for study only: never reuse their words, footage or branding.
22072
+ - When QA catches a mistake, add a rule here so it doesn't happen again.
22073
+
22074
+ ## My rules
22075
+ `;
22076
+ function ensureStudio() {
22077
+ for (const d of Object.values(studio)) mkdirSync4(d(), { recursive: true });
22078
+ const claude = join4(HOME(), "CLAUDE.md");
22079
+ if (!existsSync3(claude)) writeFileSync3(claude, CLAUDE_MD);
22080
+ const oldBrand = join4(HOME(), "brand.json"), newBrand = join4(studio.brand(), "brand.json");
22081
+ if (existsSync3(oldBrand) && !existsSync3(newBrand)) renameSync(oldBrand, newBrand);
22082
+ return HOME();
22083
+ }
22084
+ var inside = (child, parent) => {
22085
+ const r = relative(parent, child);
22086
+ return !!r && !r.startsWith("..") && !isAbsolute(r);
22087
+ };
22088
+ function takeRaw(source, folder) {
22089
+ const raw = join4(folder, "raw");
22090
+ const file = resolve2(source);
22091
+ if (inside(file, raw)) return { file, how: "already here" };
22092
+ mkdirSync4(raw, { recursive: true });
22093
+ let target = join4(raw, basename(file));
22094
+ for (let n = 2; existsSync3(target); n++) target = join4(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
22095
+ if (inside(file, studio.inbox())) {
22096
+ try {
22097
+ renameSync(file, target);
22098
+ return { file: target, how: "moved from inbox" };
22099
+ } catch {
22100
+ }
22101
+ }
22102
+ copyFileSync(file, target);
22103
+ return { file: target, how: "copied" };
22104
+ }
22105
+ function setStatus(folder, status, plan) {
22106
+ const file = join4(folder, "plan.md");
22107
+ const name = basename(folder);
22108
+ const current = existsSync3(file) ? readFileSync4(file, "utf8") : `# ${name}
22109
+
22110
+ status:
22111
+ `;
22112
+ const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
22113
+ status: ${status}
22114
+ `);
22115
+ writeFileSync3(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
22116
+
22117
+ ## Plan
22118
+ ${plan}
22119
+ `);
22120
+ }
22121
+ function startVideoNotes(folder, transcript) {
22122
+ const script = join4(folder, "script.md"), facts = join4(folder, "facts.md");
22123
+ if (!existsSync3(script)) writeFileSync3(script, `# Script
22124
+
22125
+ What I say, word for word (from the footage; fix any misheard words here).
22126
+
22127
+ ${transcript}
22128
+ `);
22129
+ if (!existsSync3(facts)) writeFileSync3(facts, "# Facts\n\nEvery number or claim that shows on screen, with where it comes from.\n\n| on screen | source |\n| - | - |\n");
22130
+ }
22131
+ function addCheck(folder, entry) {
22132
+ const file = join4(folder, "checks.md");
22133
+ if (!existsSync3(file)) writeFileSync3(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
22134
+ appendFileSync(file, `
22135
+ ${entry.trim()}
22136
+ `);
22137
+ }
22138
+
21889
22139
  // src/community/local/render.ts
21890
22140
  var HF = "hyperframes@0.8.99";
21891
22141
  var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
21892
22142
  var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
21893
22143
  var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
21894
22144
  var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
21895
- var projectFile = (folder) => join3(folder, ".cutroom", "project.json");
22145
+ var projectFile = (folder) => join5(folder, ".cutroom", "project.json");
21896
22146
  var resolveProject = (p) => {
21897
- const folder = resolve2(p.replace(/^~/, homedir2()));
21898
- const file = existsSync2(projectFile(folder)) ? projectFile(folder) : existsSync2(join3(HOME(), "renders", p, ".cutroom", "project.json")) ? join3(HOME(), "renders", p, ".cutroom", "project.json") : null;
22147
+ const folder = resolve3(p.replace(/^~/, homedir2()));
22148
+ const file = [folder, join5(studio.videos(), p), join5(HOME(), "renders", p)].map(projectFile).find((f) => existsSync4(f)) ?? null;
21899
22149
  if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
21900
- return JSON.parse(readFileSync3(file, "utf8"));
22150
+ return JSON.parse(readFileSync5(file, "utf8"));
21901
22151
  };
21902
22152
  function cutAndNormalize(src, out, segments) {
21903
22153
  const parts = segments.map(([s, e], i) => `[0:v]trim=${s}:${e},setpts=PTS-STARTPTS[v${i}];[0:a]atrim=${s}:${e},asetpts=PTS-STARTPTS[a${i}];`).join("");
@@ -21912,19 +22162,21 @@ function looksLikeSpeech(words, duration3) {
21912
22162
  return readable.length >= Math.max(3, duration3 * 0.6) && readable.length >= words.length * 0.8;
21913
22163
  }
21914
22164
  async function prepareFootage(input) {
21915
- const source = resolve2(input.video.replace(/^~/, homedir2()));
21916
- if (!existsSync2(source)) throw new Error(`No such file: ${source}`);
22165
+ const source = resolve3(input.video.replace(/^~/, homedir2()));
22166
+ if (!existsSync4(source)) throw new Error(`No such file: ${source}`);
21917
22167
  const p = probe(source);
21918
22168
  if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
21919
22169
  if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
21920
- const name = slug2(input.name ?? basename(source, extname(source)));
21921
- const folder = join3(HOME(), "renders", `${day2()}-${name}`);
21922
- const work = join3(folder, ".cutroom");
21923
- mkdirSync3(work, { recursive: true });
21924
- const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(source), p.duration));
22170
+ const name = slug2(input.name ?? basename2(source, extname(source)));
22171
+ ensureStudio();
22172
+ const folder = join5(studio.videos(), `${day2()}-${name}`);
22173
+ const work = join5(folder, ".cutroom");
22174
+ mkdirSync5(work, { recursive: true });
22175
+ const clip = takeRaw(source, folder);
22176
+ const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
21925
22177
  if (!segments.length) throw new Error("Couldn't find any speech in that video.");
21926
- const footage = join3(work, "footage.mp4");
21927
- cutAndNormalize(source, footage, segments);
22178
+ const footage = join5(work, "footage.mp4");
22179
+ cutAndNormalize(clip.file, footage, segments);
21928
22180
  const duration3 = probe(footage).duration;
21929
22181
  const lang = (input.language ?? "en").toLowerCase().slice(0, 5);
21930
22182
  const t = sh("npx", ["--yes", HF, "transcribe", "footage.mp4", "--json", "-d", ".", ...lang === "en" ? [] : ["--language", lang, "-m", "large-v3"]], work);
@@ -21937,14 +22189,17 @@ async function prepareFootage(input) {
21937
22189
  })();
21938
22190
  if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
21939
22191
  ${meta2.error ?? tail(t.stderr)}`);
21940
- const raw = JSON.parse(readFileSync3(meta2.transcriptPath ?? join3(work, "transcript.json"), "utf8"));
22192
+ const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(work, "transcript.json"), "utf8"));
21941
22193
  const words = raw.map((w) => ({ t: Math.round(w.start * 100) / 100, e: Math.round(w.end * 100) / 100, w: w.text.trim() })).filter((w) => w.w);
21942
22194
  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.`);
21943
- const project = { name, folder, source, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
21944
- writeFileSync2(projectFile(folder), JSON.stringify(project, null, 2));
22195
+ const project = { name, folder, source: clip.file, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
22196
+ writeFileSync4(projectFile(folder), JSON.stringify(project, null, 2));
22197
+ startVideoNotes(folder, words.map((w) => w.w).join(" "));
22198
+ setStatus(folder, "footage ready");
21945
22199
  const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
21946
22200
  return {
21947
22201
  project: folder,
22202
+ raw: `${clip.file} (${clip.how})`,
21948
22203
  duration: project.duration,
21949
22204
  pauses_removed_s: project.removed_s,
21950
22205
  ...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." } : {},
@@ -21953,15 +22208,15 @@ ${meta2.error ?? tail(t.stderr)}`);
21953
22208
  };
21954
22209
  }
21955
22210
  async function ensureFonts(work) {
21956
- const cache = join3(HOME(), ".kit", "fonts");
22211
+ const cache = join5(HOME(), ".kit", "fonts");
21957
22212
  const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
21958
- if (!need.every((f) => existsSync2(join3(cache, f)))) {
22213
+ if (!need.every((f) => existsSync4(join5(cache, f)))) {
21959
22214
  const { fonts } = await call("fonts");
21960
- mkdirSync3(cache, { recursive: true });
21961
- for (const [f, b64] of Object.entries(fonts)) writeFileSync2(join3(cache, basename(f)), Buffer.from(b64, "base64"));
22215
+ mkdirSync5(cache, { recursive: true });
22216
+ for (const [f, b64] of Object.entries(fonts)) writeFileSync4(join5(cache, basename2(f)), Buffer.from(b64, "base64"));
21962
22217
  }
21963
- mkdirSync3(join3(work, "fonts"), { recursive: true });
21964
- for (const f of need) writeFileSync2(join3(work, "fonts", f), readFileSync3(join3(cache, f)));
22218
+ mkdirSync5(join5(work, "fonts"), { recursive: true });
22219
+ for (const f of need) writeFileSync4(join5(work, "fonts", f), readFileSync5(join5(cache, f)));
21965
22220
  }
21966
22221
  function applyFixes(words, fixes = []) {
21967
22222
  return words.map((w) => {
@@ -21969,46 +22224,48 @@ function applyFixes(words, fixes = []) {
21969
22224
  return f ? { ...w, w: f.to } : w;
21970
22225
  }).filter((w) => w.w);
21971
22226
  }
21972
- var brandFile = () => join3(HOME(), "brand.json");
22227
+ var brandFile = () => join5(studio.brand(), "brand.json");
21973
22228
  function getBrand() {
21974
- try {
21975
- return JSON.parse(readFileSync3(brandFile(), "utf8"));
21976
- } catch {
21977
- return null;
22229
+ for (const f of [brandFile(), join5(HOME(), "brand.json")]) {
22230
+ try {
22231
+ return JSON.parse(readFileSync5(f, "utf8"));
22232
+ } catch {
22233
+ }
21978
22234
  }
22235
+ return null;
21979
22236
  }
21980
22237
  async function setBrand(input) {
21981
22238
  if (input.reset) {
21982
- rmSync(brandFile(), { force: true });
22239
+ rmSync2(brandFile(), { force: true });
21983
22240
  return { brand: null, saved: "Back to the default look." };
21984
22241
  }
21985
22242
  const { reset, ...fields } = input;
21986
22243
  const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
21987
22244
  await call("compose", { duration: 2, words: [], brand: next, check_only: true });
21988
- mkdirSync3(HOME(), { recursive: true });
21989
- writeFileSync2(brandFile(), JSON.stringify(next, null, 2));
22245
+ ensureStudio();
22246
+ writeFileSync4(brandFile(), JSON.stringify(next, null, 2));
21990
22247
  return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
21991
22248
  }
21992
22249
  var PICTURE = /\.(jpe?g|png|webp)$/i;
21993
22250
  function safeAssetName(file, i) {
21994
- const m = basename(file).match(PICTURE);
22251
+ const m = basename2(file).match(PICTURE);
21995
22252
  if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
21996
22253
  const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
21997
- return `${i + 1}-${slug2(basename(file, extname(file)))}.${ext}`;
22254
+ return `${i + 1}-${slug2(basename2(file, extname(file)))}.${ext}`;
21998
22255
  }
21999
22256
  function localImages(work, layoutImages, effects) {
22000
22257
  const names = /* @__PURE__ */ new Map();
22001
- const dir = join3(work, "assets");
22258
+ const dir = join5(work, "assets");
22002
22259
  const swap = (v) => {
22003
22260
  const raw = String(v ?? "").trim();
22004
- if (/^assets\/[\w.-]+$/.test(raw) && existsSync2(join3(work, raw))) return raw;
22005
- const file = resolve2(raw.replace(/^~(?=$|\/)/, homedir2()));
22261
+ if (/^assets\/[\w.-]+$/.test(raw) && existsSync4(join5(work, raw))) return raw;
22262
+ const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir2()));
22006
22263
  if (!names.has(file)) {
22007
- if (!existsSync2(file) || !statSync(file).isFile()) throw new Error(`Can't find the picture ${raw}. Use the full path of a jpg, png or webp file on this computer.`);
22264
+ if (!existsSync4(file) || !statSync(file).isFile()) throw new Error(`Can't find the picture ${raw}. Use the full path of a jpg, png or webp file on this computer.`);
22008
22265
  if (names.size >= 8) throw new Error("8 pictures at most per reel.");
22009
22266
  const name = safeAssetName(file, names.size);
22010
- mkdirSync3(dir, { recursive: true });
22011
- copyFileSync(file, join3(dir, name));
22267
+ mkdirSync5(dir, { recursive: true });
22268
+ copyFileSync2(file, join5(dir, name));
22012
22269
  names.set(file, `assets/${name}`);
22013
22270
  }
22014
22271
  return names.get(file);
@@ -22045,40 +22302,40 @@ function headTop(work, duration3) {
22045
22302
  return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
22046
22303
  }
22047
22304
  function ensureCutout(work, duration3) {
22048
- const footage = join3(work, "footage.mp4"), cut = join3(work, "cut.webm"), stamp = join3(work, "cut.json"), still = join3(work, "assets", "still.jpg");
22305
+ const footage = join5(work, "footage.mp4"), cut = join5(work, "cut.webm"), stamp = join5(work, "cut.json"), still = join5(work, "assets", "still.jpg");
22049
22306
  const st = statSync(footage);
22050
22307
  const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
22051
22308
  let cached2 = null;
22052
22309
  try {
22053
- cached2 = JSON.parse(readFileSync3(stamp, "utf8"));
22310
+ cached2 = JSON.parse(readFileSync5(stamp, "utf8"));
22054
22311
  } catch {
22055
22312
  }
22056
22313
  let made_s = 0;
22057
- if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync2(cut))) {
22314
+ if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync4(cut))) {
22058
22315
  const started = Date.now();
22059
22316
  const r = sh("npx", ["--yes", HF, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
22060
- if (r.status !== 0 || !existsSync2(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22317
+ if (r.status !== 0 || !existsSync4(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22061
22318
  ${tail(r.stderr) || tail(r.stdout)}`);
22062
22319
  made_s = Math.round((Date.now() - started) / 100) / 10;
22063
22320
  cached2 = { ...key, head: headTop(work, duration3), made_s };
22064
- writeFileSync2(stamp, JSON.stringify(cached2));
22321
+ writeFileSync4(stamp, JSON.stringify(cached2));
22065
22322
  }
22066
- if (!existsSync2(still)) {
22067
- mkdirSync3(join3(work, "assets"), { recursive: true });
22323
+ if (!existsSync4(still)) {
22324
+ mkdirSync5(join5(work, "assets"), { recursive: true });
22068
22325
  sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
22069
22326
  }
22070
22327
  return { head: cached2.head, made_s };
22071
22328
  }
22072
- var planFile = (folder) => join3(folder, ".cutroom", "plan.json");
22329
+ var planFile = (folder) => join5(folder, ".cutroom", "plan.json");
22073
22330
  async function composition(input) {
22074
22331
  const pj = resolveProject(input.project);
22075
- const work = join3(pj.folder, ".cutroom");
22332
+ const work = join5(pj.folder, ".cutroom");
22076
22333
  const words = applyFixes(pj.words, input.fixes);
22077
22334
  const local = localImages(work, input.images, input.effects ?? []);
22078
22335
  const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
22079
22336
  const { html } = await call("compose", { duration: pj.duration, words, layout: input.layout, caption: input.caption, effects: local.effects, images: local.images, emphasis: input.emphasis, brand: getBrand() ?? void 0, ...cutout ? { cutout: true, head: cutout.head } : {} });
22080
22337
  await ensureFonts(work);
22081
- writeFileSync2(join3(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22338
+ writeFileSync4(join5(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22082
22339
  return { pj, work, html, cutout, words };
22083
22340
  }
22084
22341
  var fmtT = (t) => `${t.toFixed(1)}s`;
@@ -22092,7 +22349,7 @@ async function planReel(input) {
22092
22349
  const at = f.slug === "frame-rip" ? d * 0.5 : Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : 0.55));
22093
22350
  return Math.min(pj.duration - 0.1, Number(f.start) + at);
22094
22351
  };
22095
- const plainAt = effects.length && Number(effects[0].start) > 2 ? 1.2 : null;
22352
+ const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
22096
22353
  const styles = await call("styles");
22097
22354
  const title = (list, slug3) => (list.find((x) => x.slug === slug3)?.title ?? String(slug3)).replace(/\s*\(.*\)$/, "");
22098
22355
  const sentences = words.reduce((out, w, k) => {
@@ -22122,37 +22379,37 @@ async function planReel(input) {
22122
22379
  };
22123
22380
  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)));
22124
22381
  const moments = [
22125
- ...plainAt !== null ? [{ t: 0, end: Number(effects[0].start), what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
22382
+ ...plainAt !== null ? [{ t: 0, end: effects.length ? Number(effects[0].start) : pj.duration, what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
22126
22383
  ...effects.map((f, k) => {
22127
22384
  const name = title(styles.effects, f.slug);
22128
22385
  return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
22129
22386
  })
22130
22387
  ];
22131
- const shots = join3(work, "storyboard");
22132
- rmSync(shots, { recursive: true, force: true });
22133
- writeFileSync2(join3(work, "index.html"), html);
22388
+ const shots = join5(work, "storyboard");
22389
+ rmSync2(shots, { recursive: true, force: true });
22390
+ writeFileSync4(join5(work, "index.html"), html);
22134
22391
  let r;
22135
22392
  try {
22136
22393
  r = sh("npx", ["--yes", HF, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
22137
22394
  } finally {
22138
- rmSync(join3(work, "index.html"), { force: true });
22395
+ rmSync2(join5(work, "index.html"), { force: true });
22139
22396
  }
22140
- const pngs = existsSync2(shots) ? readdirSync(shots).filter((f) => f.endsWith(".png")).sort() : [];
22397
+ const pngs = existsSync4(shots) ? readdirSync(shots).filter((f) => f.endsWith(".png")).sort() : [];
22141
22398
  if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
22142
22399
  ${tail(r.stderr) || tail(r.stdout)}`);
22143
- const board = join3(pj.folder, `${pj.name}-storyboard.jpg`);
22400
+ const board = join5(pj.folder, "storyboard.jpg");
22144
22401
  const cols = Math.min(4, moments.length);
22145
22402
  const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
22146
- const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join3(shots, f)]);
22403
+ const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join5(shots, f)]);
22147
22404
  const cells = moments.map((m, k) => `[${k}:v]scale=270:480,pad=270:540:0:0:#0A0A0A,drawtext=text='${label(`${fmtT(m.t)} ${m.tag}`)}':x=10:y=496:fontsize=18:fontcolor=#F7F3E8[c${k}]`).join(";");
22148
22405
  const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
22149
22406
  const stack = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
22150
22407
  const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
22151
22408
  if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
22152
22409
  ${tail(t.stderr)}`);
22153
- rmSync(shots, { recursive: true, force: true });
22410
+ rmSync2(shots, { recursive: true, force: true });
22154
22411
  const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
22155
- writeFileSync2(planFile(pj.folder), JSON.stringify(plan, null, 2));
22412
+ writeFileSync4(planFile(pj.folder), JSON.stringify(plan, null, 2));
22156
22413
  const text = [
22157
22414
  `Plan for ${pj.name} (${pj.duration.toFixed(1)}s). Layout: ${title(styles.layouts, plan.layout)}. Captions: ${title(styles.captions, plan.caption)}.${cutout ? " You are cut out of the background for this one (done)." : ""}`,
22158
22415
  "",
@@ -22162,38 +22419,41 @@ ${tail(t.stderr)}`);
22162
22419
  "",
22163
22420
  `Storyboard: ${board}`
22164
22421
  ].join("\n");
22422
+ setStatus(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
22165
22423
  return { text, storyboard: board, plan, words_used: words.length, next: "Show this table as it is (plain words, no effect codes) and the storyboard. On changes, plan_reel again with the new choice. On a go, render_reel with plan:true and draft:true." };
22166
22424
  }
22167
22425
  async function renderReel(input) {
22168
22426
  if (input.plan) {
22169
22427
  const folder = resolveProject(input.project).folder;
22170
- if (!existsSync2(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
22171
- input = { ...JSON.parse(readFileSync3(planFile(folder), "utf8")), project: input.project, draft: input.draft };
22428
+ if (!existsSync4(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
22429
+ input = { ...JSON.parse(readFileSync5(planFile(folder), "utf8")), project: input.project, draft: input.draft };
22172
22430
  }
22173
22431
  const { pj, work, html, cutout } = await composition(input);
22174
- const out = join3(pj.folder, `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22175
- const tmp = join3(work, "render.mp4");
22176
- writeFileSync2(join3(work, "index.html"), html);
22432
+ mkdirSync5(join5(pj.folder, "renders"), { recursive: true });
22433
+ const out = join5(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22434
+ const tmp = join5(work, "render.mp4");
22435
+ writeFileSync4(join5(work, "index.html"), html);
22177
22436
  const started = Date.now();
22178
22437
  let r;
22179
22438
  try {
22180
22439
  r = sh("npx", ["--yes", HF, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
22181
22440
  } finally {
22182
- rmSync(join3(work, "index.html"), { force: true });
22441
+ rmSync2(join5(work, "index.html"), { force: true });
22183
22442
  }
22184
- if (r.status !== 0 || !existsSync2(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22443
+ if (r.status !== 0 || !existsSync4(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22185
22444
  ${tail(r.stderr) || tail(r.stdout)}`);
22186
- renameSync(tmp, out);
22445
+ renameSync2(tmp, out);
22187
22446
  const render_s = Math.round((Date.now() - started) / 100) / 10;
22188
- const qa = await qaVideo({ video: out, write_report: true, layout: input.layout });
22447
+ const { report: checks, ...qa } = await qaVideo({ video: out, layout: input.layout });
22448
+ addCheck(pj.folder, checks);
22449
+ setStatus(pj.folder, input.draft ? "preview" : "final");
22189
22450
  return { video: out, render_s, ...cutout?.made_s ? { cutout_s: cutout.made_s } : {}, qa, next: qa.passed ? "Watch it once, then it's ready to post." : qa.technical?.length ? "Fix the technical issues listed, then render again." : "Look at each flagged spot yourself (grab the frame) and tell the user which are real problems, with the fix, and which are the design." };
22190
22451
  }
22191
22452
  async function qaVideo(input) {
22192
- const file = resolve2(input.video.replace(/^~/, homedir2()));
22193
- if (!existsSync2(file)) throw new Error(`No such file: ${file}`);
22453
+ const file = resolve3(input.video.replace(/^~/, homedir2()));
22454
+ if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
22194
22455
  const tech = technicalChecks(file);
22195
- const dir = join3(file, "..", ".cutroom", "qa-frames");
22196
- rmSync(dir, { recursive: true, force: true });
22456
+ const dir = mkdtempSync(join5(tmpdir(), "cutroom-qa-"));
22197
22457
  const { frames } = sampleFrames(file, dir, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
22198
22458
  let flags = [];
22199
22459
  let judged = 0, judge_error = null;
@@ -22204,7 +22464,7 @@ async function qaVideo(input) {
22204
22464
  } catch (e) {
22205
22465
  judge_error = e.message;
22206
22466
  }
22207
- rmSync(dir, { recursive: true, force: true });
22467
+ rmSync2(dir, { recursive: true, force: true });
22208
22468
  const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
22209
22469
  const passed = !tech.issues.length && !flags.length && !judge_error;
22210
22470
  const report = {
@@ -22215,31 +22475,28 @@ async function qaVideo(input) {
22215
22475
  judge_error,
22216
22476
  measured: { duration: Math.round(tech.duration * 10) / 10, size: `${tech.width}x${tech.height}`, lufs: tech.lufs }
22217
22477
  };
22218
- if (input.write_report) {
22219
- writeFileSync2(file.replace(/\.mp4$/i, ".qa.md"), [
22220
- `# QA: ${basename(file)}`,
22221
- "",
22222
- passed ? "Passed." : "Needs a look.",
22223
- "",
22224
- "## Technical",
22225
- ...tech.issues.length ? tech.issues.map((i) => `- ${i}`) : ["- clean"],
22226
- "",
22227
- `## Frames (${judged} judged)`,
22228
- ...judge_error ? [`- not judged: ${judge_error}`] : look.length ? look.map((l) => `- ${l}`) : ["- nothing flagged"],
22229
- ""
22230
- ].join("\n"));
22231
- }
22232
- return report;
22478
+ const md = [
22479
+ `## ${basename2(file)}, ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ")}`,
22480
+ "",
22481
+ passed ? "Passed." : "Needs a look.",
22482
+ "",
22483
+ "Technical:",
22484
+ ...tech.issues.length ? tech.issues.map((i) => `- ${i}`) : ["- clean"],
22485
+ "",
22486
+ `Frames (${judged} judged):`,
22487
+ ...judge_error ? [`- not judged: ${judge_error}`] : look.length ? look.map((l) => `- ${l}`) : ["- nothing flagged"]
22488
+ ].join("\n");
22489
+ return { ...report, report: md };
22233
22490
  }
22234
22491
 
22235
22492
  // src/community/local/server.ts
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);
22493
+ process.env.PATH = (WIN ? [process.env.PATH, join6(process.env.LOCALAPPDATA ?? join6(homedir3(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join6(homedir3(), ".local", "bin")]).filter(Boolean).join(delimiter);
22237
22494
  var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
22238
22495
  var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
22239
22496
  var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
22240
22497
  var INSTRUCTIONS = `Cutroom: short-form video help. Speak plainly; the user is not technical. Never show tool or effect codes; use plain names.
22241
22498
 
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.
22499
+ Setup or any failure: cutroom_doctor, then a short checklist. If they aren't signed in it opens "Sign in with Whop" in their browser and waits: before calling it, tell them a Whop sign-in page will open and to sign in with the Whop account they joined with. Never ask for a key; signing in is the only way in. Wrong account or a membership problem: cutroom_sign_in to sign in again. Missing ffmpeg, whisper or Node.js: ask, then run the fix cutroom_doctor gives, and tell them to restart the Claude app afterwards so it sees the new tools.
22243
22500
 
22244
22501
  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.
22245
22502
 
@@ -22251,13 +22508,17 @@ Before a first render: show_brand; if none, offer set_brand (from colors, a webs
22251
22508
 
22252
22509
  QA flags are spots to check: look at each flagged frame yourself and say which are real problems (with the fix) and which are the design.
22253
22510
 
22254
- Results are saved in ~/Cutroom. Other creators' reels are for study only: never reuse their words, footage or branding.`;
22511
+ Their studio is the Cutroom folder in their home folder (cutroom_doctor returns the path): inbox/ for new raw videos (look there first when they say "my video" without a path), videos/<date>-<topic>/ with everything for one video (raw/, script.md, facts.md, plan.md with its status line, renders/, checks.md), brand/, references/ for breakdowns and teardowns, and CLAUDE.md with their rules. Put every number shown on screen in that video's facts.md with its source; when QA finds a real mistake, offer to add a rule to their CLAUDE.md. To list videos or what's unfinished, read the status lines in videos/*/plan.md. Other creators' reels are for study only: never reuse their words, footage or branding.`;
22255
22512
  await serveStdio(() => {
22256
22513
  const server = new McpServer({ name: "cutroom", version: VERSION }, { instructions: INSTRUCTIONS });
22257
22514
  server.registerTool("cutroom_doctor", {
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.",
22515
+ description: "Check Cutroom is ready: makes their studio folders, checks ffmpeg, whisper and Node.js on this computer, and the membership. If they aren't signed in, opens Sign in with Whop in their browser and waits up to 5 minutes. Returns each check with the exact fix. Run it first, and whenever a tool fails.",
22259
22516
  inputSchema: object({})
22260
22517
  }, async () => safe(() => doctor()));
22518
+ server.registerTool("cutroom_sign_in", {
22519
+ description: "Sign in to Cutroom again with Whop (another Whop account, or a membership that was renewed). Opens the Whop sign-in in their browser and waits up to 5 minutes, then runs the setup check.",
22520
+ inputSchema: object({})
22521
+ }, async () => safe(() => doctor({ fresh: true })));
22261
22522
  server.registerTool("list_methods", {
22262
22523
  description: "List the Cutroom writing methods available to members (hooks, short script, caption and CTA, and how to write up breakdowns and teardowns).",
22263
22524
  inputSchema: object({})
@@ -22280,11 +22541,11 @@ ${m.text}`;
22280
22541
  inputSchema: object({ script: string2().min(1), audience: string2().optional() })
22281
22542
  }, async ({ script, audience }) => safe(() => call("score", { kind: "script", texts: [script], audience })));
22282
22543
  server.registerTool("breakdown_reel", {
22283
- description: "Break down one reel (Instagram link, or a video file on this computer) into a blueprint: hook, pacing, cuts, the look (layouts, caption styles, effects with timestamps) and beats. Saves breakdown.md in ~/Cutroom/breakdowns. Then use get_method('breakdown_writeup'). Study only: never reuse the creator's words, footage or assets.",
22544
+ description: "Break down one reel (Instagram link, or a video file on this computer) into a blueprint: hook, pacing, cuts, the look (layouts, caption styles, effects with timestamps) and beats. Saves breakdown.md in a folder in their studio's references/. Then use get_method('breakdown_writeup'). Study only: never reuse the creator's words, footage or assets.",
22284
22545
  inputSchema: object({ source: string2().min(1).describe("Instagram reel link or a local video file path.") })
22285
22546
  }, async (input) => safe(() => breakdownReel(input)));
22286
22547
  server.registerTool("creator_teardown", {
22287
- description: "Tear down an Instagram creator's latest reels (profile Reels tab; trial reels are not included): their layouts, captions, effects, pacing, hooks and CTA keywords, what their top quarter by plays does differently, and with compare_to (the user's own handle) the gap. Takes a few minutes. Saves teardown.md in ~/Cutroom/teardowns. Then use get_method('teardown_writeup').",
22548
+ description: "Tear down an Instagram creator's latest reels (profile Reels tab; trial reels are not included): their layouts, captions, effects, pacing, hooks and CTA keywords, what their top quarter by plays does differently, and with compare_to (the user's own handle) the gap. Takes a few minutes. Saves teardown.md in a folder in their studio's references/. Then use get_method('teardown_writeup').",
22288
22549
  inputSchema: object({ handle: string2().min(1), limit: number2().int().min(1).max(30).default(12), compare_to: string2().optional() })
22289
22550
  }, async (input) => safe(() => creatorTeardown(input)));
22290
22551
  server.registerTool("list_styles", {
@@ -22308,16 +22569,16 @@ ${m.text}`;
22308
22569
  inputSchema: object({})
22309
22570
  }, async () => safe(() => getBrand() ?? "No brand kit set: renders use the default look. Set one with set_brand."));
22310
22571
  server.registerTool("prepare_footage", {
22311
- description: "Step 1 of finishing a talking-head video on this computer: cuts long pauses, fits it to 1080x1920, and transcribes it with word timings. Returns the project folder and the timed transcript to plan captions and effects from. Takes about a minute.",
22572
+ description: "Step 1 of finishing a talking-head video on this computer: makes the video's folder (videos/<date>-<name>/ in their studio) and puts the clip in its raw/ (moved from inbox/, copied from anywhere else), cuts long pauses, fits it to 1080x1920, and transcribes it with word timings (also saved as script.md). Returns the project folder and the timed transcript to plan captions and effects from. Takes about a minute.",
22312
22573
  inputSchema: object({
22313
- video: string2().min(1).describe("Path to the raw video file on this computer."),
22574
+ video: string2().min(1).describe("Path to the raw video file on this computer (often in their studio's inbox/)."),
22314
22575
  name: string2().optional().describe("Short name for the output, e.g. 'trial-loop'."),
22315
22576
  cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second."),
22316
22577
  language: string2().optional().describe("Spoken language code if not English, e.g. ko, es, ja (uses a larger model, downloaded once).")
22317
22578
  })
22318
22579
  }, async (input) => safe(() => prepareFootage(input)));
22319
22580
  server.registerTool("plan_reel", {
22320
- description: "Step 2: the plan before anything renders. Takes the same choice as render_reel (layout, caption, effects with times, images) and returns the plan as lines plus a storyboard image: one real frame of the user's video per moment, with the effect on it, in their brand. About 10 to 20 seconds (plus the one-time cut-out if the choice needs it). Saves the plan so render_reel with plan:true renders exactly this. Show the storyboard and get a go before rendering.",
22581
+ description: "Step 2: the plan before anything renders. Takes the same choice as render_reel (layout, caption, effects with times, images) and returns the plan as lines plus a storyboard image: one real frame of the user's video per moment, with the effect on it, in their brand. About 10 to 20 seconds (plus the one-time cut-out if the choice needs it). Saves the plan (plan.md in the video's folder, status plan ready) so render_reel with plan:true renders exactly this. Show the storyboard and get a go before rendering.",
22321
22582
  inputSchema: object({
22322
22583
  project: string2().min(1).describe("The project folder returned by prepare_footage."),
22323
22584
  layout: string2().default("full-frame"),
@@ -22332,13 +22593,13 @@ ${m.text}`;
22332
22593
  const r = await planReel(input);
22333
22594
  return { content: [{ type: "text", text: `${r.text}
22334
22595
 
22335
- ${r.next}` }, { type: "image", data: readFileSync4(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
22596
+ ${r.next}` }, { type: "image", data: readFileSync6(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
22336
22597
  } catch (e) {
22337
22598
  return failure2(e);
22338
22599
  }
22339
22600
  });
22340
22601
  server.registerTool("render_reel", {
22341
- description: "Step 3: render the prepared footage in a Cutroom layout with a caption style and effects, on this computer, then QA it (technical checks plus every sampled frame judged). Saves <name>.mp4 and <name>.qa.md in the project folder. Use draft:true for a fast preview. Effect times are seconds in the prepared (trimmed) video. Layouts and effects that cut the speaker out of the background (see needs in list_styles) run a one-time cut-out first: about 5 to 6 seconds per second of footage, reused for later renders of the same project.",
22602
+ description: "Step 3: render the prepared footage in a Cutroom layout with a caption style and effects, on this computer, then QA it (technical checks plus every sampled frame judged). Saves the video in the project's renders/ folder and the QA result in its checks.md, and sets plan.md's status to preview (draft) or final. Use draft:true for a fast preview. Effect times are seconds in the prepared (trimmed) video. Layouts and effects that cut the speaker out of the background (see needs in list_styles) run a one-time cut-out first: about 5 to 6 seconds per second of footage, reused for later renders of the same project.",
22342
22603
  inputSchema: object({
22343
22604
  project: string2().min(1).describe("The project folder returned by prepare_footage."),
22344
22605
  layout: string2().default("full-frame"),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@keepylabs/cutroom",
3
- "version": "0.5.6",
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.",
3
+ "version": "0.7.0",
4
+ "description": "Cutroom for Claude Code: hooks and scripts with scoring, reel breakdowns, creator teardowns, and finishing your own videos with QA. Sign in with your Keepy Labs membership on Whop.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cutroom": "cutroom.mjs"
@@ -11,7 +11,7 @@
11
11
  "README.md"
12
12
  ],
13
13
  "engines": {
14
- "node": ">=20"
14
+ "node": ">=22"
15
15
  },
16
16
  "license": "UNLICENSED",
17
17
  "homepage": "https://keepylabs.com",