@keepylabs/cutroom 0.5.7 → 0.7.1

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 +29 -10
  2. package/cutroom.mjs +436 -182
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -3,25 +3,44 @@
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:
7
-
8
- claude mcp add cutroom -s user -e CUTROOM_KEY=ck_yourkey -- npx -y @keepylabs/cutroom@latest
9
-
10
- Restart Claude Code and say "check Cutroom is ready". It checks ffmpeg and Node and installs them with your OK.
6
+ In your terminal:
7
+
8
+ claude mcp add cutroom -s user -- npx -y @keepylabs/cutroom@latest
9
+
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/ your library: 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
+ references/ what this video copies, with a why-it-works note
24
+ script.md what you say, word for word
25
+ facts.md every number on screen, with its source
26
+ plan.md the edit plan and its status
27
+ renders/ previews and the final export
28
+ checks.md what QA found and what got fixed
11
29
 
12
30
  ## Use
13
31
  - "give me hooks for <topic>"
14
32
  - "write a short script about <idea>"
15
33
  - "break down this reel: <link>"
16
34
  - "tear down @creator and compare to @me"
17
- - "finish this video: ~/Desktop/take3.mov"
35
+ - "finish the video in my inbox"
36
+ - "what videos are unfinished?"
18
37
 
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).
38
+ The first video you finish downloads the free HyperFrames renderer and a speech-to-text model (a few hundred MB, once).
20
39
 
21
40
  ## 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.
41
+ - Claude Code on macOS or Windows, Node 22 or newer, ffmpeg, whisper-cli
42
+ - A Keepy Labs membership on Whop. No other account or API key.
24
43
 
25
44
  ## Privacy
26
- - Your membership key is the only thing we keep about you, to know your membership is active.
45
+ - 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
46
  - 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,12 @@ 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 ? { ffmpeg: "winget install --id Gyan.FFmpeg -e", node: "winget install --id OpenJS.NodeJS.LTS -e", "node-update": "winget upgrade --id OpenJS.NodeJS.LTS -e" }[tool] : { ffmpeg: "brew install ffmpeg", node: "brew install node", "node-update": "brew upgrade node" }[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];
24
29
  var NODE_MIN = 22;
