@keepylabs/cutroom 0.5.7 → 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 +432 -181
- 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,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 ? {
|
|
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,
|
|
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,
|
|
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,
|
|
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 ?? (
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
|
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
|
|
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)
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
15532
|
+
function resolveComponent(base, relative2, options, skipNormalization) {
|
|
15528
15533
|
const target = {};
|
|
15529
15534
|
if (!skipNormalization) {
|
|
15530
15535
|
base = parse2(serialize(base, options), options);
|
|
15531
|
-
|
|
15536
|
+
relative2 = parse2(serialize(relative2, options), options);
|
|
15532
15537
|
}
|
|
15533
15538
|
options = options || {};
|
|
15534
|
-
if (!options.tolerant &&
|
|
15535
|
-
target.scheme =
|
|
15536
|
-
target.userinfo =
|
|
15537
|
-
target.host =
|
|
15538
|
-
target.port =
|
|
15539
|
-
target.path = removeDotSegments(
|
|
15540
|
-
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;
|
|
15541
15546
|
} else {
|
|
15542
|
-
if (
|
|
15543
|
-
target.userinfo =
|
|
15544
|
-
target.host =
|
|
15545
|
-
target.port =
|
|
15546
|
-
target.path = removeDotSegments(
|
|
15547
|
-
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;
|
|
15548
15553
|
} else {
|
|
15549
|
-
if (!
|
|
15554
|
+
if (!relative2.path) {
|
|
15550
15555
|
target.path = base.path;
|
|
15551
|
-
if (
|
|
15556
|
+
if (relative2.query !== void 0) target.query = relative2.query;
|
|
15552
15557
|
else target.query = base.query;
|
|
15553
15558
|
} else {
|
|
15554
|
-
if (
|
|
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 = "/" +
|
|
15557
|
-
else if (!base.path) target.path =
|
|
15558
|
-
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;
|
|
15559
15564
|
target.path = removeDotSegments(target.path);
|
|
15560
15565
|
}
|
|
15561
|
-
target.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 =
|
|
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:
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
21209
|
+
return await new Promise((resolve4) => {
|
|
21205
21210
|
const waiter = () => {
|
|
21206
21211
|
clearTimeout(timer);
|
|
21207
|
-
|
|
21212
|
+
resolve4(true);
|
|
21208
21213
|
};
|
|
21209
21214
|
const timer = setTimeout(() => {
|
|
21210
21215
|
this._drainWaiters = this._drainWaiters.filter((pending) => pending !== waiter);
|
|
21211
|
-
|
|
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
|
|
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
|
|
21558
|
-
import { homedir as homedir2 } from "node:os";
|
|
21559
|
-
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";
|
|
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
|
|
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
|
|
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
|
-
|
|
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";
|
|
21662
21792
|
var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
|
|
21663
|
-
var HOME = () => process.env.CUTROOM_HOME ||
|
|
21793
|
+
var HOME = () => process.env.CUTROOM_HOME || join3(homedir(), "Cutroom");
|
|
21664
21794
|
async function call(op, body = {}, fetchImpl = fetch) {
|
|
21665
|
-
const key =
|
|
21666
|
-
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 });
|
|
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
|
-
|
|
21704
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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 =
|
|
21752
|
-
|
|
21753
|
-
file =
|
|
21754
|
-
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);
|
|
21755
21901
|
} else {
|
|
21756
21902
|
file = resolve(src.replace(/^~/, homedir()));
|
|
21757
|
-
if (!
|
|
21903
|
+
if (!existsSync2(file)) throw new Error(`No such file: ${file}`);
|
|
21758
21904
|
}
|
|
21759
|
-
const dir = /^https?:/i.test(src) ?
|
|
21760
|
-
|
|
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,
|
|
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
|
-
|
|
21803
|
-
const out = { folder: dir, report:
|
|
21804
|
-
|
|
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 =
|
|
21814
|
-
|
|
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 =
|
|
21819
|
-
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);
|
|
21820
21966
|
const m = measure(file);
|
|
21821
|
-
const { frames, interval } = sampleFrames(file,
|
|
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,118 @@ 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
|
-
|
|
21894
|
-
const out = { folder: a.dir, report:
|
|
21895
|
-
|
|
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
|
+
- 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
|
+
|
|
21899
22139
|
// src/community/local/render.ts
|
|
21900
22140
|
var HF = "hyperframes@0.8.99";
|
|
21901
22141
|
var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
21902
22142
|
var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
|
|
21903
22143
|
var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
21904
22144
|
var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
|
|
21905
|
-
var projectFile = (folder) =>
|
|
22145
|
+
var projectFile = (folder) => join5(folder, ".cutroom", "project.json");
|
|
21906
22146
|
var resolveProject = (p) => {
|
|
21907
|
-
const folder =
|
|
21908
|
-
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;
|
|
21909
22149
|
if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
|
|
21910
|
-
return JSON.parse(
|
|
22150
|
+
return JSON.parse(readFileSync5(file, "utf8"));
|
|
21911
22151
|
};
|
|
21912
22152
|
function cutAndNormalize(src, out, segments) {
|
|
21913
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("");
|
|
@@ -21922,19 +22162,21 @@ function looksLikeSpeech(words, duration3) {
|
|
|
21922
22162
|
return readable.length >= Math.max(3, duration3 * 0.6) && readable.length >= words.length * 0.8;
|
|
21923
22163
|
}
|
|
21924
22164
|
async function prepareFootage(input) {
|
|
21925
|
-
const source =
|
|
21926
|
-
if (!
|
|
22165
|
+
const source = resolve3(input.video.replace(/^~/, homedir2()));
|
|
22166
|
+
if (!existsSync4(source)) throw new Error(`No such file: ${source}`);
|
|
21927
22167
|
const p = probe(source);
|
|
21928
22168
|
if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
|
|
21929
22169
|
if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
|
|
21930
|
-
const name = slug2(input.name ??
|
|
21931
|
-
|
|
21932
|
-
const
|
|
21933
|
-
|
|
21934
|
-
|
|
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));
|
|
21935
22177
|
if (!segments.length) throw new Error("Couldn't find any speech in that video.");
|
|
21936
|
-
const footage =
|
|
21937
|
-
cutAndNormalize(
|
|
22178
|
+
const footage = join5(work, "footage.mp4");
|
|
22179
|
+
cutAndNormalize(clip.file, footage, segments);
|
|
21938
22180
|
const duration3 = probe(footage).duration;
|
|
21939
22181
|
const lang = (input.language ?? "en").toLowerCase().slice(0, 5);
|
|
21940
22182
|
const t = sh("npx", ["--yes", HF, "transcribe", "footage.mp4", "--json", "-d", ".", ...lang === "en" ? [] : ["--language", lang, "-m", "large-v3"]], work);
|
|
@@ -21947,14 +22189,17 @@ async function prepareFootage(input) {
|
|
|
21947
22189
|
})();
|
|
21948
22190
|
if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
|
|
21949
22191
|
${meta2.error ?? tail(t.stderr)}`);
|
|
21950
|
-
const raw = JSON.parse(
|
|
22192
|
+
const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(work, "transcript.json"), "utf8"));
|
|
21951
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);
|
|
21952
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.`);
|
|
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
|
-
|
|
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");
|
|
21955
22199
|
const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
|
|
21956
22200
|
return {
|
|
21957
22201
|
project: folder,
|
|
22202
|
+
raw: `${clip.file} (${clip.how})`,
|
|
21958
22203
|
duration: project.duration,
|
|
21959
22204
|
pauses_removed_s: project.removed_s,
|
|
21960
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." } : {},
|
|
@@ -21963,15 +22208,15 @@ ${meta2.error ?? tail(t.stderr)}`);
|
|
|
21963
22208
|
};
|
|
21964
22209
|
}
|
|
21965
22210
|
async function ensureFonts(work) {
|
|
21966
|
-
const cache =
|
|
22211
|
+
const cache = join5(HOME(), ".kit", "fonts");
|
|
21967
22212
|
const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
|
|
21968
|
-
if (!need.every((f) =>
|
|
22213
|
+
if (!need.every((f) => existsSync4(join5(cache, f)))) {
|
|
21969
22214
|
const { fonts } = await call("fonts");
|
|
21970
|
-
|
|
21971
|
-
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"));
|
|
21972
22217
|
}
|
|
21973
|
-
|
|
21974
|
-
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)));
|
|
21975
22220
|
}
|
|
21976
22221
|
function applyFixes(words, fixes = []) {
|
|
21977
22222
|
return words.map((w) => {
|
|
@@ -21979,46 +22224,48 @@ function applyFixes(words, fixes = []) {
|
|
|
21979
22224
|
return f ? { ...w, w: f.to } : w;
|
|
21980
22225
|
}).filter((w) => w.w);
|
|
21981
22226
|
}
|
|
21982
|
-
var brandFile = () =>
|
|
22227
|
+
var brandFile = () => join5(studio.brand(), "brand.json");
|
|
21983
22228
|
function getBrand() {
|
|
21984
|
-
|
|
21985
|
-
|
|
21986
|
-
|
|
21987
|
-
|
|
22229
|
+
for (const f of [brandFile(), join5(HOME(), "brand.json")]) {
|
|
22230
|
+
try {
|
|
22231
|
+
return JSON.parse(readFileSync5(f, "utf8"));
|
|
22232
|
+
} catch {
|
|
22233
|
+
}
|
|
21988
22234
|
}
|
|
22235
|
+
return null;
|
|
21989
22236
|
}
|
|
21990
22237
|
async function setBrand(input) {
|
|
21991
22238
|
if (input.reset) {
|
|
21992
|
-
|
|
22239
|
+
rmSync2(brandFile(), { force: true });
|
|
21993
22240
|
return { brand: null, saved: "Back to the default look." };
|
|
21994
22241
|
}
|
|
21995
22242
|
const { reset, ...fields } = input;
|
|
21996
22243
|
const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
|
|
21997
22244
|
await call("compose", { duration: 2, words: [], brand: next, check_only: true });
|
|
21998
|
-
|
|
21999
|
-
|
|
22245
|
+
ensureStudio();
|
|
22246
|
+
writeFileSync4(brandFile(), JSON.stringify(next, null, 2));
|
|
22000
22247
|
return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
|
|
22001
22248
|
}
|
|
22002
22249
|
var PICTURE = /\.(jpe?g|png|webp)$/i;
|
|
22003
22250
|
function safeAssetName(file, i) {
|
|
22004
|
-
const m =
|
|
22251
|
+
const m = basename2(file).match(PICTURE);
|
|
22005
22252
|
if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
|
|
22006
22253
|
const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
|
|
22007
|
-
return `${i + 1}-${slug2(
|
|
22254
|
+
return `${i + 1}-${slug2(basename2(file, extname(file)))}.${ext}`;
|
|
22008
22255
|
}
|
|
22009
22256
|
function localImages(work, layoutImages, effects) {
|
|
22010
22257
|
const names = /* @__PURE__ */ new Map();
|
|
22011
|
-
const dir =
|
|
22258
|
+
const dir = join5(work, "assets");
|
|
22012
22259
|
const swap = (v) => {
|
|
22013
22260
|
const raw = String(v ?? "").trim();
|
|
22014
|
-
if (/^assets\/[\w.-]+$/.test(raw) &&
|
|
22015
|
-
const file =
|
|
22261
|
+
if (/^assets\/[\w.-]+$/.test(raw) && existsSync4(join5(work, raw))) return raw;
|
|
22262
|
+
const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir2()));
|
|
22016
22263
|
if (!names.has(file)) {
|
|
22017
|
-
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.`);
|
|
22018
22265
|
if (names.size >= 8) throw new Error("8 pictures at most per reel.");
|
|
22019
22266
|
const name = safeAssetName(file, names.size);
|
|
22020
|
-
|
|
22021
|
-
|
|
22267
|
+
mkdirSync5(dir, { recursive: true });
|
|
22268
|
+
copyFileSync2(file, join5(dir, name));
|
|
22022
22269
|
names.set(file, `assets/${name}`);
|
|
22023
22270
|
}
|
|
22024
22271
|
return names.get(file);
|
|
@@ -22055,40 +22302,40 @@ function headTop(work, duration3) {
|
|
|
22055
22302
|
return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
|
|
22056
22303
|
}
|
|
22057
22304
|
function ensureCutout(work, duration3) {
|
|
22058
|
-
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");
|
|
22059
22306
|
const st = statSync(footage);
|
|
22060
22307
|
const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
22061
22308
|
let cached2 = null;
|
|
22062
22309
|
try {
|
|
22063
|
-
cached2 = JSON.parse(
|
|
22310
|
+
cached2 = JSON.parse(readFileSync5(stamp, "utf8"));
|
|
22064
22311
|
} catch {
|
|
22065
22312
|
}
|
|
22066
22313
|
let made_s = 0;
|
|
22067
|
-
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime &&
|
|
22314
|
+
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync4(cut))) {
|
|
22068
22315
|
const started = Date.now();
|
|
22069
22316
|
const r = sh("npx", ["--yes", HF, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
|
|
22070
|
-
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.
|
|
22071
22318
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22072
22319
|
made_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22073
22320
|
cached2 = { ...key, head: headTop(work, duration3), made_s };
|
|
22074
|
-
|
|
22321
|
+
writeFileSync4(stamp, JSON.stringify(cached2));
|
|
22075
22322
|
}
|
|
22076
|
-
if (!
|
|
22077
|
-
|
|
22323
|
+
if (!existsSync4(still)) {
|
|
22324
|
+
mkdirSync5(join5(work, "assets"), { recursive: true });
|
|
22078
22325
|
sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
|
|
22079
22326
|
}
|
|
22080
22327
|
return { head: cached2.head, made_s };
|
|
22081
22328
|
}
|
|
22082
|
-
var planFile = (folder) =>
|
|
22329
|
+
var planFile = (folder) => join5(folder, ".cutroom", "plan.json");
|
|
22083
22330
|
async function composition(input) {
|
|
22084
22331
|
const pj = resolveProject(input.project);
|
|
22085
|
-
const work =
|
|
22332
|
+
const work = join5(pj.folder, ".cutroom");
|
|
22086
22333
|
const words = applyFixes(pj.words, input.fixes);
|
|
22087
22334
|
const local = localImages(work, input.images, input.effects ?? []);
|
|
22088
22335
|
const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22089
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 } : {} });
|
|
22090
22337
|
await ensureFonts(work);
|
|
22091
|
-
|
|
22338
|
+
writeFileSync4(join5(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
22092
22339
|
return { pj, work, html, cutout, words };
|
|
22093
22340
|
}
|
|
22094
22341
|
var fmtT = (t) => `${t.toFixed(1)}s`;
|
|
@@ -22102,7 +22349,7 @@ async function planReel(input) {
|
|
|
22102
22349
|
const at = f.slug === "frame-rip" ? d * 0.5 : Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : 0.55));
|
|
22103
22350
|
return Math.min(pj.duration - 0.1, Number(f.start) + at);
|
|
22104
22351
|
};
|
|
22105
|
-
const plainAt = effects.length
|
|
22352
|
+
const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
|
|
22106
22353
|
const styles = await call("styles");
|
|
22107
22354
|
const title = (list, slug3) => (list.find((x) => x.slug === slug3)?.title ?? String(slug3)).replace(/\s*\(.*\)$/, "");
|
|
22108
22355
|
const sentences = words.reduce((out, w, k) => {
|
|
@@ -22132,37 +22379,37 @@ async function planReel(input) {
|
|
|
22132
22379
|
};
|
|
22133
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)));
|
|
22134
22381
|
const moments = [
|
|
22135
|
-
...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 }] : [],
|
|
22136
22383
|
...effects.map((f, k) => {
|
|
22137
22384
|
const name = title(styles.effects, f.slug);
|
|
22138
22385
|
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
|
|
22139
22386
|
})
|
|
22140
22387
|
];
|
|
22141
|
-
const shots =
|
|
22142
|
-
|
|
22143
|
-
|
|
22388
|
+
const shots = join5(work, "storyboard");
|
|
22389
|
+
rmSync2(shots, { recursive: true, force: true });
|
|
22390
|
+
writeFileSync4(join5(work, "index.html"), html);
|
|
22144
22391
|
let r;
|
|
22145
22392
|
try {
|
|
22146
22393
|
r = sh("npx", ["--yes", HF, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
|
|
22147
22394
|
} finally {
|
|
22148
|
-
|
|
22395
|
+
rmSync2(join5(work, "index.html"), { force: true });
|
|
22149
22396
|
}
|
|
22150
|
-
const pngs =
|
|
22397
|
+
const pngs = existsSync4(shots) ? readdirSync(shots).filter((f) => f.endsWith(".png")).sort() : [];
|
|
22151
22398
|
if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
|
|
22152
22399
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22153
|
-
const board =
|
|
22400
|
+
const board = join5(pj.folder, "storyboard.jpg");
|
|
22154
22401
|
const cols = Math.min(4, moments.length);
|
|
22155
22402
|
const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
|
|
22156
|
-
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i",
|
|
22403
|
+
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join5(shots, f)]);
|
|
22157
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(";");
|
|
22158
22405
|
const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
|
|
22159
22406
|
const stack = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
|
|
22160
22407
|
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
|
|
22161
22408
|
if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
|
|
22162
22409
|
${tail(t.stderr)}`);
|
|
22163
|
-
|
|
22410
|
+
rmSync2(shots, { recursive: true, force: true });
|
|
22164
22411
|
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
|
|
22165
|
-
|
|
22412
|
+
writeFileSync4(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22166
22413
|
const text = [
|
|
22167
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)." : ""}`,
|
|
22168
22415
|
"",
|
|
@@ -22172,38 +22419,41 @@ ${tail(t.stderr)}`);
|
|
|
22172
22419
|
"",
|
|
22173
22420
|
`Storyboard: ${board}`
|
|
22174
22421
|
].join("\n");
|
|
22422
|
+
setStatus(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
|
|
22175
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." };
|
|
22176
22424
|
}
|
|
22177
22425
|
async function renderReel(input) {
|
|
22178
22426
|
if (input.plan) {
|
|
22179
22427
|
const folder = resolveProject(input.project).folder;
|
|
22180
|
-
if (!
|
|
22181
|
-
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 };
|
|
22182
22430
|
}
|
|
22183
22431
|
const { pj, work, html, cutout } = await composition(input);
|
|
22184
|
-
|
|
22185
|
-
const
|
|
22186
|
-
|
|
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);
|
|
22187
22436
|
const started = Date.now();
|
|
22188
22437
|
let r;
|
|
22189
22438
|
try {
|
|
22190
22439
|
r = sh("npx", ["--yes", HF, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
|
|
22191
22440
|
} finally {
|
|
22192
|
-
|
|
22441
|
+
rmSync2(join5(work, "index.html"), { force: true });
|
|
22193
22442
|
}
|
|
22194
|
-
if (r.status !== 0 || !
|
|
22443
|
+
if (r.status !== 0 || !existsSync4(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
|
|
22195
22444
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22196
|
-
|
|
22445
|
+
renameSync2(tmp, out);
|
|
22197
22446
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22198
|
-
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");
|
|
22199
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." };
|
|
22200
22451
|
}
|
|
22201
22452
|
async function qaVideo(input) {
|
|
22202
|
-
const file =
|
|
22203
|
-
if (!
|
|
22453
|
+
const file = resolve3(input.video.replace(/^~/, homedir2()));
|
|
22454
|
+
if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
|
|
22204
22455
|
const tech = technicalChecks(file);
|
|
22205
|
-
const dir =
|
|
22206
|
-
rmSync(dir, { recursive: true, force: true });
|
|
22456
|
+
const dir = mkdtempSync(join5(tmpdir(), "cutroom-qa-"));
|
|
22207
22457
|
const { frames } = sampleFrames(file, dir, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
|
|
22208
22458
|
let flags = [];
|
|
22209
22459
|
let judged = 0, judge_error = null;
|
|
@@ -22214,7 +22464,7 @@ async function qaVideo(input) {
|
|
|
22214
22464
|
} catch (e) {
|
|
22215
22465
|
judge_error = e.message;
|
|
22216
22466
|
}
|
|
22217
|
-
|
|
22467
|
+
rmSync2(dir, { recursive: true, force: true });
|
|
22218
22468
|
const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
|
|
22219
22469
|
const passed = !tech.issues.length && !flags.length && !judge_error;
|
|
22220
22470
|
const report = {
|
|
@@ -22225,31 +22475,28 @@ async function qaVideo(input) {
|
|
|
22225
22475
|
judge_error,
|
|
22226
22476
|
measured: { duration: Math.round(tech.duration * 10) / 10, size: `${tech.width}x${tech.height}`, lufs: tech.lufs }
|
|
22227
22477
|
};
|
|
22228
|
-
|
|
22229
|
-
|
|
22230
|
-
|
|
22231
|
-
|
|
22232
|
-
|
|
22233
|
-
|
|
22234
|
-
|
|
22235
|
-
|
|
22236
|
-
|
|
22237
|
-
|
|
22238
|
-
|
|
22239
|
-
|
|
22240
|
-
].join("\n"));
|
|
22241
|
-
}
|
|
22242
|
-
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 };
|
|
22243
22490
|
}
|
|
22244
22491
|
|
|
22245
22492
|
// src/community/local/server.ts
|
|
22246
|
-
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);
|
|
22247
22494
|
var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
22248
22495
|
var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
|
|
22249
22496
|
var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
|
|
22250
22497
|
var INSTRUCTIONS = `Cutroom: short-form video help. Speak plainly; the user is not technical. Never show tool or effect codes; use plain names.
|
|
22251
22498
|
|
|
22252
|
-
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.
|
|
22253
22500
|
|
|
22254
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.
|
|
22255
22502
|
|
|
@@ -22261,13 +22508,17 @@ Before a first render: show_brand; if none, offer set_brand (from colors, a webs
|
|
|
22261
22508
|
|
|
22262
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.
|
|
22263
22510
|
|
|
22264
|
-
|
|
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.`;
|
|
22265
22512
|
await serveStdio(() => {
|
|
22266
22513
|
const server = new McpServer({ name: "cutroom", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
22267
22514
|
server.registerTool("cutroom_doctor", {
|
|
22268
|
-
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.",
|
|
22269
22516
|
inputSchema: object({})
|
|
22270
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 })));
|
|
22271
22522
|
server.registerTool("list_methods", {
|
|
22272
22523
|
description: "List the Cutroom writing methods available to members (hooks, short script, caption and CTA, and how to write up breakdowns and teardowns).",
|
|
22273
22524
|
inputSchema: object({})
|
|
@@ -22290,11 +22541,11 @@ ${m.text}`;
|
|
|
22290
22541
|
inputSchema: object({ script: string2().min(1), audience: string2().optional() })
|
|
22291
22542
|
}, async ({ script, audience }) => safe(() => call("score", { kind: "script", texts: [script], audience })));
|
|
22292
22543
|
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
|
|
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.",
|
|
22294
22545
|
inputSchema: object({ source: string2().min(1).describe("Instagram reel link or a local video file path.") })
|
|
22295
22546
|
}, async (input) => safe(() => breakdownReel(input)));
|
|
22296
22547
|
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
|
|
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').",
|
|
22298
22549
|
inputSchema: object({ handle: string2().min(1), limit: number2().int().min(1).max(30).default(12), compare_to: string2().optional() })
|
|
22299
22550
|
}, async (input) => safe(() => creatorTeardown(input)));
|
|
22300
22551
|
server.registerTool("list_styles", {
|
|
@@ -22318,16 +22569,16 @@ ${m.text}`;
|
|
|
22318
22569
|
inputSchema: object({})
|
|
22319
22570
|
}, async () => safe(() => getBrand() ?? "No brand kit set: renders use the default look. Set one with set_brand."));
|
|
22320
22571
|
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.",
|
|
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.",
|
|
22322
22573
|
inputSchema: object({
|
|
22323
|
-
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/)."),
|
|
22324
22575
|
name: string2().optional().describe("Short name for the output, e.g. 'trial-loop'."),
|
|
22325
22576
|
cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second."),
|
|
22326
22577
|
language: string2().optional().describe("Spoken language code if not English, e.g. ko, es, ja (uses a larger model, downloaded once).")
|
|
22327
22578
|
})
|
|
22328
22579
|
}, async (input) => safe(() => prepareFootage(input)));
|
|
22329
22580
|
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.",
|
|
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.",
|
|
22331
22582
|
inputSchema: object({
|
|
22332
22583
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22333
22584
|
layout: string2().default("full-frame"),
|
|
@@ -22342,13 +22593,13 @@ ${m.text}`;
|
|
|
22342
22593
|
const r = await planReel(input);
|
|
22343
22594
|
return { content: [{ type: "text", text: `${r.text}
|
|
22344
22595
|
|
|
22345
|
-
${r.next}` }, { type: "image", data:
|
|
22596
|
+
${r.next}` }, { type: "image", data: readFileSync6(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
|
|
22346
22597
|
} catch (e) {
|
|
22347
22598
|
return failure2(e);
|
|
22348
22599
|
}
|
|
22349
22600
|
});
|
|
22350
22601
|
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
|
|
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.",
|
|
22352
22603
|
inputSchema: object({
|
|
22353
22604
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22354
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",
|