@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.
- package/README.md +26 -8
- package/cutroom.mjs +443 -182
- 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
|
|
6
|
+
In your terminal:
|
|
7
7
|
|
|
8
|
-
claude mcp add cutroom -s user
|
|
8
|
+
claude mcp add cutroom -s user -- npx -y @keepylabs/cutroom@latest
|
|
9
9
|
|
|
10
|
-
Restart Claude Code and say "check
|
|
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
|
|
34
|
+
- "finish the video in my inbox"
|
|
35
|
+
- "what videos are unfinished?"
|
|
18
36
|
|
|
19
|
-
|
|
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
|
|
23
|
-
- A Keepy Labs membership
|
|
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
|
-
-
|
|
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
|
|
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 ?
|
|
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,
|
|
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,
|
|
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,
|
|
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 ?? (
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
|
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
|
|
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)
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
15532
|
+
function resolveComponent(base, relative2, options, skipNormalization) {
|
|
15522
15533
|
const target = {};
|
|
15523
15534
|
if (!skipNormalization) {
|
|
15524
15535
|
base = parse2(serialize(base, options), options);
|
|
15525
|
-
|
|
15536
|
+
relative2 = parse2(serialize(relative2, options), options);
|
|
15526
15537
|
}
|
|
15527
15538
|
options = options || {};
|
|
15528
|
-
if (!options.tolerant &&
|
|
15529
|
-
target.scheme =
|
|
15530
|
-
target.userinfo =
|
|
15531
|
-
target.host =
|
|
15532
|
-
target.port =
|
|
15533
|
-
target.path = removeDotSegments(
|
|
15534
|
-
target.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 (
|
|
15537
|
-
target.userinfo =
|
|
15538
|
-
target.host =
|
|
15539
|
-
target.port =
|
|
15540
|
-
target.path = removeDotSegments(
|
|
15541
|
-
target.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 (!
|
|
15554
|
+
if (!relative2.path) {
|
|
15544
15555
|
target.path = base.path;
|
|
15545
|
-
if (
|
|
15556
|
+
if (relative2.query !== void 0) target.query = relative2.query;
|
|
15546
15557
|
else target.query = base.query;
|
|
15547
15558
|
} else {
|
|
15548
|
-
if (
|
|
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 = "/" +
|
|
15551
|
-
else if (!base.path) target.path =
|
|
15552
|
-
else target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) +
|
|
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 =
|
|
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 =
|
|
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:
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
21209
|
+
return await new Promise((resolve4) => {
|
|
21199
21210
|
const waiter = () => {
|
|
21200
21211
|
clearTimeout(timer);
|
|
21201
|
-
|
|
21212
|
+
resolve4(true);
|
|
21202
21213
|
};
|
|
21203
21214
|
const timer = setTimeout(() => {
|
|
21204
21215
|
this._drainWaiters = this._drainWaiters.filter((pending) => pending !== waiter);
|
|
21205
|
-
|
|
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
|
|
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
|
|
21552
|
-
import { homedir as homedir2 } from "node:os";
|
|
21553
|
-
import { basename, extname, join as
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
21793
|
+
var HOME = () => process.env.CUTROOM_HOME || join3(homedir(), "Cutroom");
|
|
21658
21794
|
async function call(op, body = {}, fetchImpl = fetch) {
|
|
21659
|
-
const key =
|
|
21660
|
-
if (!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: "
|
|
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
|
-
|
|
21694
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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 =
|
|
21742
|
-
|
|
21743
|
-
file =
|
|
21744
|
-
if (!
|
|
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 (!
|
|
21903
|
+
if (!existsSync2(file)) throw new Error(`No such file: ${file}`);
|
|
21748
21904
|
}
|
|
21749
|
-
const dir = /^https?:/i.test(src) ?
|
|
21750
|
-
|
|
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,
|
|
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
|
-
|
|
21793
|
-
const out = { folder: dir, report:
|
|
21794
|
-
|
|
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 =
|
|
21804
|
-
|
|
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 =
|
|
21809
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
21884
|
-
const out = { folder: a.dir, report:
|
|
21885
|
-
|
|
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) =>
|
|
22145
|
+
var projectFile = (folder) => join5(folder, ".cutroom", "project.json");
|
|
21896
22146
|
var resolveProject = (p) => {
|
|
21897
|
-
const folder =
|
|
21898
|
-
const file =
|
|
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(
|
|
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 =
|
|
21916
|
-
if (!
|
|
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 ??
|
|
21921
|
-
|
|
21922
|
-
const
|
|
21923
|
-
|
|
21924
|
-
|
|
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 =
|
|
21927
|
-
cutAndNormalize(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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) =>
|
|
22213
|
+
if (!need.every((f) => existsSync4(join5(cache, f)))) {
|
|
21959
22214
|
const { fonts } = await call("fonts");
|
|
21960
|
-
|
|
21961
|
-
for (const [f, b64] of Object.entries(fonts))
|
|
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
|
-
|
|
21964
|
-
for (const f of need)
|
|
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 = () =>
|
|
22227
|
+
var brandFile = () => join5(studio.brand(), "brand.json");
|
|
21973
22228
|
function getBrand() {
|
|
21974
|
-
|
|
21975
|
-
|
|
21976
|
-
|
|
21977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21989
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
22258
|
+
const dir = join5(work, "assets");
|
|
22002
22259
|
const swap = (v) => {
|
|
22003
22260
|
const raw = String(v ?? "").trim();
|
|
22004
|
-
if (/^assets\/[\w.-]+$/.test(raw) &&
|
|
22005
|
-
const file =
|
|
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 (!
|
|
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
|
-
|
|
22011
|
-
|
|
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 =
|
|
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(
|
|
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 &&
|
|
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 || !
|
|
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
|
-
|
|
22321
|
+
writeFileSync4(stamp, JSON.stringify(cached2));
|
|
22065
22322
|
}
|
|
22066
|
-
if (!
|
|
22067
|
-
|
|
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) =>
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
22132
|
-
|
|
22133
|
-
|
|
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
|
-
|
|
22395
|
+
rmSync2(join5(work, "index.html"), { force: true });
|
|
22139
22396
|
}
|
|
22140
|
-
const pngs =
|
|
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 =
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
22171
|
-
input = { ...JSON.parse(
|
|
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
|
-
|
|
22175
|
-
const
|
|
22176
|
-
|
|
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
|
-
|
|
22441
|
+
rmSync2(join5(work, "index.html"), { force: true });
|
|
22183
22442
|
}
|
|
22184
|
-
if (r.status !== 0 || !
|
|
22443
|
+
if (r.status !== 0 || !existsSync4(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
|
|
22185
22444
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22186
|
-
|
|
22445
|
+
renameSync2(tmp, out);
|
|
22187
22446
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22188
|
-
const qa = await qaVideo({ video: out,
|
|
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 =
|
|
22193
|
-
if (!
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
22219
|
-
|
|
22220
|
-
|
|
22221
|
-
|
|
22222
|
-
|
|
22223
|
-
|
|
22224
|
-
|
|
22225
|
-
|
|
22226
|
-
|
|
22227
|
-
|
|
22228
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
4
|
-
"description": "Cutroom for Claude Code: hooks and scripts with scoring, reel breakdowns, creator teardowns, and finishing your own videos with QA.
|
|
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": ">=
|
|
14
|
+
"node": ">=22"
|
|
15
15
|
},
|
|
16
16
|
"license": "UNLICENSED",
|
|
17
17
|
"homepage": "https://keepylabs.com",
|