25
30
  function nodeMajor() {
26
31
  const r = spawnSync("node", ["--version"], { encoding: "utf8", shell: WIN });
@@ -3467,7 +3472,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
3467
3472
  var NONE = 0;
3468
3473
  var ASSUMED = 1;
3469
3474
  var PROVEN = 2;
3470
- function isRecursive(inst, stack, resolve3) {
3475
+ function isRecursive(inst, stack, resolve4) {
3471
3476
  const cached2 = recursive.get(inst);
3472
3477
  if (cached2 !== void 0)
3473
3478
  return cached2 ? PROVEN : NONE;
@@ -3477,7 +3482,7 @@ function isRecursive(inst, stack, resolve3) {
3477
3482
  let result2 = NONE;
3478
3483
  const check = (child) => {
3479
3484
  if (result2 !== PROVEN && child?._zod) {
3480
- const answer = isRecursive(child, stack, resolve3);
3485
+ const answer = isRecursive(child, stack, resolve4);
3481
3486
  if (answer > result2)
3482
3487
  result2 = answer;
3483
3488
  }
@@ -3488,7 +3493,7 @@ function isRecursive(inst, stack, resolve3) {
3488
3493
  const desc = Object.getOwnPropertyDescriptor(sh2, key);
3489
3494
  if (spread && !desc.enumerable)
3490
3495
  continue;
3491
- 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;
3492
3497
  if (child > answer)
3493
3498
  answer = child;
3494
3499
  }
@@ -3552,7 +3557,7 @@ function isRecursive(inst, stack, resolve3) {
3552
3557
  break;
3553
3558
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
3554
3559
  case "lazy": {
3555
- const inner = def._cachedInner ?? (resolve3 ? inst._zod.innerType : void 0);
3560
+ const inner = def._cachedInner ?? (resolve4 ? inst._zod.innerType : void 0);
3556
3561
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
3557
3562
  break;
3558
3563
  }
@@ -11244,14 +11249,14 @@ function inputRequiredRoundsExceededMessage(method, maxRounds) {
11244
11249
  return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
11245
11250
  }
11246
11251
  function sleep(ms, signal) {
11247
- return new Promise((resolve3, reject) => {
11252
+ return new Promise((resolve4, reject) => {
11248
11253
  if (signal?.aborted) {
11249
11254
  reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason)));
11250
11255
  return;
11251
11256
  }
11252
11257
  const timer = setTimeout(() => {
11253
11258
  signal?.removeEventListener("abort", onAbort);
11254
- resolve3();
11259
+ resolve4();
11255
11260
  }, ms);
11256
11261
  const onAbort = () => {
11257
11262
  clearTimeout(timer);
@@ -12045,7 +12050,7 @@ var Protocol = class {
12045
12050
  const flowStartedAt = Date.now();
12046
12051
  let onAbort;
12047
12052
  let cleanupMessageId;
12048
- return new Promise((resolve3, reject) => {
12053
+ return new Promise((resolve4, reject) => {
12049
12054
  const earlyReject = (error2) => {
12050
12055
  reject(error2);
12051
12056
  };
@@ -12114,7 +12119,7 @@ var Protocol = class {
12114
12119
  }
12115
12120
  if (decoded.kind === "invalid") return reject(decoded.error);
12116
12121
  if (decoded.kind === "input_required") {
12117
- if (options?.allowInputRequired === true) return resolve3(manualInputRequiredValue(decoded));
12122
+ if (options?.allowInputRequired === true) return resolve4(manualInputRequiredValue(decoded));
12118
12123
  const flow = {
12119
12124
  codec,
12120
12125
  request,
@@ -12126,11 +12131,11 @@ var Protocol = class {
12126
12131
  params
12127
12132
  }, resultSchema, legOptions)
12128
12133
  };
12129
- return resolve3(this._resolveNonCompleteResult(decoded, flow));
12134
+ return resolve4(this._resolveNonCompleteResult(decoded, flow));
12130
12135
  }
12131
12136
  const result2 = decoded.result;
12132
12137
  validateStandardSchema(resultSchema, result2).then((parseResult) => {
12133
- if (parseResult.success) resolve3(parseResult.data);
12138
+ if (parseResult.success) resolve4(parseResult.data);
12134
12139
  else reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`));
12135
12140
  }, reject);
12136
12141
  });
@@ -15042,7 +15047,7 @@ var require_compile = /* @__PURE__ */ __commonJSMin(((exports) => {
15042
15047
  ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, ref);
15043
15048
  const schOrFunc = root.refs[ref];
15044
15049
  if (schOrFunc) return schOrFunc;
15045
- let _sch = resolve3.call(this, root, ref);
15050
+ let _sch = resolve4.call(this, root, ref);
15046
15051
  if (_sch === void 0) {
15047
15052
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
15048
15053
  const { schemaId } = this.opts;
@@ -15068,7 +15073,7 @@ var require_compile = /* @__PURE__ */ __commonJSMin(((exports) => {
15068
15073
  function sameSchemaEnv(s1, s2) {
15069
15074
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
15070
15075
  }
15071
- function resolve3(root, ref) {
15076
+ function resolve4(root, ref) {
15072
15077
  let sch;
15073
15078
  while (typeof (sch = this.refs[ref]) == "string") ref = sch;
15074
15079
  return sch || this.schemas[ref] || resolveSchema.call(this, root, ref);
@@ -15518,47 +15523,47 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
15518
15523
  else if (typeof uri === "object") uri = parse2(serialize(uri, options), options);
15519
15524
  return uri;
15520
15525
  }
15521
- function resolve3(baseURI, relativeURI, options) {
15526
+ function resolve4(baseURI, relativeURI, options) {
15522
15527
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
15523
15528
  const resolved = resolveComponent(parse2(baseURI, schemelessOptions), parse2(relativeURI, schemelessOptions), schemelessOptions, true);
15524
15529
  schemelessOptions.skipEscape = true;
15525
15530
  return serialize(resolved, schemelessOptions);
15526
15531
  }
15527
- function resolveComponent(base, relative, options, skipNormalization) {
15532
+ function resolveComponent(base, relative2, options, skipNormalization) {
15528
15533
  const target = {};
15529
15534
  if (!skipNormalization) {
15530
15535
  base = parse2(serialize(base, options), options);
15531
- relative = parse2(serialize(relative, options), options);
15536
+ relative2 = parse2(serialize(relative2, options), options);
15532
15537
  }
15533
15538
  options = options || {};
15534
- if (!options.tolerant && relative.scheme) {
15535
- target.scheme = relative.scheme;
15536
- target.userinfo = relative.userinfo;
15537
- target.host = relative.host;
15538
- target.port = relative.port;
15539
- target.path = removeDotSegments(relative.path || "");
15540
- 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;
15541
15546
  } else {
15542
- if (relative.userinfo !== void 0 || relative.host !== void 0 || relative.port !== void 0) {
15543
- target.userinfo = relative.userinfo;
15544
- target.host = relative.host;
15545
- target.port = relative.port;
15546
- target.path = removeDotSegments(relative.path || "");
15547
- 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;
15548
15553
  } else {
15549
- if (!relative.path) {
15554
+ if (!relative2.path) {
15550
15555
  target.path = base.path;
15551
- if (relative.query !== void 0) target.query = relative.query;
15556
+ if (relative2.query !== void 0) target.query = relative2.query;
15552
15557
  else target.query = base.query;
15553
15558
  } else {
15554
- if (relative.path[0] === "/") target.path = removeDotSegments(relative.path);
15559
+ if (relative2.path[0] === "/") target.path = removeDotSegments(relative2.path);
15555
15560
  else {
15556
- if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) target.path = "/" + relative.path;
15557
- else if (!base.path) target.path = relative.path;
15558
- 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;
15559
15564
  target.path = removeDotSegments(target.path);
15560
15565
  }
15561
- target.query = relative.query;
15566
+ target.query = relative2.query;
15562
15567
  }
15563
15568
  target.userinfo = base.userinfo;
15564
15569
  target.host = base.host;
@@ -15566,7 +15571,7 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
15566
15571
  }
15567
15572
  target.scheme = base.scheme;
15568
15573
  }
15569
- target.fragment = relative.fragment;
15574
+ target.fragment = relative2.fragment;
15570
15575
  return target;
15571
15576
  }
15572
15577
  function equal(uriA, uriB, options) {
@@ -15692,7 +15697,7 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
15692
15697
  const fastUri = {
15693
15698
  SCHEMES,
15694
15699
  normalize,
15695
- resolve: resolve3,
15700
+ resolve: resolve4,
15696
15701
  resolveComponent,
15697
15702
  equal,
15698
15703
  serialize,
@@ -21125,7 +21130,7 @@ var StdioServerTransport = class {
21125
21130
  }
21126
21131
  send(message) {
21127
21132
  if (this._closed) return Promise.reject(/* @__PURE__ */ new Error("StdioServerTransport is closed"));
21128
- return new Promise((resolve3, reject) => {
21133
+ return new Promise((resolve4, reject) => {
21129
21134
  const json = serializeMessage(message);
21130
21135
  let settled = false;
21131
21136
  const onError = (error2) => {
@@ -21140,14 +21145,14 @@ var StdioServerTransport = class {
21140
21145
  settled = true;
21141
21146
  this._stdout.off("error", onError);
21142
21147
  this._stdout.off("drain", onDrain);
21143
- resolve3();
21148
+ resolve4();
21144
21149
  };
21145
21150
  this._stdout.once("error", onError);
21146
21151
  if (this._stdout.write(json)) {
21147
21152
  if (settled) return;
21148
21153
  settled = true;
21149
21154
  this._stdout.off("error", onError);
21150
- resolve3();
21155
+ resolve4();
21151
21156
  } else if (!settled) this._stdout.once("drain", onDrain);
21152
21157
  });
21153
21158
  }
@@ -21201,14 +21206,14 @@ var StdioConnectionChannel = class {
21201
21206
  */
21202
21207
  async whenRequestsAnswered(timeoutMs) {
21203
21208
  if (this._closed || this._pendingRequests.size === 0) return true;
21204
- return await new Promise((resolve3) => {
21209
+ return await new Promise((resolve4) => {
21205
21210
  const waiter = () => {
21206
21211
  clearTimeout(timer);
21207
- resolve3(true);
21212
+ resolve4(true);
21208
21213
  };
21209
21214
  const timer = setTimeout(() => {
21210
21215
  this._drainWaiters = this._drainWaiters.filter((pending) => pending !== waiter);
21211
- resolve3(false);
21216
+ resolve4(false);
21212
21217
  }, timeoutMs);
21213
21218
  this._drainWaiters.push(waiter);
21214
21219
  });
@@ -21550,13 +21555,13 @@ function toError(value) {
21550
21555
  }
21551
21556
 
21552
21557
  // src/community/local/server.ts
21553
- import { readFileSync as readFileSync4 } from "node:fs";
21558
+ import { readFileSync as readFileSync6 } from "node:fs";
21554
21559
 
21555
21560
  // src/community/local/render.ts
21556
21561
  import { spawnSync as spawnSync3 } from "node:child_process";
21557
- import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, renameSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
21558
- import { homedir as homedir2 } from "node:os";
21559
- 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";
21560
21565
 
21561
21566
  // src/community/local/measure.ts
21562
21567
  import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
@@ -21652,18 +21657,143 @@ function technicalChecks(file) {
21652
21657
  return { ...p, lufs, black, freeze, silences, issues };
21653
21658
  }
21654
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
+
21655
21664
  // src/community/local/tools.ts
21656
- 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";
21657
21666
  import { homedir } from "node:os";
21658
- import { join as join2, resolve } from "node:path";
21667
+ import { join as join3, resolve } from "node:path";
21659
21668
  import { Readable } from "node:stream";
21660
21669
  import { pipeline } from "node:stream/promises";
21661
- var VERSION = "0.5.7";
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.1";
21662
21792
  var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
21663
- var HOME = () => process.env.CUTROOM_HOME || join2(homedir(), "Cutroom");
21793
+ var HOME = () => process.env.CUTROOM_HOME || join3(homedir(), "Cutroom");
21664
21794
  async function call(op, body = {}, fetchImpl = fetch) {
21665
- const key = process.env.CUTROOM_KEY;
21666
- 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 });
21667
21797
  const send = () => fetchImpl(`${GATEWAY()}/v1/${op}`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify(body) });
21668
21798
  let res;
21669
21799
  try {
@@ -21677,7 +21807,7 @@ async function call(op, body = {}, fetchImpl = fetch) {
21677
21807
  }
21678
21808
  }
21679
21809
  const json = await res.json().catch(() => ({}));
21680
- 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 });
21681
21811
  return json;
21682
21812
  }
21683
21813
  var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
@@ -21689,23 +21819,39 @@ async function download(url2, target) {
21689
21819
  if (!res.ok || !res.body) throw new Error(`Couldn't download the video (HTTP ${res.status}).`);
21690
21820
  await pipeline(Readable.fromWeb(res.body), createWriteStream(target));
21691
21821
  }
21692
- async function doctor() {
21822
+ async function doctor(opts = {}) {
21823
+ const studioFolder = ensureStudio();
21693
21824
  const checks = [
21694
21825
  { name: "ffmpeg", ok: has("ffmpeg") && has("ffprobe"), fix: installLine("ffmpeg") },
21826
+ { name: "whisper (captions)", ok: has("whisper-cli"), fix: installLine("whisper") },
21695
21827
  (() => {
21696
21828
  const v = has("npx") ? nodeMajor() : 0;
21697
21829
  if (!v) return { name: "Node.js (for rendering)", ok: false, fix: installLine("node") };
21698
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") };
21699
21831
  })()
21700
21832
  ];
21701
- let member = null;
21833
+ let member = null, signed_in_now = false;
21702
21834
  try {
21703
- member = await call("me");
21704
- 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 });
21705
21851
  } catch (e) {
21706
- 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 });
21707
21853
  }
21708
- 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 };
21709
21855
  }
21710
21856
  function approxWords(text, duration3) {
21711
21857
  const words = (text ?? "").split(/\s+/).filter(Boolean);
@@ -21724,7 +21870,7 @@ function localWords(file, dir) {
21724
21870
  try {
21725
21871
  const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
21726
21872
  if (r.status !== 0 || !meta2.ok) return null;
21727
- 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"));
21728
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);
21729
21875
  return words.length ? words : null;
21730
21876
  } catch {
@@ -21748,18 +21894,18 @@ async function breakdownReel(input) {
21748
21894
  const r = await call("reel", { url: src });
21749
21895
  meta2 = r.meta;
21750
21896
  transcript = r.transcript;
21751
- const dir2 = join2(HOME(), "breakdowns", `${day()}-${slug(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
21752
- mkdirSync2(dir2, { recursive: true });
21753
- file = join2(dir2, "source.mp4");
21754
- 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);
21755
21901
  } else {
21756
21902
  file = resolve(src.replace(/^~/, homedir()));
21757
- if (!existsSync(file)) throw new Error(`No such file: ${file}`);
21903
+ if (!existsSync2(file)) throw new Error(`No such file: ${file}`);
21758
21904
  }
21759
- const dir = /^https?:/i.test(src) ? join2(file, "..") : join2(HOME(), "breakdowns", `${day()}-${slug(file.split("/").pop() ?? "video")}`);
21760
- 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 });
21761
21907
  const m = measure(file);
21762
- 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);
21763
21909
  const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
21764
21910
  const timed = localWords(file, dir);
21765
21911
  const words = timed ?? approxWords(transcript, m.duration);
@@ -21799,9 +21945,9 @@ async function breakdownReel(input) {
21799
21945
  transcript ?? "(none)",
21800
21946
  ""
21801
21947
  ].join("\n");
21802
- writeFileSync(join2(dir, "breakdown.md"), md);
21803
- 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." };
21804
- 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));
21805
21951
  return out;
21806
21952
  }
21807
21953
  function ctaKeyword(caption) {
@@ -21810,15 +21956,15 @@ function ctaKeyword(caption) {
21810
21956
  }
21811
21957
  async function teardownOne(handle, limit) {
21812
21958
  const { reels } = await call("reels", { handle, limit });
21813
- const dir = join2(HOME(), "teardowns", `${day()}-${slug(handle)}`);
21814
- mkdirSync2(join2(dir, "raw"), { recursive: true });
21959
+ const dir = join3(studio.references(), `${day()}-${slug(handle)}-creator`);
21960
+ mkdirSync3(join3(dir, "raw"), { recursive: true });
21815
21961
  const rows = [];
21816
21962
  for (const [i, r] of reels.entries()) {
21817
21963
  try {
21818
- const file = join2(dir, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
21819
- 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);
21820
21966
  const m = measure(file);
21821
- 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);
21822
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 }))]);
21823
21969
  const top = (o) => Object.keys(o)[0] ?? null;
21824
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) });
@@ -21890,24 +22036,121 @@ async function creatorTeardown(input) {
21890
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)} |`),
21891
22037
  ""
21892
22038
  ].join("\n");
21893
- writeFileSync(join2(a.dir, "teardown.md"), md);
21894
- 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." };
21895
- 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));
21896
22042
  return out;
21897
22043
  }
21898
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
+ - references/: only what this video copies (Pinterest images, the reel it matches), each with a one-line "why it works" in references/notes.md
22061
+ - script.md: what I say, word for word
22062
+ - facts.md: every number or claim on screen, with its source
22063
+ - plan.md: the edit plan and a status line (footage ready, plan ready, preview, final, posted)
22064
+ - renders/: previews and the final export
22065
+ - checks.md: what QA found and what got fixed
22066
+ - brand/: my colors (brand.json), logo and a few frames I love.
22067
+ - references/: my library of reels and creators I study across videos, each with a breakdown of why it works.
22068
+
22069
+ ## Rules
22070
+ - Three stops: I approve the script, then the plan, then the preview. Nothing final renders before that.
22071
+ - Never invent a number. Every figure on screen goes in facts.md with its source.
22072
+ - Other creators' reels are for study only: never reuse their words, footage or branding.
22073
+ - When QA catches a mistake, add a rule here so it doesn't happen again.
22074
+
22075
+ ## My rules
22076
+ `;
22077
+ function ensureStudio() {
22078
+ for (const d of Object.values(studio)) mkdirSync4(d(), { recursive: true });
22079
+ const claude = join4(HOME(), "CLAUDE.md");
22080
+ if (!existsSync3(claude)) writeFileSync3(claude, CLAUDE_MD);
22081
+ const oldBrand = join4(HOME(), "brand.json"), newBrand = join4(studio.brand(), "brand.json");
22082
+ if (existsSync3(oldBrand) && !existsSync3(newBrand)) renameSync(oldBrand, newBrand);
22083
+ return HOME();
22084
+ }
22085
+ var inside = (child, parent) => {
22086
+ const r = relative(parent, child);
22087
+ return !!r && !r.startsWith("..") && !isAbsolute(r);
22088
+ };
22089
+ function takeRaw(source, folder) {
22090
+ const raw = join4(folder, "raw");
22091
+ const file = resolve2(source);
22092
+ if (inside(file, raw)) return { file, how: "already here" };
22093
+ mkdirSync4(raw, { recursive: true });
22094
+ let target = join4(raw, basename(file));
22095
+ for (let n = 2; existsSync3(target); n++) target = join4(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
22096
+ if (inside(file, studio.inbox())) {
22097
+ try {
22098
+ renameSync(file, target);
22099
+ return { file: target, how: "moved from inbox" };
22100
+ } catch {
22101
+ }
22102
+ }
22103
+ copyFileSync(file, target);
22104
+ return { file: target, how: "copied" };
22105
+ }
22106
+ function setStatus(folder, status, plan) {
22107
+ const file = join4(folder, "plan.md");
22108
+ const name = basename(folder);
22109
+ const current = existsSync3(file) ? readFileSync4(file, "utf8") : `# ${name}
22110
+
22111
+ status:
22112
+ `;
22113
+ const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
22114
+ status: ${status}
22115
+ `);
22116
+ writeFileSync3(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
22117
+
22118
+ ## Plan
22119
+ ${plan}
22120
+ `);
22121
+ }
22122
+ function startVideoNotes(folder, transcript) {
22123
+ const script = join4(folder, "script.md"), facts = join4(folder, "facts.md"), notes = join4(folder, "references", "notes.md");
22124
+ mkdirSync4(join4(folder, "references"), { recursive: true });
22125
+ if (!existsSync3(notes)) writeFileSync3(notes, "# References for this video\n\nWhat this video copies and why it works, one line each.\n\n");
22126
+ if (!existsSync3(script)) writeFileSync3(script, `# Script
22127
+
22128
+ What I say, word for word (from the footage; fix any misheard words here).
22129
+
22130
+ ${transcript}
22131
+ `);
22132
+ 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");
22133
+ }
22134
+ function addCheck(folder, entry) {
22135
+ const file = join4(folder, "checks.md");
22136
+ if (!existsSync3(file)) writeFileSync3(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
22137
+ appendFileSync(file, `
22138
+ ${entry.trim()}
22139
+ `);
22140
+ }
22141
+
21899
22142
  // src/community/local/render.ts
21900
22143
  var HF = "hyperframes@0.8.99";
21901
22144
  var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
21902
22145
  var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
21903
22146
  var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
21904
22147
  var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
21905
- var projectFile = (folder) => join3(folder, ".cutroom", "project.json");
22148
+ var projectFile = (folder) => join5(folder, ".cutroom", "project.json");
21906
22149
  var resolveProject = (p) => {
21907
- const folder = resolve2(p.replace(/^~/, homedir2()));
21908
- const file = existsSync2(projectFile(folder)) ? projectFile(folder) : existsSync2(join3(HOME(), "renders", p, ".cutroom", "project.json")) ? join3(HOME(), "renders", p, ".cutroom", "project.json") : null;
22150
+ const folder = resolve3(p.replace(/^~/, homedir2()));
22151
+ const file = [folder, join5(studio.videos(), p), join5(HOME(), "renders", p)].map(projectFile).find((f) => existsSync4(f)) ?? null;
21909
22152
  if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
21910
- return JSON.parse(readFileSync3(file, "utf8"));
22153
+ return JSON.parse(readFileSync5(file, "utf8"));
21911
22154
  };
21912
22155
  function cutAndNormalize(src, out, segments) {
21913
22156
  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("");
@@ -21922,19 +22165,21 @@ function looksLikeSpeech(words, duration3) {
21922
22165
  return readable.length >= Math.max(3, duration3 * 0.6) && readable.length >= words.length * 0.8;
21923
22166
  }
21924
22167
  async function prepareFootage(input) {
21925
- const source = resolve2(input.video.replace(/^~/, homedir2()));
21926
- if (!existsSync2(source)) throw new Error(`No such file: ${source}`);
22168
+ const source = resolve3(input.video.replace(/^~/, homedir2()));
22169
+ if (!existsSync4(source)) throw new Error(`No such file: ${source}`);
21927
22170
  const p = probe(source);
21928
22171
  if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
21929
22172
  if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
21930
- const name = slug2(input.name ?? basename(source, extname(source)));
21931
- const folder = join3(HOME(), "renders", `${day2()}-${name}`);
21932
- const work = join3(folder, ".cutroom");
21933
- mkdirSync3(work, { recursive: true });
21934
- const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(source), p.duration));
22173
+ const name = slug2(input.name ?? basename2(source, extname(source)));
22174
+ ensureStudio();
22175
+ const folder = join5(studio.videos(), `${day2()}-${name}`);
22176
+ const work = join5(folder, ".cutroom");
22177
+ mkdirSync5(work, { recursive: true });
22178
+ const clip = takeRaw(source, folder);
22179
+ const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
21935
22180
  if (!segments.length) throw new Error("Couldn't find any speech in that video.");
21936
- const footage = join3(work, "footage.mp4");
21937
- cutAndNormalize(source, footage, segments);
22181
+ const footage = join5(work, "footage.mp4");
22182
+ cutAndNormalize(clip.file, footage, segments);
21938
22183
  const duration3 = probe(footage).duration;
21939
22184
  const lang = (input.language ?? "en").toLowerCase().slice(0, 5);
21940
22185
  const t = sh("npx", ["--yes", HF, "transcribe", "footage.mp4", "--json", "-d", ".", ...lang === "en" ? [] : ["--language", lang, "-m", "large-v3"]], work);
@@ -21947,31 +22192,34 @@ async function prepareFootage(input) {
21947
22192
  })();
21948
22193
  if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
21949
22194
  ${meta2.error ?? tail(t.stderr)}`);
21950
- const raw = JSON.parse(readFileSync3(meta2.transcriptPath ?? join3(work, "transcript.json"), "utf8"));
22195
+ const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(work, "transcript.json"), "utf8"));
21951
22196
  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);
21952
22197
  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.`);
21953
- const project = { name, folder, source, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
21954
- writeFileSync2(projectFile(folder), JSON.stringify(project, null, 2));
22198
+ 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 };
22199
+ writeFileSync4(projectFile(folder), JSON.stringify(project, null, 2));
22200
+ startVideoNotes(folder, words.map((w) => w.w).join(" "));
22201
+ setStatus(folder, "footage ready");
21955
22202
  const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
21956
22203
  return {
21957
22204
  project: folder,
22205
+ raw: `${clip.file} (${clip.how})`,
21958
22206
  duration: project.duration,
21959
22207
  pauses_removed_s: project.removed_s,
21960
22208
  ...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." } : {},
21961
22209
  transcript: words.map((w) => `${w.t.toFixed(2)} ${w.w}`).join("\n"),
21962
- 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."
22210
+ next: "Check the transcript for misheard words (pass them as fixes). References for this video go in its references/ folder. 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."
21963
22211
  };
21964
22212
  }
21965
22213
  async function ensureFonts(work) {
21966
- const cache = join3(HOME(), ".kit", "fonts");
22214
+ const cache = join5(HOME(), ".kit", "fonts");
21967
22215
  const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
21968
- if (!need.every((f) => existsSync2(join3(cache, f)))) {
22216
+ if (!need.every((f) => existsSync4(join5(cache, f)))) {
21969
22217
  const { fonts } = await call("fonts");
21970
- mkdirSync3(cache, { recursive: true });
21971
- for (const [f, b64] of Object.entries(fonts)) writeFileSync2(join3(cache, basename(f)), Buffer.from(b64, "base64"));
22218
+ mkdirSync5(cache, { recursive: true });
22219
+ for (const [f, b64] of Object.entries(fonts)) writeFileSync4(join5(cache, basename2(f)), Buffer.from(b64, "base64"));
21972
22220
  }
21973
- mkdirSync3(join3(work, "fonts"), { recursive: true });
21974
- for (const f of need) writeFileSync2(join3(work, "fonts", f), readFileSync3(join3(cache, f)));
22221
+ mkdirSync5(join5(work, "fonts"), { recursive: true });
22222
+ for (const f of need) writeFileSync4(join5(work, "fonts", f), readFileSync5(join5(cache, f)));
21975
22223
  }
21976
22224
  function applyFixes(words, fixes = []) {
21977
22225
  return words.map((w) => {
@@ -21979,46 +22227,48 @@ function applyFixes(words, fixes = []) {
21979
22227
  return f ? { ...w, w: f.to } : w;
21980
22228
  }).filter((w) => w.w);
21981
22229
  }
21982
- var brandFile = () => join3(HOME(), "brand.json");
22230
+ var brandFile = () => join5(studio.brand(), "brand.json");
21983
22231
  function getBrand() {
21984
- try {
21985
- return JSON.parse(readFileSync3(brandFile(), "utf8"));
21986
- } catch {
21987
- return null;
22232
+ for (const f of [brandFile(), join5(HOME(), "brand.json")]) {
22233
+ try {
22234
+ return JSON.parse(readFileSync5(f, "utf8"));
22235
+ } catch {
22236
+ }
21988
22237
  }
22238
+ return null;
21989
22239
  }
21990
22240
  async function setBrand(input) {
21991
22241
  if (input.reset) {
21992
- rmSync(brandFile(), { force: true });
22242
+ rmSync2(brandFile(), { force: true });
21993
22243
  return { brand: null, saved: "Back to the default look." };
21994
22244
  }
21995
22245
  const { reset, ...fields } = input;
21996
22246
  const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
21997
22247
  await call("compose", { duration: 2, words: [], brand: next, check_only: true });
21998
- mkdirSync3(HOME(), { recursive: true });
21999
- writeFileSync2(brandFile(), JSON.stringify(next, null, 2));
22248
+ ensureStudio();
22249
+ writeFileSync4(brandFile(), JSON.stringify(next, null, 2));
22000
22250
  return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
22001
22251
  }
22002
22252
  var PICTURE = /\.(jpe?g|png|webp)$/i;
22003
22253
  function safeAssetName(file, i) {
22004
- const m = basename(file).match(PICTURE);
22254
+ const m = basename2(file).match(PICTURE);
22005
22255
  if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
22006
22256
  const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
22007
- return `${i + 1}-${slug2(basename(file, extname(file)))}.${ext}`;
22257
+ return `${i + 1}-${slug2(basename2(file, extname(file)))}.${ext}`;
22008
22258
  }
22009
22259
  function localImages(work, layoutImages, effects) {
22010
22260
  const names = /* @__PURE__ */ new Map();
22011
- const dir = join3(work, "assets");
22261
+ const dir = join5(work, "assets");
22012
22262
  const swap = (v) => {
22013
22263
  const raw = String(v ?? "").trim();
22014
- if (/^assets\/[\w.-]+$/.test(raw) && existsSync2(join3(work, raw))) return raw;
22015
- const file = resolve2(raw.replace(/^~(?=$|\/)/, homedir2()));
22264
+ if (/^assets\/[\w.-]+$/.test(raw) && existsSync4(join5(work, raw))) return raw;
22265
+ const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir2()));
22016
22266
  if (!names.has(file)) {
22017
- 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.`);
22267
+ 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.`);
22018
22268
  if (names.size >= 8) throw new Error("8 pictures at most per reel.");
22019
22269
  const name = safeAssetName(file, names.size);
22020
- mkdirSync3(dir, { recursive: true });
22021
- copyFileSync(file, join3(dir, name));
22270
+ mkdirSync5(dir, { recursive: true });
22271
+ copyFileSync2(file, join5(dir, name));
22022
22272
  names.set(file, `assets/${name}`);
22023
22273
  }
22024
22274
  return names.get(file);
@@ -22055,40 +22305,40 @@ function headTop(work, duration3) {
22055
22305
  return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
22056
22306
  }
22057
22307
  function ensureCutout(work, duration3) {
22058
- const footage = join3(work, "footage.mp4"), cut = join3(work, "cut.webm"), stamp = join3(work, "cut.json"), still = join3(work, "assets", "still.jpg");
22308
+ const footage = join5(work, "footage.mp4"), cut = join5(work, "cut.webm"), stamp = join5(work, "cut.json"), still = join5(work, "assets", "still.jpg");
22059
22309
  const st = statSync(footage);
22060
22310
  const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
22061
22311
  let cached2 = null;
22062
22312
  try {
22063
- cached2 = JSON.parse(readFileSync3(stamp, "utf8"));
22313
+ cached2 = JSON.parse(readFileSync5(stamp, "utf8"));
22064
22314
  } catch {
22065
22315
  }
22066
22316
  let made_s = 0;
22067
- if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync2(cut))) {
22317
+ if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync4(cut))) {
22068
22318
  const started = Date.now();
22069
22319
  const r = sh("npx", ["--yes", HF, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
22070
- if (r.status !== 0 || !existsSync2(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22320
+ if (r.status !== 0 || !existsSync4(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22071
22321
  ${tail(r.stderr) || tail(r.stdout)}`);
22072
22322
  made_s = Math.round((Date.now() - started) / 100) / 10;
22073
22323
  cached2 = { ...key, head: headTop(work, duration3), made_s };
22074
- writeFileSync2(stamp, JSON.stringify(cached2));
22324
+ writeFileSync4(stamp, JSON.stringify(cached2));
22075
22325
  }
22076
- if (!existsSync2(still)) {
22077
- mkdirSync3(join3(work, "assets"), { recursive: true });
22326
+ if (!existsSync4(still)) {
22327
+ mkdirSync5(join5(work, "assets"), { recursive: true });
22078
22328
  sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
22079
22329
  }
22080
22330
  return { head: cached2.head, made_s };
22081
22331
  }
22082
- var planFile = (folder) => join3(folder, ".cutroom", "plan.json");
22332
+ var planFile = (folder) => join5(folder, ".cutroom", "plan.json");
22083
22333
  async function composition(input) {
22084
22334
  const pj = resolveProject(input.project);
22085
- const work = join3(pj.folder, ".cutroom");
22335
+ const work = join5(pj.folder, ".cutroom");
22086
22336
  const words = applyFixes(pj.words, input.fixes);
22087
22337
  const local = localImages(work, input.images, input.effects ?? []);
22088
22338
  const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
22089
22339
  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 } : {} });
22090
22340
  await ensureFonts(work);
22091
- writeFileSync2(join3(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22341
+ writeFileSync4(join5(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22092
22342
  return { pj, work, html, cutout, words };
22093
22343
  }
22094
22344
  var fmtT = (t) => `${t.toFixed(1)}s`;
@@ -22102,7 +22352,7 @@ async function planReel(input) {
22102
22352
  const at = f.slug === "frame-rip" ? d * 0.5 : Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : 0.55));
22103
22353
  return Math.min(pj.duration - 0.1, Number(f.start) + at);
22104
22354
  };
22105
- const plainAt = effects.length && Number(effects[0].start) > 2 ? 1.2 : null;
22355
+ const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
22106
22356
  const styles = await call("styles");
22107
22357
  const title = (list, slug3) => (list.find((x) => x.slug === slug3)?.title ?? String(slug3)).replace(/\s*\(.*\)$/, "");
22108
22358
  const sentences = words.reduce((out, w, k) => {
@@ -22132,37 +22382,37 @@ async function planReel(input) {
22132
22382
  };
22133
22383
  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)));
22134
22384
  const moments = [
22135
- ...plainAt !== null ? [{ t: 0, end: Number(effects[0].start), what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
22385
+ ...plainAt !== null ? [{ t: 0, end: effects.length ? Number(effects[0].start) : pj.duration, what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
22136
22386
  ...effects.map((f, k) => {
22137
22387
  const name = title(styles.effects, f.slug);
22138
22388
  return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
22139
22389
  })
22140
22390
  ];
22141
- const shots = join3(work, "storyboard");
22142
- rmSync(shots, { recursive: true, force: true });
22143
- writeFileSync2(join3(work, "index.html"), html);
22391
+ const shots = join5(work, "storyboard");
22392
+ rmSync2(shots, { recursive: true, force: true });
22393
+ writeFileSync4(join5(work, "index.html"), html);
22144
22394
  let r;
22145
22395
  try {
22146
22396
  r = sh("npx", ["--yes", HF, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
22147
22397
  } finally {
22148
- rmSync(join3(work, "index.html"), { force: true });
22398
+ rmSync2(join5(work, "index.html"), { force: true });
22149
22399
  }
22150
- const pngs = existsSync2(shots) ? readdirSync(shots).filter((f) => f.endsWith(".png")).sort() : [];
22400
+ const pngs = existsSync4(shots) ? readdirSync(shots).filter((f) => f.endsWith(".png")).sort() : [];
22151
22401
  if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
22152
22402
  ${tail(r.stderr) || tail(r.stdout)}`);
22153
- const board = join3(pj.folder, `${pj.name}-storyboard.jpg`);
22403
+ const board = join5(pj.folder, "storyboard.jpg");
22154
22404
  const cols = Math.min(4, moments.length);
22155
22405
  const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
22156
- const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join3(shots, f)]);
22406
+ const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join5(shots, f)]);
22157
22407
  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(";");
22158
22408
  const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
22159
22409
  const stack = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
22160
22410
  const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
22161
22411
  if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
22162
22412
  ${tail(t.stderr)}`);
22163
- rmSync(shots, { recursive: true, force: true });
22413
+ rmSync2(shots, { recursive: true, force: true });
22164
22414
  const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
22165
- writeFileSync2(planFile(pj.folder), JSON.stringify(plan, null, 2));
22415
+ writeFileSync4(planFile(pj.folder), JSON.stringify(plan, null, 2));
22166
22416
  const text = [
22167
22417
  `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)." : ""}`,
22168
22418
  "",
@@ -22172,38 +22422,41 @@ ${tail(t.stderr)}`);
22172
22422
  "",
22173
22423
  `Storyboard: ${board}`
22174
22424
  ].join("\n");
22425
+ setStatus(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
22175
22426
  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." };
22176
22427
  }
22177
22428
  async function renderReel(input) {
22178
22429
  if (input.plan) {
22179
22430
  const folder = resolveProject(input.project).folder;
22180
- if (!existsSync2(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
22181
- input = { ...JSON.parse(readFileSync3(planFile(folder), "utf8")), project: input.project, draft: input.draft };
22431
+ if (!existsSync4(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
22432
+ input = { ...JSON.parse(readFileSync5(planFile(folder), "utf8")), project: input.project, draft: input.draft };
22182
22433
  }
22183
22434
  const { pj, work, html, cutout } = await composition(input);
22184
- const out = join3(pj.folder, `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22185
- const tmp = join3(work, "render.mp4");
22186
- writeFileSync2(join3(work, "index.html"), html);
22435
+ mkdirSync5(join5(pj.folder, "renders"), { recursive: true });
22436
+ const out = join5(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22437
+ const tmp = join5(work, "render.mp4");
22438
+ writeFileSync4(join5(work, "index.html"), html);
22187
22439
  const started = Date.now();
22188
22440
  let r;
22189
22441
  try {
22190
22442
  r = sh("npx", ["--yes", HF, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
22191
22443
  } finally {
22192
- rmSync(join3(work, "index.html"), { force: true });
22444
+ rmSync2(join5(work, "index.html"), { force: true });
22193
22445
  }
22194
- if (r.status !== 0 || !existsSync2(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22446
+ if (r.status !== 0 || !existsSync4(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22195
22447
  ${tail(r.stderr) || tail(r.stdout)}`);
22196
- renameSync(tmp, out);
22448
+ renameSync2(tmp, out);
22197
22449
  const render_s = Math.round((Date.now() - started) / 100) / 10;
22198
- const qa = await qaVideo({ video: out, write_report: true, layout: input.layout });
22450
+ const { report: checks, ...qa } = await qaVideo({ video: out, layout: input.layout });
22451
+ addCheck(pj.folder, checks);
22452
+ setStatus(pj.folder, input.draft ? "preview" : "final");
22199
22453
  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." };
22200
22454
  }
22201
22455
  async function qaVideo(input) {
22202
- const file = resolve2(input.video.replace(/^~/, homedir2()));
22203
- if (!existsSync2(file)) throw new Error(`No such file: ${file}`);
22456
+ const file = resolve3(input.video.replace(/^~/, homedir2()));
22457
+ if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
22204
22458
  const tech = technicalChecks(file);
22205
- const dir = join3(file, "..", ".cutroom", "qa-frames");
22206
- rmSync(dir, { recursive: true, force: true });
22459
+ const dir = mkdtempSync(join5(tmpdir(), "cutroom-qa-"));
22207
22460
  const { frames } = sampleFrames(file, dir, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
22208
22461
  let flags = [];
22209
22462
  let judged = 0, judge_error = null;
@@ -22214,7 +22467,7 @@ async function qaVideo(input) {
22214
22467
  } catch (e) {
22215
22468
  judge_error = e.message;
22216
22469
  }
22217
- rmSync(dir, { recursive: true, force: true });
22470
+ rmSync2(dir, { recursive: true, force: true });
22218
22471
  const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
22219
22472
  const passed = !tech.issues.length && !flags.length && !judge_error;
22220
22473
  const report = {
@@ -22225,31 +22478,28 @@ async function qaVideo(input) {
22225
22478
  judge_error,
22226
22479
  measured: { duration: Math.round(tech.duration * 10) / 10, size: `${tech.width}x${tech.height}`, lufs: tech.lufs }
22227
22480
  };
22228
- if (input.write_report) {
22229
- writeFileSync2(file.replace(/\.mp4$/i, ".qa.md"), [
22230
- `# QA: ${basename(file)}`,
22231
- "",
22232
- passed ? "Passed." : "Needs a look.",
22233
- "",
22234
- "## Technical",
22235
- ...tech.issues.length ? tech.issues.map((i) => `- ${i}`) : ["- clean"],
22236
- "",
22237
- `## Frames (${judged} judged)`,
22238
- ...judge_error ? [`- not judged: ${judge_error}`] : look.length ? look.map((l) => `- ${l}`) : ["- nothing flagged"],
22239
- ""
22240
- ].join("\n"));
22241
- }
22242
- return report;
22481
+ const md = [
22482
+ `## ${basename2(file)}, ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ")}`,
22483
+ "",
22484
+ passed ? "Passed." : "Needs a look.",
22485
+ "",
22486
+ "Technical:",
22487
+ ...tech.issues.length ? tech.issues.map((i) => `- ${i}`) : ["- clean"],
22488
+ "",
22489
+ `Frames (${judged} judged):`,
22490
+ ...judge_error ? [`- not judged: ${judge_error}`] : look.length ? look.map((l) => `- ${l}`) : ["- nothing flagged"]
22491
+ ].join("\n");
22492
+ return { ...report, report: md };
22243
22493
  }
22244
22494
 
22245
22495
  // src/community/local/server.ts
22246
- 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);
22496
+ 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);
22247
22497
  var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
22248
22498
  var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
22249
22499
  var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
22250
22500
  var INSTRUCTIONS = `Cutroom: short-form video help. Speak plainly; the user is not technical. Never show tool or effect codes; use plain names.
22251
22501
 
22252
- 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.
22502
+ 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.
22253
22503
 
22254
22504
  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.
22255
22505
 
@@ -22261,13 +22511,17 @@ Before a first render: show_brand; if none, offer set_brand (from colors, a webs
22261
22511
 
22262
22512
  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.
22263
22513
 
22264
- Results are saved in ~/Cutroom. Other creators' reels are for study only: never reuse their words, footage or branding.`;
22514
+ 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/, references/, script.md, facts.md, plan.md with its status line, renders/, checks.md), brand/, references/ as the library for breakdowns and teardowns, and CLAUDE.md with their rules. When they give a reference for one video (an image, a link, a reel to match), save it in that video's references/ and add a line to its references/notes.md saying what it is and why it works; pictures for plan_reel and render_reel can come from there. breakdown_reel and creator_teardown still go to the top-level references/ library. 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.`;
22265
22515
  await serveStdio(() => {
22266
22516
  const server = new McpServer({ name: "cutroom", version: VERSION }, { instructions: INSTRUCTIONS });
22267
22517
  server.registerTool("cutroom_doctor", {
22268
- 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.",
22518
+ 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.",
22269
22519
  inputSchema: object({})
22270
22520
  }, async () => safe(() => doctor()));
22521
+ server.registerTool("cutroom_sign_in", {
22522
+ 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.",
22523
+ inputSchema: object({})
22524
+ }, async () => safe(() => doctor({ fresh: true })));
22271
22525
  server.registerTool("list_methods", {
22272
22526
  description: "List the Cutroom writing methods available to members (hooks, short script, caption and CTA, and how to write up breakdowns and teardowns).",
22273
22527
  inputSchema: object({})
@@ -22290,11 +22544,11 @@ ${m.text}`;
22290
22544
  inputSchema: object({ script: string2().min(1), audience: string2().optional() })
22291
22545
  }, async ({ script, audience }) => safe(() => call("score", { kind: "script", texts: [script], audience })));
22292
22546
  server.registerTool("breakdown_reel", {
22293
- 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.",
22547
+ 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.",
22294
22548
  inputSchema: object({ source: string2().min(1).describe("Instagram reel link or a local video file path.") })
22295
22549
  }, async (input) => safe(() => breakdownReel(input)));
22296
22550
  server.registerTool("creator_teardown", {
22297
- 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').",
22551
+ 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').",
22298
22552
  inputSchema: object({ handle: string2().min(1), limit: number2().int().min(1).max(30).default(12), compare_to: string2().optional() })
22299
22553
  }, async (input) => safe(() => creatorTeardown(input)));
22300
22554
  server.registerTool("list_styles", {
@@ -22318,16 +22572,16 @@ ${m.text}`;
22318
22572
  inputSchema: object({})
22319
22573
  }, async () => safe(() => getBrand() ?? "No brand kit set: renders use the default look. Set one with set_brand."));
22320
22574
  server.registerTool("prepare_footage", {
22321
- 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.",
22575
+ 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.",
22322
22576
  inputSchema: object({
22323
- video: string2().min(1).describe("Path to the raw video file on this computer."),
22577
+ video: string2().min(1).describe("Path to the raw video file on this computer (often in their studio's inbox/)."),
22324
22578
  name: string2().optional().describe("Short name for the output, e.g. 'trial-loop'."),
22325
22579
  cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second."),
22326
22580
  language: string2().optional().describe("Spoken language code if not English, e.g. ko, es, ja (uses a larger model, downloaded once).")
22327
22581
  })
22328
22582
  }, async (input) => safe(() => prepareFootage(input)));
22329
22583
  server.registerTool("plan_reel", {
22330
- 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.",
22584
+ 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.",
22331
22585
  inputSchema: object({
22332
22586
  project: string2().min(1).describe("The project folder returned by prepare_footage."),
22333
22587
  layout: string2().default("full-frame"),
@@ -22342,13 +22596,13 @@ ${m.text}`;
22342
22596
  const r = await planReel(input);
22343
22597
  return { content: [{ type: "text", text: `${r.text}
22344
22598
 
22345
- ${r.next}` }, { type: "image", data: readFileSync4(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
22599
+ ${r.next}` }, { type: "image", data: readFileSync6(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
22346
22600
  } catch (e) {
22347
22601
  return failure2(e);
22348
22602
  }
22349
22603
  });
22350
22604
  server.registerTool("render_reel", {
22351
- 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.",
22605
+ 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.",
22352
22606
  inputSchema: object({
22353
22607
  project: string2().min(1).describe("The project folder returned by prepare_footage."),
22354
22608
  layout: string2().default("full-frame"),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@keepylabs/cutroom",
3
- "version": "0.5.7",
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.1",
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",