@keepylabs/cutroom 0.8.0 → 0.10.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/NOTICE +20 -0
- package/README.md +5 -4
- package/cutroom.mjs +1257 -213
- package/package.json +3 -2
package/cutroom.mjs
CHANGED
|
@@ -7,8 +7,8 @@ var __export = (target, all) => {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
// src/community/local/server.ts
|
|
10
|
-
import { homedir as
|
|
11
|
-
import { delimiter, join as
|
|
10
|
+
import { homedir as homedir6 } from "node:os";
|
|
11
|
+
import { delimiter as delimiter2, join as join12 } from "node:path";
|
|
12
12
|
|
|
13
13
|
// src/community/local/proc.ts
|
|
14
14
|
import { spawnSync } from "node:child_process";
|
|
@@ -20,12 +20,7 @@ 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 ? {
|
|
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];
|
|
23
|
+
var installLine = (tool) => WIN ? { node: "winget install --id OpenJS.NodeJS.LTS -e", "node-update": "winget upgrade --id OpenJS.NodeJS.LTS -e" }[tool] : has("brew") ? { node: "brew install node", "node-update": "brew upgrade node" }[tool] : "Download and run the Node.js LTS installer from https://nodejs.org, then restart the Claude app";
|
|
29
24
|
var NODE_MIN = 22;
|
|
30
25
|
function nodeMajor() {
|
|
31
26
|
const r = spawnSync("node", ["--version"], { encoding: "utf8", shell: WIN });
|
|
@@ -3472,17 +3467,17 @@ var recursive = /* @__PURE__ */ new WeakMap();
|
|
|
3472
3467
|
var NONE = 0;
|
|
3473
3468
|
var ASSUMED = 1;
|
|
3474
3469
|
var PROVEN = 2;
|
|
3475
|
-
function isRecursive(inst,
|
|
3470
|
+
function isRecursive(inst, stack2, resolve6) {
|
|
3476
3471
|
const cached2 = recursive.get(inst);
|
|
3477
3472
|
if (cached2 !== void 0)
|
|
3478
3473
|
return cached2 ? PROVEN : NONE;
|
|
3479
|
-
if (
|
|
3474
|
+
if (stack2.has(inst))
|
|
3480
3475
|
return PROVEN;
|
|
3481
|
-
|
|
3476
|
+
stack2.add(inst);
|
|
3482
3477
|
let result2 = NONE;
|
|
3483
3478
|
const check = (child) => {
|
|
3484
3479
|
if (result2 !== PROVEN && child?._zod) {
|
|
3485
|
-
const answer = isRecursive(child,
|
|
3480
|
+
const answer = isRecursive(child, stack2, resolve6);
|
|
3486
3481
|
if (answer > result2)
|
|
3487
3482
|
result2 = answer;
|
|
3488
3483
|
}
|
|
@@ -3493,7 +3488,7 @@ function isRecursive(inst, stack, resolve4) {
|
|
|
3493
3488
|
const desc = Object.getOwnPropertyDescriptor(sh2, key);
|
|
3494
3489
|
if (spread && !desc.enumerable)
|
|
3495
3490
|
continue;
|
|
3496
|
-
const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value,
|
|
3491
|
+
const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack2, resolve6) : NONE;
|
|
3497
3492
|
if (child > answer)
|
|
3498
3493
|
answer = child;
|
|
3499
3494
|
}
|
|
@@ -3557,8 +3552,8 @@ function isRecursive(inst, stack, resolve4) {
|
|
|
3557
3552
|
break;
|
|
3558
3553
|
// `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
|
|
3559
3554
|
case "lazy": {
|
|
3560
|
-
const inner = def._cachedInner ?? (
|
|
3561
|
-
merge2(inner ? isRecursive(inner,
|
|
3555
|
+
const inner = def._cachedInner ?? (resolve6 ? inst._zod.innerType : void 0);
|
|
3556
|
+
merge2(inner ? isRecursive(inner, stack2, false) : ASSUMED);
|
|
3562
3557
|
break;
|
|
3563
3558
|
}
|
|
3564
3559
|
// a leaf by choice: `parts` are regex fragments, not data positions
|
|
@@ -3601,7 +3596,7 @@ function isRecursive(inst, stack, resolve4) {
|
|
|
3601
3596
|
}
|
|
3602
3597
|
}
|
|
3603
3598
|
}
|
|
3604
|
-
|
|
3599
|
+
stack2.delete(inst);
|
|
3605
3600
|
return settle(inst, result2);
|
|
3606
3601
|
}
|
|
3607
3602
|
function settle(inst, answer) {
|
|
@@ -5535,13 +5530,13 @@ var recordProcessor = (schema, ctx, _json, params) => {
|
|
|
5535
5530
|
...params,
|
|
5536
5531
|
path: [...params.path, "propertyNames"]
|
|
5537
5532
|
});
|
|
5538
|
-
let
|
|
5539
|
-
if (!
|
|
5540
|
-
|
|
5541
|
-
pendingRecords.set(ctx,
|
|
5533
|
+
let pending2 = pendingRecords.get(ctx);
|
|
5534
|
+
if (!pending2) {
|
|
5535
|
+
pending2 = [];
|
|
5536
|
+
pendingRecords.set(ctx, pending2);
|
|
5542
5537
|
ctx.deferred.push(() => rewriteKeyNames(ctx));
|
|
5543
5538
|
}
|
|
5544
|
-
|
|
5539
|
+
pending2.push(schema);
|
|
5545
5540
|
}
|
|
5546
5541
|
json.additionalProperties = processSchema(def.valueType, ctx, {
|
|
5547
5542
|
...params,
|
|
@@ -9192,11 +9187,11 @@ var rev2025Codec = {
|
|
|
9192
9187
|
},
|
|
9193
9188
|
encodeResult(method, result2) {
|
|
9194
9189
|
if (method !== "tools/list") return result2;
|
|
9195
|
-
const
|
|
9196
|
-
if (!Array.isArray(
|
|
9190
|
+
const tools2 = result2.tools;
|
|
9191
|
+
if (!Array.isArray(tools2) || !tools2.some((t) => toolNeedsLegacyWrap(t))) return result2;
|
|
9197
9192
|
return {
|
|
9198
9193
|
...result2,
|
|
9199
|
-
tools:
|
|
9194
|
+
tools: tools2.map((t) => toolNeedsLegacyWrap(t) ? {
|
|
9200
9195
|
...t,
|
|
9201
9196
|
outputSchema: wrapOutputSchemaForLegacy(t.outputSchema)
|
|
9202
9197
|
} : t)
|
|
@@ -10424,8 +10419,8 @@ function enforceDeletedFields(method, result2) {
|
|
|
10424
10419
|
}
|
|
10425
10420
|
return next;
|
|
10426
10421
|
};
|
|
10427
|
-
const
|
|
10428
|
-
if (method === "tools/list" && Array.isArray(
|
|
10422
|
+
const tools2 = result2.tools;
|
|
10423
|
+
if (method === "tools/list" && Array.isArray(tools2) && tools2.some((tool) => isPlainObject$4(tool) && "execution" in tool)) copy().tools = tools2.map((tool) => {
|
|
10429
10424
|
if (!isPlainObject$4(tool) || !("execution" in tool)) return tool;
|
|
10430
10425
|
const rest = { ...tool };
|
|
10431
10426
|
delete rest["execution"];
|
|
@@ -11074,8 +11069,8 @@ function datetimeReferenceSchemas(pattern) {
|
|
|
11074
11069
|
0
|
|
11075
11070
|
];
|
|
11076
11071
|
if (fractionDigits) precisions.push(Number(fractionDigits[1]));
|
|
11077
|
-
return [false, true].flatMap((
|
|
11078
|
-
local,
|
|
11072
|
+
return [false, true].flatMap((local2) => [false, true].flatMap((offset) => precisions.map((precision) => iso_exports.datetime({
|
|
11073
|
+
local: local2,
|
|
11079
11074
|
offset,
|
|
11080
11075
|
precision
|
|
11081
11076
|
}))));
|
|
@@ -11249,14 +11244,14 @@ function inputRequiredRoundsExceededMessage(method, maxRounds) {
|
|
|
11249
11244
|
return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
|
|
11250
11245
|
}
|
|
11251
11246
|
function sleep(ms, signal) {
|
|
11252
|
-
return new Promise((
|
|
11247
|
+
return new Promise((resolve6, reject) => {
|
|
11253
11248
|
if (signal?.aborted) {
|
|
11254
11249
|
reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason)));
|
|
11255
11250
|
return;
|
|
11256
11251
|
}
|
|
11257
11252
|
const timer = setTimeout(() => {
|
|
11258
11253
|
signal?.removeEventListener("abort", onAbort);
|
|
11259
|
-
|
|
11254
|
+
resolve6();
|
|
11260
11255
|
}, ms);
|
|
11261
11256
|
const onAbort = () => {
|
|
11262
11257
|
clearTimeout(timer);
|
|
@@ -12050,7 +12045,7 @@ var Protocol = class {
|
|
|
12050
12045
|
const flowStartedAt = Date.now();
|
|
12051
12046
|
let onAbort;
|
|
12052
12047
|
let cleanupMessageId;
|
|
12053
|
-
return new Promise((
|
|
12048
|
+
return new Promise((resolve6, reject) => {
|
|
12054
12049
|
const earlyReject = (error2) => {
|
|
12055
12050
|
reject(error2);
|
|
12056
12051
|
};
|
|
@@ -12119,7 +12114,7 @@ var Protocol = class {
|
|
|
12119
12114
|
}
|
|
12120
12115
|
if (decoded.kind === "invalid") return reject(decoded.error);
|
|
12121
12116
|
if (decoded.kind === "input_required") {
|
|
12122
|
-
if (options?.allowInputRequired === true) return
|
|
12117
|
+
if (options?.allowInputRequired === true) return resolve6(manualInputRequiredValue(decoded));
|
|
12123
12118
|
const flow = {
|
|
12124
12119
|
codec,
|
|
12125
12120
|
request,
|
|
@@ -12131,11 +12126,11 @@ var Protocol = class {
|
|
|
12131
12126
|
params
|
|
12132
12127
|
}, resultSchema, legOptions)
|
|
12133
12128
|
};
|
|
12134
|
-
return
|
|
12129
|
+
return resolve6(this._resolveNonCompleteResult(decoded, flow));
|
|
12135
12130
|
}
|
|
12136
12131
|
const result2 = decoded.result;
|
|
12137
12132
|
validateStandardSchema(resultSchema, result2).then((parseResult) => {
|
|
12138
|
-
if (parseResult.success)
|
|
12133
|
+
if (parseResult.success) resolve6(parseResult.data);
|
|
12139
12134
|
else reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`));
|
|
12140
12135
|
}, reject);
|
|
12141
12136
|
});
|
|
@@ -15047,7 +15042,7 @@ var require_compile = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
15047
15042
|
ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, ref);
|
|
15048
15043
|
const schOrFunc = root.refs[ref];
|
|
15049
15044
|
if (schOrFunc) return schOrFunc;
|
|
15050
|
-
let _sch =
|
|
15045
|
+
let _sch = resolve6.call(this, root, ref);
|
|
15051
15046
|
if (_sch === void 0) {
|
|
15052
15047
|
const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
|
|
15053
15048
|
const { schemaId } = this.opts;
|
|
@@ -15073,7 +15068,7 @@ var require_compile = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
15073
15068
|
function sameSchemaEnv(s1, s2) {
|
|
15074
15069
|
return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
|
|
15075
15070
|
}
|
|
15076
|
-
function
|
|
15071
|
+
function resolve6(root, ref) {
|
|
15077
15072
|
let sch;
|
|
15078
15073
|
while (typeof (sch = this.refs[ref]) == "string") ref = sch;
|
|
15079
15074
|
return sch || this.schemas[ref] || resolveSchema.call(this, root, ref);
|
|
@@ -15326,8 +15321,8 @@ var require_utils = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
15326
15321
|
}
|
|
15327
15322
|
return output.join("");
|
|
15328
15323
|
}
|
|
15329
|
-
function normalizeComponentEncoding(component,
|
|
15330
|
-
const func =
|
|
15324
|
+
function normalizeComponentEncoding(component, esc3) {
|
|
15325
|
+
const func = esc3 !== true ? escape : unescape;
|
|
15331
15326
|
if (component.scheme !== void 0) component.scheme = func(component.scheme);
|
|
15332
15327
|
if (component.userinfo !== void 0) component.userinfo = func(component.userinfo);
|
|
15333
15328
|
if (component.host !== void 0) component.host = func(component.host);
|
|
@@ -15523,7 +15518,7 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
15523
15518
|
else if (typeof uri === "object") uri = parse2(serialize(uri, options), options);
|
|
15524
15519
|
return uri;
|
|
15525
15520
|
}
|
|
15526
|
-
function
|
|
15521
|
+
function resolve6(baseURI, relativeURI, options) {
|
|
15527
15522
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
15528
15523
|
const resolved = resolveComponent(parse2(baseURI, schemelessOptions), parse2(relativeURI, schemelessOptions), schemelessOptions, true);
|
|
15529
15524
|
schemelessOptions.skipEscape = true;
|
|
@@ -15697,7 +15692,7 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
15697
15692
|
const fastUri = {
|
|
15698
15693
|
SCHEMES,
|
|
15699
15694
|
normalize,
|
|
15700
|
-
resolve:
|
|
15695
|
+
resolve: resolve6,
|
|
15701
15696
|
resolveComponent,
|
|
15702
15697
|
equal,
|
|
15703
15698
|
serialize,
|
|
@@ -21130,7 +21125,7 @@ var StdioServerTransport = class {
|
|
|
21130
21125
|
}
|
|
21131
21126
|
send(message) {
|
|
21132
21127
|
if (this._closed) return Promise.reject(/* @__PURE__ */ new Error("StdioServerTransport is closed"));
|
|
21133
|
-
return new Promise((
|
|
21128
|
+
return new Promise((resolve6, reject) => {
|
|
21134
21129
|
const json = serializeMessage(message);
|
|
21135
21130
|
let settled = false;
|
|
21136
21131
|
const onError = (error2) => {
|
|
@@ -21145,14 +21140,14 @@ var StdioServerTransport = class {
|
|
|
21145
21140
|
settled = true;
|
|
21146
21141
|
this._stdout.off("error", onError);
|
|
21147
21142
|
this._stdout.off("drain", onDrain);
|
|
21148
|
-
|
|
21143
|
+
resolve6();
|
|
21149
21144
|
};
|
|
21150
21145
|
this._stdout.once("error", onError);
|
|
21151
21146
|
if (this._stdout.write(json)) {
|
|
21152
21147
|
if (settled) return;
|
|
21153
21148
|
settled = true;
|
|
21154
21149
|
this._stdout.off("error", onError);
|
|
21155
|
-
|
|
21150
|
+
resolve6();
|
|
21156
21151
|
} else if (!settled) this._stdout.once("drain", onDrain);
|
|
21157
21152
|
});
|
|
21158
21153
|
}
|
|
@@ -21206,14 +21201,14 @@ var StdioConnectionChannel = class {
|
|
|
21206
21201
|
*/
|
|
21207
21202
|
async whenRequestsAnswered(timeoutMs) {
|
|
21208
21203
|
if (this._closed || this._pendingRequests.size === 0) return true;
|
|
21209
|
-
return await new Promise((
|
|
21204
|
+
return await new Promise((resolve6) => {
|
|
21210
21205
|
const waiter = () => {
|
|
21211
21206
|
clearTimeout(timer);
|
|
21212
|
-
|
|
21207
|
+
resolve6(true);
|
|
21213
21208
|
};
|
|
21214
21209
|
const timer = setTimeout(() => {
|
|
21215
|
-
this._drainWaiters = this._drainWaiters.filter((
|
|
21216
|
-
|
|
21210
|
+
this._drainWaiters = this._drainWaiters.filter((pending2) => pending2 !== waiter);
|
|
21211
|
+
resolve6(false);
|
|
21217
21212
|
}, timeoutMs);
|
|
21218
21213
|
this._drainWaiters.push(waiter);
|
|
21219
21214
|
});
|
|
@@ -21555,13 +21550,13 @@ function toError(value) {
|
|
|
21555
21550
|
}
|
|
21556
21551
|
|
|
21557
21552
|
// src/community/local/server.ts
|
|
21558
|
-
import { readFileSync as
|
|
21553
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
21559
21554
|
|
|
21560
21555
|
// src/community/local/render.ts
|
|
21561
21556
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
21562
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
21563
|
-
import { homedir as
|
|
21564
|
-
import { basename as basename2, extname, join as
|
|
21557
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync7, mkdirSync as mkdirSync7, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync8, renameSync as renameSync4, rmSync as rmSync5, statSync, writeFileSync as writeFileSync7 } from "node:fs";
|
|
21558
|
+
import { homedir as homedir3, tmpdir } from "node:os";
|
|
21559
|
+
import { basename as basename2, extname, join as join9, resolve as resolve3 } from "node:path";
|
|
21565
21560
|
|
|
21566
21561
|
// src/community/local/measure.ts
|
|
21567
21562
|
import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
|
|
@@ -21597,13 +21592,13 @@ function measure(file) {
|
|
|
21597
21592
|
lufs: p.has_audio ? parseLufs(ffmpegErr(["-i", file, "-vn", "-af", "ebur128", "-f", "null", "-"])) : null
|
|
21598
21593
|
};
|
|
21599
21594
|
}
|
|
21600
|
-
function sampleFrames(file,
|
|
21601
|
-
mkdirSync(
|
|
21595
|
+
function sampleFrames(file, dir2, duration3, interval = 1, max = 40) {
|
|
21596
|
+
mkdirSync(dir2, { recursive: true });
|
|
21602
21597
|
let step = interval;
|
|
21603
21598
|
if (duration3 / step > max) step = Math.ceil(duration3 / max * 10) / 10;
|
|
21604
21599
|
const out = [];
|
|
21605
21600
|
for (let t = Math.min(0.05, duration3); t < duration3 - 0.05 && out.length < max; t = Math.round((t + step) * 100) / 100) {
|
|
21606
|
-
const path = join(
|
|
21601
|
+
const path = join(dir2, `t${t.toFixed(2).padStart(6, "0")}.jpg`);
|
|
21607
21602
|
execFileSync("ffmpeg", ["-v", "error", "-y", "-ss", t.toFixed(2), "-i", file, "-frames:v", "1", "-vf", "scale='if(gt(iw,ih),-2,540)':'if(gt(iw,ih),540,-2)'", "-q:v", "5", path]);
|
|
21608
21603
|
out.push({ t, jpg_base64: readFileSync(path).toString("base64"), path });
|
|
21609
21604
|
}
|
|
@@ -21686,8 +21681,8 @@ function technicalChecks(file) {
|
|
|
21686
21681
|
}
|
|
21687
21682
|
|
|
21688
21683
|
// src/community/local/studio.ts
|
|
21689
|
-
import { existsSync as
|
|
21690
|
-
import { join as
|
|
21684
|
+
import { existsSync as existsSync5, renameSync as renameSync3 } from "node:fs";
|
|
21685
|
+
import { join as join7 } from "node:path";
|
|
21691
21686
|
|
|
21692
21687
|
// src/studio-core.ts
|
|
21693
21688
|
var studio_core_exports = {};
|
|
@@ -21712,16 +21707,16 @@ import { basename, dirname, isAbsolute, join as join2, relative, resolve } from
|
|
|
21712
21707
|
var STATUSES = ["footage ready", "plan ready", "preview", "final", "posted"];
|
|
21713
21708
|
var day = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
21714
21709
|
var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
21715
|
-
var layout = (
|
|
21716
|
-
function ensureLayout(
|
|
21717
|
-
const s = layout(
|
|
21710
|
+
var layout = (home2) => ({ home: home2, brand: join2(home2, "brand"), references: join2(home2, "references"), inbox: join2(home2, "inbox"), videos: join2(home2, "videos") });
|
|
21711
|
+
function ensureLayout(home2, claudeMd) {
|
|
21712
|
+
const s = layout(home2);
|
|
21718
21713
|
for (const d of [s.brand, s.references, s.inbox, s.videos]) mkdirSync2(d, { recursive: true });
|
|
21719
|
-
const claude = join2(
|
|
21714
|
+
const claude = join2(home2, "CLAUDE.md");
|
|
21720
21715
|
if (!existsSync(claude)) writeFileSync2(claude, claudeMd);
|
|
21721
21716
|
return s;
|
|
21722
21717
|
}
|
|
21723
|
-
function videoPath(
|
|
21724
|
-
const videos = layout(
|
|
21718
|
+
function videoPath(home2, topic, date4 = day()) {
|
|
21719
|
+
const videos = layout(home2).videos;
|
|
21725
21720
|
const t = slug(topic) || "video";
|
|
21726
21721
|
const existing = existsSync(videos) ? readdirSync(videos).filter((name) => /^\d{4}-\d{2}-\d{2}-/.test(name) && name.slice(11) === t).sort() : [];
|
|
21727
21722
|
return join2(videos, existing.at(-1) ?? `${date4}-${t}`);
|
|
@@ -21807,9 +21802,9 @@ function keepRender(folder, file, name = basename(file)) {
|
|
|
21807
21802
|
}
|
|
21808
21803
|
|
|
21809
21804
|
// src/community/local/tools.ts
|
|
21810
|
-
import { createWriteStream, existsSync as
|
|
21811
|
-
import { homedir } from "node:os";
|
|
21812
|
-
import { join as
|
|
21805
|
+
import { createWriteStream, existsSync as existsSync4, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
21806
|
+
import { homedir as homedir2 } from "node:os";
|
|
21807
|
+
import { join as join6, resolve as resolve2 } from "node:path";
|
|
21813
21808
|
import { Readable } from "node:stream";
|
|
21814
21809
|
import { pipeline } from "node:stream/promises";
|
|
21815
21810
|
|
|
@@ -21932,10 +21927,163 @@ ${authorize}
|
|
|
21932
21927
|
}
|
|
21933
21928
|
}
|
|
21934
21929
|
|
|
21930
|
+
// src/community/local/bin.ts
|
|
21931
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
21932
|
+
import { chmodSync as chmodSync2, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
21933
|
+
import { homedir } from "node:os";
|
|
21934
|
+
import { delimiter, join as join4 } from "node:path";
|
|
21935
|
+
import { gunzipSync } from "node:zlib";
|
|
21936
|
+
var FF = "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1";
|
|
21937
|
+
var ASSETS = {
|
|
21938
|
+
"darwin-arm64": {
|
|
21939
|
+
ffmpeg: { url: `${FF}/ffmpeg-darwin-arm64.gz`, sha256: "8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa", size: 19246198 },
|
|
21940
|
+
ffprobe: { url: `${FF}/ffprobe-darwin-arm64.gz`, sha256: "d986a8ec7b030899fe66a8a288ed809a3543338705a3ce178cfb85869c5d80be", size: 19207077 }
|
|
21941
|
+
},
|
|
21942
|
+
"darwin-x64": {
|
|
21943
|
+
ffmpeg: { url: `${FF}/ffmpeg-darwin-x64.gz`, sha256: "929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106", size: 25296431 },
|
|
21944
|
+
ffprobe: { url: `${FF}/ffprobe-darwin-x64.gz`, sha256: "d4da574d6e2e197bd259b47d69cf262df9e312af24ad960444f6d806d3d4c186", size: 25239438 }
|
|
21945
|
+
},
|
|
21946
|
+
"win32-x64": {
|
|
21947
|
+
ffmpeg: { url: `${FF}/ffmpeg-win32-x64.gz`, sha256: "8883a3dffbd0a16cf4ef95206ea05283f78908dbfb118f73c83f4951dcc06d77", size: 29581307 },
|
|
21948
|
+
ffprobe: { url: `${FF}/ffprobe-win32-x64.gz`, sha256: "f309e6223ad89d2fe54bccd420a7709b66fd27540674e92309578ed491a43c8d", size: 29521644 }
|
|
21949
|
+
}
|
|
21950
|
+
};
|
|
21951
|
+
ASSETS["win32-arm64"] = ASSETS["win32-x64"];
|
|
21952
|
+
var BIN = () => join4(process.env.CUTROOM_HOME || join4(homedir(), "Cutroom"), "bin");
|
|
21953
|
+
var exe = (t) => t + (WIN ? ".exe" : "");
|
|
21954
|
+
var stampFile = () => join4(BIN(), "tools.json");
|
|
21955
|
+
var platformKey = () => `${process.platform}-${process.arch}`;
|
|
21956
|
+
var sha256 = (b) => createHash2("sha256").update(b).digest("hex");
|
|
21957
|
+
function stamps() {
|
|
21958
|
+
try {
|
|
21959
|
+
return JSON.parse(readFileSync4(stampFile(), "utf8"));
|
|
21960
|
+
} catch {
|
|
21961
|
+
return {};
|
|
21962
|
+
}
|
|
21963
|
+
}
|
|
21964
|
+
function bundled(t) {
|
|
21965
|
+
const a = ASSETS[platformKey()]?.[t];
|
|
21966
|
+
const p = join4(BIN(), exe(t));
|
|
21967
|
+
return a && stamps()[t] === a.sha256 && existsSync3(p) ? p : null;
|
|
21968
|
+
}
|
|
21969
|
+
function useTools() {
|
|
21970
|
+
const bin = BIN();
|
|
21971
|
+
const parts = (process.env.PATH ?? "").split(delimiter).filter((p) => p && p !== bin);
|
|
21972
|
+
process.env.PATH = [bin, ...parts].join(delimiter);
|
|
21973
|
+
const env = { ffmpeg: "HYPERFRAMES_FFMPEG_PATH", ffprobe: "HYPERFRAMES_FFPROBE_PATH" };
|
|
21974
|
+
for (const t of Object.keys(env)) {
|
|
21975
|
+
const p = bundled(t);
|
|
21976
|
+
if (p) process.env[env[t]] = p;
|
|
21977
|
+
}
|
|
21978
|
+
}
|
|
21979
|
+
async function fetchVerified(a) {
|
|
21980
|
+
const res = await fetch(a.url, { signal: AbortSignal.timeout(15 * 6e4) });
|
|
21981
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
21982
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
21983
|
+
if (sha256(body) !== a.sha256) throw new Error("the download didn't match its checksum");
|
|
21984
|
+
return body;
|
|
21985
|
+
}
|
|
21986
|
+
function place(name, data) {
|
|
21987
|
+
const target = join4(BIN(), name), tmp = `${target}.${process.pid}.part`;
|
|
21988
|
+
writeFileSync4(tmp, data);
|
|
21989
|
+
chmodSync2(tmp, 493);
|
|
21990
|
+
rmSync3(target, { force: true });
|
|
21991
|
+
renameSync2(tmp, target);
|
|
21992
|
+
}
|
|
21993
|
+
async function install(t, a) {
|
|
21994
|
+
const body = await fetchVerified(a);
|
|
21995
|
+
mkdirSync4(BIN(), { recursive: true });
|
|
21996
|
+
place(exe(t), gunzipSync(body));
|
|
21997
|
+
writeFileSync4(stampFile(), JSON.stringify({ ...stamps(), [t]: a.sha256 }, null, 2));
|
|
21998
|
+
}
|
|
21999
|
+
var TOOLS = ["ffmpeg", "ffprobe"];
|
|
22000
|
+
var pending = null;
|
|
22001
|
+
function ensureTools() {
|
|
22002
|
+
if (TOOLS.every((t) => bundled(t))) {
|
|
22003
|
+
useTools();
|
|
22004
|
+
return Promise.resolve({ downloaded: [], failed: [], note: null });
|
|
22005
|
+
}
|
|
22006
|
+
return pending ??= (async () => {
|
|
22007
|
+
const assets = ASSETS[platformKey()];
|
|
22008
|
+
const todo = TOOLS.filter((t) => !bundled(t) && assets?.[t]);
|
|
22009
|
+
const mb = Math.round(todo.reduce((s, t) => s + assets[t].size, 0) / 1e6);
|
|
22010
|
+
if (todo.length) process.stderr.write(`Cutroom one-time setup: downloading the video tools (about ${mb} MB) into ${BIN()}
|
|
22011
|
+
`);
|
|
22012
|
+
const results = await Promise.allSettled(todo.map((t) => install(t, assets[t])));
|
|
22013
|
+
const downloaded = todo.filter((_, i) => results[i].status === "fulfilled");
|
|
22014
|
+
const failed = todo.flatMap((tool, i) => {
|
|
22015
|
+
const r = results[i];
|
|
22016
|
+
return r.status === "rejected" ? [{ tool, error: r.reason?.message ?? String(r.reason) }] : [];
|
|
22017
|
+
});
|
|
22018
|
+
useTools();
|
|
22019
|
+
const missing = TOOLS.filter((t) => !bundled(t) && !has(t));
|
|
22020
|
+
for (const t of missing) if (!failed.some((f) => f.tool === t)) failed.push({ tool: t, error: `no download for ${platformKey()}; install ffmpeg` });
|
|
22021
|
+
const note = downloaded.length ? `One-time setup done: downloaded the video tools into ${BIN()}.` : null;
|
|
22022
|
+
return { downloaded, failed: failed.filter((f) => missing.includes(f.tool)), note };
|
|
22023
|
+
})().finally(() => {
|
|
22024
|
+
pending = null;
|
|
22025
|
+
});
|
|
22026
|
+
}
|
|
22027
|
+
function toolSources() {
|
|
22028
|
+
return Object.fromEntries(TOOLS.map((t) => [t, bundled(t) ? "cutroom" : has(t) ? "system" : "missing"]));
|
|
22029
|
+
}
|
|
22030
|
+
|
|
22031
|
+
// src/community/local/transcribe.ts
|
|
22032
|
+
import { readFileSync as readFileSync5, rmSync as rmSync4 } from "node:fs";
|
|
22033
|
+
import { join as join5 } from "node:path";
|
|
22034
|
+
var HF = "hyperframes@0.8.99";
|
|
22035
|
+
var POLL_MS = 2e3;
|
|
22036
|
+
var WAIT_MS2 = 3 * 6e4;
|
|
22037
|
+
function extractAudio(video, out) {
|
|
22038
|
+
const r = run("ffmpeg", ["-nostdin", "-v", "error", "-y", "-i", video, "-vn", "-ac", "1", "-ar", "16000", "-b:a", "48k", out], { encoding: "utf8" });
|
|
22039
|
+
if (r.status !== 0) throw new Error(`Couldn't read the sound of that video.
|
|
22040
|
+
${(r.stderr ?? "").trim().split("\n").slice(-3).join("\n")}`);
|
|
22041
|
+
return out;
|
|
22042
|
+
}
|
|
22043
|
+
async function cloudWords(video, dir2, language, deps = {}) {
|
|
22044
|
+
const audio = extractAudio(video, join5(dir2, "audio.mp3"));
|
|
22045
|
+
let id;
|
|
22046
|
+
try {
|
|
22047
|
+
({ id } = await call("transcribe_start", { audio_base64: readFileSync5(audio).toString("base64"), ...language ? { language } : {} }));
|
|
22048
|
+
} finally {
|
|
22049
|
+
rmSync4(audio, { force: true });
|
|
22050
|
+
}
|
|
22051
|
+
const sleep2 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
22052
|
+
const until = Date.now() + (deps.waitMs ?? WAIT_MS2);
|
|
22053
|
+
while (Date.now() < until) {
|
|
22054
|
+
const s = await call("transcribe_status", { id });
|
|
22055
|
+
if (s.status === "completed") return s.words ?? [];
|
|
22056
|
+
await sleep2(POLL_MS);
|
|
22057
|
+
}
|
|
22058
|
+
throw new Error("Transcription is taking too long. Try again in a minute.");
|
|
22059
|
+
}
|
|
22060
|
+
function localWhisperWords(video, dir2, language) {
|
|
22061
|
+
if (!has("whisper-cli")) return null;
|
|
22062
|
+
const lang = (language ?? "en").toLowerCase().slice(0, 5);
|
|
22063
|
+
const r = run("npx", ["--yes", HF, "transcribe", video, "--json", "-d", dir2, ...lang === "en" ? [] : ["--language", lang, "-m", "large-v3"]], { cwd: dir2, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
22064
|
+
try {
|
|
22065
|
+
const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
|
|
22066
|
+
if (r.status !== 0 || !meta2.ok) return null;
|
|
22067
|
+
const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(dir2, "transcript.json"), "utf8"));
|
|
22068
|
+
return 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);
|
|
22069
|
+
} catch {
|
|
22070
|
+
return null;
|
|
22071
|
+
}
|
|
22072
|
+
}
|
|
22073
|
+
async function transcribeVideo(video, dir2, language) {
|
|
22074
|
+
try {
|
|
22075
|
+
return await cloudWords(video, dir2, language);
|
|
22076
|
+
} catch (e) {
|
|
22077
|
+
const local2 = localWhisperWords(video, dir2, language);
|
|
22078
|
+
if (local2) return local2;
|
|
22079
|
+
throw e;
|
|
22080
|
+
}
|
|
22081
|
+
}
|
|
22082
|
+
|
|
21935
22083
|
// src/community/local/tools.ts
|
|
21936
|
-
var VERSION = "0.
|
|
22084
|
+
var VERSION = "0.10.0";
|
|
21937
22085
|
var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
|
|
21938
|
-
var HOME = () => process.env.CUTROOM_HOME ||
|
|
22086
|
+
var HOME = () => process.env.CUTROOM_HOME || join6(homedir2(), "Cutroom");
|
|
21939
22087
|
async function call(op, body = {}, fetchImpl = fetch) {
|
|
21940
22088
|
const key = currentKey();
|
|
21941
22089
|
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 });
|
|
@@ -21966,9 +22114,12 @@ async function download(url2, target) {
|
|
|
21966
22114
|
}
|
|
21967
22115
|
async function doctor(opts = {}) {
|
|
21968
22116
|
const studioFolder = ensureStudio();
|
|
22117
|
+
const setup = await ensureTools();
|
|
22118
|
+
const src = toolSources();
|
|
22119
|
+
const ffOk = src.ffmpeg !== "missing" && src.ffprobe !== "missing";
|
|
22120
|
+
const ffFail = setup.failed.map((f) => f.error).join("; ");
|
|
21969
22121
|
const checks = [
|
|
21970
|
-
{ name:
|
|
21971
|
-
{ name: "whisper (captions)", ok: has("whisper-cli"), fix: installLine("whisper") },
|
|
22122
|
+
ffOk ? { name: `Video tools (ffmpeg${src.ffmpeg === "system" ? ", this computer's copy" : ""})`, ok: true } : { name: "Video tools (ffmpeg)", ok: false, fix: `Couldn't download them${ffFail ? ` (${ffFail})` : ""}. Check the internet connection and run the check again.` },
|
|
21972
22123
|
(() => {
|
|
21973
22124
|
const v = has("npx") ? nodeMajor() : 0;
|
|
21974
22125
|
if (!v) return { name: "Node.js (for rendering)", ok: false, fix: installLine("node") };
|
|
@@ -21996,7 +22147,7 @@ async function doctor(opts = {}) {
|
|
|
21996
22147
|
} catch (e) {
|
|
21997
22148
|
checks.push({ name: "Cutroom membership (signed in with Whop)", ok: false, fix: e.message });
|
|
21998
22149
|
}
|
|
21999
|
-
return { ready: checks.every((c) => c.ok), helper_version: VERSION, checks, member, ...signed_in_now ? { signed_in_now } : {}, studio: studioFolder };
|
|
22150
|
+
return { ready: checks.every((c) => c.ok), helper_version: VERSION, checks, member, ...signed_in_now ? { signed_in_now } : {}, ...setup.note ? { setup: setup.note } : {}, studio: studioFolder };
|
|
22000
22151
|
}
|
|
22001
22152
|
function approxWords(text, duration3) {
|
|
22002
22153
|
const words = (text ?? "").split(/\s+/).filter(Boolean);
|
|
@@ -22010,13 +22161,9 @@ function beatBounds(duration3, cuts, words) {
|
|
|
22010
22161
|
for (const x of all) if (x - out[out.length - 1] >= MIN_BEAT && duration3 - x >= MIN_BEAT / 2) out.push(x);
|
|
22011
22162
|
return [...out, duration3];
|
|
22012
22163
|
}
|
|
22013
|
-
function
|
|
22014
|
-
const r = run("npx", ["--yes", "hyperframes@0.8.99", "transcribe", file, "--json", "-d", dir], { cwd: dir, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
22164
|
+
async function timedWords(file, dir2) {
|
|
22015
22165
|
try {
|
|
22016
|
-
const
|
|
22017
|
-
if (r.status !== 0 || !meta2.ok) return null;
|
|
22018
|
-
const raw = JSON.parse(readFileSync4(meta2.transcriptPath ?? join4(dir, "transcript.json"), "utf8"));
|
|
22019
|
-
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);
|
|
22166
|
+
const words = await transcribeVideo(file, dir2);
|
|
22020
22167
|
return words.length ? words : null;
|
|
22021
22168
|
} catch {
|
|
22022
22169
|
return null;
|
|
@@ -22041,21 +22188,21 @@ async function breakdownReel(input) {
|
|
|
22041
22188
|
});
|
|
22042
22189
|
meta2 = r.meta;
|
|
22043
22190
|
transcript = r.transcript;
|
|
22044
|
-
const
|
|
22045
|
-
|
|
22046
|
-
file =
|
|
22047
|
-
if (!
|
|
22191
|
+
const dir3 = join6(studio.references(), `${day2()}-${slug2(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
|
|
22192
|
+
mkdirSync5(dir3, { recursive: true });
|
|
22193
|
+
file = join6(dir3, "source.mp4");
|
|
22194
|
+
if (!existsSync4(file)) await download(meta2.videoUrl, file);
|
|
22048
22195
|
} else {
|
|
22049
|
-
file = resolve2(src.replace(/^~/,
|
|
22050
|
-
if (!
|
|
22196
|
+
file = resolve2(src.replace(/^~/, homedir2()));
|
|
22197
|
+
if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
|
|
22051
22198
|
}
|
|
22052
|
-
const
|
|
22053
|
-
|
|
22199
|
+
const dir2 = /^https?:/i.test(src) ? join6(file, "..") : join6(studio.references(), `${day2()}-${slug2(file.split(/[\\/]/).pop() ?? "video")}`);
|
|
22200
|
+
mkdirSync5(dir2, { recursive: true });
|
|
22054
22201
|
const m = measure(file);
|
|
22055
|
-
const { frames, interval } = sampleFrames(file,
|
|
22202
|
+
const { frames, interval } = sampleFrames(file, join6(dir2, "frames"), m.duration, 1, 40);
|
|
22056
22203
|
const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
|
|
22057
|
-
const sheet = contactSheet(frames,
|
|
22058
|
-
const timed =
|
|
22204
|
+
const sheet = contactSheet(frames, join6(dir2, "sheet.jpg"));
|
|
22205
|
+
const timed = await timedWords(file, dir2);
|
|
22059
22206
|
const words = timed ?? approxWords(transcript, m.duration);
|
|
22060
22207
|
const bs = beats(m, words, match.frames);
|
|
22061
22208
|
if (!transcript && timed) transcript = timed.map((w) => w.w).join(" ");
|
|
@@ -22094,9 +22241,9 @@ async function breakdownReel(input) {
|
|
|
22094
22241
|
transcript ?? "(none)",
|
|
22095
22242
|
""
|
|
22096
22243
|
].join("\n");
|
|
22097
|
-
|
|
22098
|
-
const out = { folder:
|
|
22099
|
-
|
|
22244
|
+
writeFileSync5(join6(dir2, "breakdown.md"), md);
|
|
22245
|
+
const out = { folder: dir2, report: join6(dir2, "breakdown.md"), sheet, 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." };
|
|
22246
|
+
writeFileSync5(join6(dir2, "breakdown.json"), JSON.stringify(out, null, 2));
|
|
22100
22247
|
return out;
|
|
22101
22248
|
}
|
|
22102
22249
|
function ctaKeyword(caption) {
|
|
@@ -22105,15 +22252,15 @@ function ctaKeyword(caption) {
|
|
|
22105
22252
|
}
|
|
22106
22253
|
async function teardownOne(handle, limit) {
|
|
22107
22254
|
const { reels } = await call("reels", { handle, limit });
|
|
22108
|
-
const
|
|
22109
|
-
|
|
22255
|
+
const dir2 = join6(studio.references(), `${day2()}-${slug2(handle)}-creator`);
|
|
22256
|
+
mkdirSync5(join6(dir2, "raw"), { recursive: true });
|
|
22110
22257
|
const rows = [];
|
|
22111
22258
|
for (const [i, r] of reels.entries()) {
|
|
22112
22259
|
try {
|
|
22113
|
-
const file =
|
|
22114
|
-
if (!
|
|
22260
|
+
const file = join6(dir2, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
|
|
22261
|
+
if (!existsSync4(file)) await download(r.videoUrl, file);
|
|
22115
22262
|
const m = measure(file);
|
|
22116
|
-
const { frames, interval } = sampleFrames(file,
|
|
22263
|
+
const { frames, interval } = sampleFrames(file, join6(dir2, "frames", r.shortCode), m.duration, 2, 20);
|
|
22117
22264
|
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 }))]);
|
|
22118
22265
|
const top = (o) => Object.keys(o)[0] ?? null;
|
|
22119
22266
|
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) });
|
|
@@ -22121,7 +22268,7 @@ async function teardownOne(handle, limit) {
|
|
|
22121
22268
|
rows.push({ code: r.shortCode, url: r.url, posted: String(r.timestamp ?? "").slice(0, 10), plays: r.videoPlayCount ?? null, error: e.message, effects: [] });
|
|
22122
22269
|
}
|
|
22123
22270
|
}
|
|
22124
|
-
return { dir, rows };
|
|
22271
|
+
return { dir: dir2, rows };
|
|
22125
22272
|
}
|
|
22126
22273
|
function rollup(rows) {
|
|
22127
22274
|
const ok = rows.filter((r) => !r.error);
|
|
@@ -22185,9 +22332,9 @@ async function creatorTeardown(input) {
|
|
|
22185
22332
|
...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)} |`),
|
|
22186
22333
|
""
|
|
22187
22334
|
].join("\n");
|
|
22188
|
-
|
|
22189
|
-
const out = { folder: a.dir, report:
|
|
22190
|
-
|
|
22335
|
+
writeFileSync5(join6(a.dir, "teardown.md"), md);
|
|
22336
|
+
const out = { folder: a.dir, report: join6(a.dir, "teardown.md"), rollup: r, compare: cmp, rows: a.rows, next: "Get the 'teardown_writeup' method with get_method and follow it." };
|
|
22337
|
+
writeFileSync5(join6(a.dir, "teardown.json"), JSON.stringify(out, null, 2));
|
|
22191
22338
|
return out;
|
|
22192
22339
|
}
|
|
22193
22340
|
|
|
@@ -22225,26 +22372,78 @@ Everything for my videos lives in this folder. Open Claude Code here so it can s
|
|
|
22225
22372
|
`;
|
|
22226
22373
|
function ensureStudio() {
|
|
22227
22374
|
ensureLayout(HOME(), CLAUDE_MD);
|
|
22228
|
-
const oldBrand =
|
|
22229
|
-
if (
|
|
22375
|
+
const oldBrand = join7(HOME(), "brand.json"), newBrand = join7(studio.brand(), "brand.json");
|
|
22376
|
+
if (existsSync5(oldBrand) && !existsSync5(newBrand)) renameSync3(oldBrand, newBrand);
|
|
22230
22377
|
return HOME();
|
|
22231
22378
|
}
|
|
22232
22379
|
var takeRaw2 = (source, folder) => takeRaw(source, folder, studio.inbox());
|
|
22233
22380
|
var startVideoNotes2 = (folder, transcript) => startVideoNotes(folder, transcript, "I say");
|
|
22234
22381
|
var { setStatus: setStatus2, addCheck: addCheck2 } = studio_core_exports;
|
|
22235
22382
|
|
|
22383
|
+
// src/community/local/custom.ts
|
|
22384
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
|
|
22385
|
+
import { join as join8 } from "node:path";
|
|
22386
|
+
var isCustom = (f) => f.slug === "custom";
|
|
22387
|
+
var customDur = (f) => Math.min(Math.max(Number(f.dur) || 4, 1.5), 15);
|
|
22388
|
+
var dir = (work) => join8(work, "custom");
|
|
22389
|
+
var cardOf = (work, id) => {
|
|
22390
|
+
try {
|
|
22391
|
+
return JSON.parse(readFileSync7(join8(dir(work), `${id}.json`), "utf8"));
|
|
22392
|
+
} catch {
|
|
22393
|
+
return null;
|
|
22394
|
+
}
|
|
22395
|
+
};
|
|
22396
|
+
var same = (a, b) => (a ?? "").trim().toLowerCase() === (b ?? "").trim().toLowerCase();
|
|
22397
|
+
async function waitScene(id, wait = (ms) => new Promise((r) => setTimeout(r, ms)), deadline = Date.now() + 7 * 6e4) {
|
|
22398
|
+
while (Date.now() < deadline) {
|
|
22399
|
+
await wait(8e3);
|
|
22400
|
+
const r = await call("scene_status", { id });
|
|
22401
|
+
if (r.status === "done" && r.scene) return r.scene;
|
|
22402
|
+
if (r.status === "failed") throw new Error(r.error ?? "The scene writer couldn't make this scene.");
|
|
22403
|
+
}
|
|
22404
|
+
throw new Error("The scene writer took too long. Try plan_reel again in a minute.");
|
|
22405
|
+
}
|
|
22406
|
+
async function ensureCustom(work, slots, ctx) {
|
|
22407
|
+
mkdirSync6(dir(work), { recursive: true });
|
|
22408
|
+
let made = 0;
|
|
22409
|
+
const out = await Promise.all(slots.map(async (s) => {
|
|
22410
|
+
const dur = customDur(s);
|
|
22411
|
+
const have = s.scene ? cardOf(work, s.scene) : null;
|
|
22412
|
+
if (have && existsSync6(join8(dir(work), `${have.id}.html`)) && !s.redo && Math.abs(have.start - s.start) < 0.05 && Math.abs(have.dur - dur) < 0.05 && same(have.request, s.request) && have.layout === ctx.layout)
|
|
22413
|
+
return { ...s, dur, scene: have.id, title: have.title, shows: have.shows, redo: void 0 };
|
|
22414
|
+
const { id } = await call("scene_start", {
|
|
22415
|
+
start: s.start,
|
|
22416
|
+
dur,
|
|
22417
|
+
words: ctx.words,
|
|
22418
|
+
layout: ctx.layout,
|
|
22419
|
+
brand: ctx.brand ?? void 0,
|
|
22420
|
+
about: ctx.about,
|
|
22421
|
+
request: s.request,
|
|
22422
|
+
job: s.job,
|
|
22423
|
+
...have ? { previous: { shows: have.shows } } : {}
|
|
22424
|
+
});
|
|
22425
|
+
const scene = await waitScene(id);
|
|
22426
|
+
writeFileSync6(join8(dir(work), `${id}.html`), scene.html);
|
|
22427
|
+
const card = { id, start: s.start, dur, request: s.request, job: scene.job, title: scene.title, shows: scene.shows, layout: ctx.layout };
|
|
22428
|
+
writeFileSync6(join8(dir(work), `${id}.json`), JSON.stringify(card, null, 2));
|
|
22429
|
+
made++;
|
|
22430
|
+
return { ...s, dur, scene: id, title: scene.title, shows: scene.shows, redo: void 0 };
|
|
22431
|
+
}));
|
|
22432
|
+
return { slots: out, made };
|
|
22433
|
+
}
|
|
22434
|
+
|
|
22236
22435
|
// src/community/local/render.ts
|
|
22237
|
-
var
|
|
22436
|
+
var HF2 = "hyperframes@0.8.99";
|
|
22238
22437
|
var day3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
22239
22438
|
var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
|
|
22240
22439
|
var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
22241
22440
|
var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
|
|
22242
|
-
var projectFile = (folder) =>
|
|
22441
|
+
var projectFile = (folder) => join9(folder, ".cutroom", "project.json");
|
|
22243
22442
|
var resolveProject = (p) => {
|
|
22244
|
-
const folder = resolve3(p.replace(/^~/,
|
|
22245
|
-
const file = [folder,
|
|
22443
|
+
const folder = resolve3(p.replace(/^~/, homedir3()));
|
|
22444
|
+
const file = [folder, join9(studio.videos(), p), join9(HOME(), "renders", p)].map(projectFile).find((f) => existsSync7(f)) ?? null;
|
|
22246
22445
|
if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
|
|
22247
|
-
return JSON.parse(
|
|
22446
|
+
return JSON.parse(readFileSync8(file, "utf8"));
|
|
22248
22447
|
};
|
|
22249
22448
|
function cutAndNormalize(src, out, segments) {
|
|
22250
22449
|
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("");
|
|
@@ -22259,38 +22458,33 @@ function looksLikeSpeech(words, duration3) {
|
|
|
22259
22458
|
return readable.length >= Math.max(3, duration3 * 0.6) && readable.length >= words.length * 0.8;
|
|
22260
22459
|
}
|
|
22261
22460
|
async function prepareFootage(input) {
|
|
22262
|
-
const source = resolve3(input.video.replace(/^~/,
|
|
22263
|
-
if (!
|
|
22461
|
+
const source = resolve3(input.video.replace(/^~/, homedir3()));
|
|
22462
|
+
if (!existsSync7(source)) throw new Error(`No such file: ${source}`);
|
|
22264
22463
|
const p = probe(source);
|
|
22265
22464
|
if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
|
|
22266
22465
|
if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
|
|
22267
22466
|
const name = slug3(input.name ?? basename2(source, extname(source)));
|
|
22268
22467
|
ensureStudio();
|
|
22269
|
-
const folder =
|
|
22270
|
-
const work =
|
|
22271
|
-
|
|
22468
|
+
const folder = join9(studio.videos(), `${day3()}-${name}`);
|
|
22469
|
+
const work = join9(folder, ".cutroom");
|
|
22470
|
+
mkdirSync7(work, { recursive: true });
|
|
22272
22471
|
const clip = takeRaw2(source, folder);
|
|
22273
22472
|
const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
|
|
22274
22473
|
if (!segments.length) throw new Error("Couldn't find any speech in that video.");
|
|
22275
|
-
const footage =
|
|
22474
|
+
const footage = join9(work, "footage.mp4");
|
|
22276
22475
|
cutAndNormalize(clip.file, footage, segments);
|
|
22277
22476
|
const duration3 = probe(footage).duration;
|
|
22278
|
-
const lang =
|
|
22279
|
-
|
|
22280
|
-
|
|
22281
|
-
|
|
22282
|
-
|
|
22283
|
-
|
|
22284
|
-
|
|
22285
|
-
|
|
22286
|
-
|
|
22287
|
-
if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
|
|
22288
|
-
${meta2.error ?? tail(t.stderr)}`);
|
|
22289
|
-
const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join6(work, "transcript.json"), "utf8"));
|
|
22290
|
-
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);
|
|
22291
|
-
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.`);
|
|
22477
|
+
const lang = input.language ? input.language.toLowerCase().slice(0, 5) : void 0;
|
|
22478
|
+
let words;
|
|
22479
|
+
try {
|
|
22480
|
+
words = await transcribeVideo(footage, work, lang);
|
|
22481
|
+
} catch (e) {
|
|
22482
|
+
throw new Error(`Transcription failed. Run cutroom_doctor.
|
|
22483
|
+
${e.message}`);
|
|
22484
|
+
}
|
|
22485
|
+
if (!looksLikeSpeech(words, duration3)) throw new Error(!lang || 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.`);
|
|
22292
22486
|
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 };
|
|
22293
|
-
|
|
22487
|
+
writeFileSync7(projectFile(folder), JSON.stringify(project, null, 2));
|
|
22294
22488
|
startVideoNotes2(folder, words.map((w) => w.w).join(" "));
|
|
22295
22489
|
setStatus2(folder, "footage ready");
|
|
22296
22490
|
const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
|
|
@@ -22305,15 +22499,15 @@ ${meta2.error ?? tail(t.stderr)}`);
|
|
|
22305
22499
|
};
|
|
22306
22500
|
}
|
|
22307
22501
|
async function ensureFonts(work) {
|
|
22308
|
-
const cache =
|
|
22502
|
+
const cache = join9(HOME(), ".kit", "fonts");
|
|
22309
22503
|
const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
|
|
22310
|
-
if (!need.every((f) =>
|
|
22504
|
+
if (!need.every((f) => existsSync7(join9(cache, f)))) {
|
|
22311
22505
|
const { fonts } = await call("fonts");
|
|
22312
|
-
|
|
22313
|
-
for (const [f, b64] of Object.entries(fonts))
|
|
22506
|
+
mkdirSync7(cache, { recursive: true });
|
|
22507
|
+
for (const [f, b64] of Object.entries(fonts)) writeFileSync7(join9(cache, basename2(f)), Buffer.from(b64, "base64"));
|
|
22314
22508
|
}
|
|
22315
|
-
|
|
22316
|
-
for (const f of need)
|
|
22509
|
+
mkdirSync7(join9(work, "fonts"), { recursive: true });
|
|
22510
|
+
for (const f of need) writeFileSync7(join9(work, "fonts", f), readFileSync8(join9(cache, f)));
|
|
22317
22511
|
}
|
|
22318
22512
|
function applyFixes(words, fixes = []) {
|
|
22319
22513
|
return words.map((w) => {
|
|
@@ -22321,11 +22515,11 @@ function applyFixes(words, fixes = []) {
|
|
|
22321
22515
|
return f ? { ...w, w: f.to } : w;
|
|
22322
22516
|
}).filter((w) => w.w);
|
|
22323
22517
|
}
|
|
22324
|
-
var brandFile = () =>
|
|
22518
|
+
var brandFile = () => join9(studio.brand(), "brand.json");
|
|
22325
22519
|
function getBrand() {
|
|
22326
|
-
for (const f of [brandFile(),
|
|
22520
|
+
for (const f of [brandFile(), join9(HOME(), "brand.json")]) {
|
|
22327
22521
|
try {
|
|
22328
|
-
return JSON.parse(
|
|
22522
|
+
return JSON.parse(readFileSync8(f, "utf8"));
|
|
22329
22523
|
} catch {
|
|
22330
22524
|
}
|
|
22331
22525
|
}
|
|
@@ -22333,14 +22527,14 @@ function getBrand() {
|
|
|
22333
22527
|
}
|
|
22334
22528
|
async function setBrand(input) {
|
|
22335
22529
|
if (input.reset) {
|
|
22336
|
-
|
|
22530
|
+
rmSync5(brandFile(), { force: true });
|
|
22337
22531
|
return { brand: null, saved: "Back to the default look." };
|
|
22338
22532
|
}
|
|
22339
22533
|
const { reset, ...fields } = input;
|
|
22340
22534
|
const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
|
|
22341
22535
|
await call("compose", { duration: 2, words: [], brand: next, check_only: true });
|
|
22342
22536
|
ensureStudio();
|
|
22343
|
-
|
|
22537
|
+
writeFileSync7(brandFile(), JSON.stringify(next, null, 2));
|
|
22344
22538
|
return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
|
|
22345
22539
|
}
|
|
22346
22540
|
var PICTURE = /\.(jpe?g|png|webp)$/i;
|
|
@@ -22352,17 +22546,17 @@ function safeAssetName(file, i) {
|
|
|
22352
22546
|
}
|
|
22353
22547
|
function localImages(work, layoutImages, effects) {
|
|
22354
22548
|
const names = /* @__PURE__ */ new Map();
|
|
22355
|
-
const
|
|
22549
|
+
const dir2 = join9(work, "assets");
|
|
22356
22550
|
const swap = (v) => {
|
|
22357
22551
|
const raw = String(v ?? "").trim();
|
|
22358
|
-
if (/^assets\/[\w.-]+$/.test(raw) &&
|
|
22359
|
-
const file = resolve3(raw.replace(/^~(?=$|\/)/,
|
|
22552
|
+
if (/^assets\/[\w.-]+$/.test(raw) && existsSync7(join9(work, raw))) return raw;
|
|
22553
|
+
const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir3()));
|
|
22360
22554
|
if (!names.has(file)) {
|
|
22361
|
-
if (!
|
|
22555
|
+
if (!existsSync7(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.`);
|
|
22362
22556
|
if (names.size >= 8) throw new Error("8 pictures at most per reel.");
|
|
22363
22557
|
const name = safeAssetName(file, names.size);
|
|
22364
|
-
|
|
22365
|
-
copyFileSync2(file,
|
|
22558
|
+
mkdirSync7(dir2, { recursive: true });
|
|
22559
|
+
copyFileSync2(file, join9(dir2, name));
|
|
22366
22560
|
names.set(file, `assets/${name}`);
|
|
22367
22561
|
}
|
|
22368
22562
|
return names.get(file);
|
|
@@ -22399,51 +22593,60 @@ function headTop(work, duration3) {
|
|
|
22399
22593
|
return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
|
|
22400
22594
|
}
|
|
22401
22595
|
function ensureCutout(work, duration3) {
|
|
22402
|
-
const footage =
|
|
22596
|
+
const footage = join9(work, "footage.mp4"), cut = join9(work, "cut.webm"), stamp = join9(work, "cut.json"), still = join9(work, "assets", "still.jpg");
|
|
22403
22597
|
const st = statSync(footage);
|
|
22404
22598
|
const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
22405
22599
|
let cached2 = null;
|
|
22406
22600
|
try {
|
|
22407
|
-
cached2 = JSON.parse(
|
|
22601
|
+
cached2 = JSON.parse(readFileSync8(stamp, "utf8"));
|
|
22408
22602
|
} catch {
|
|
22409
22603
|
}
|
|
22410
22604
|
let made_s = 0;
|
|
22411
|
-
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime &&
|
|
22605
|
+
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync7(cut))) {
|
|
22412
22606
|
const started = Date.now();
|
|
22413
|
-
const r = sh("npx", ["--yes",
|
|
22414
|
-
if (r.status !== 0 || !
|
|
22607
|
+
const r = sh("npx", ["--yes", HF2, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
|
|
22608
|
+
if (r.status !== 0 || !existsSync7(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
|
|
22415
22609
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22416
22610
|
made_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22417
22611
|
cached2 = { ...key, head: headTop(work, duration3), made_s };
|
|
22418
|
-
|
|
22612
|
+
writeFileSync7(stamp, JSON.stringify(cached2));
|
|
22419
22613
|
}
|
|
22420
|
-
if (!
|
|
22421
|
-
|
|
22614
|
+
if (!existsSync7(still)) {
|
|
22615
|
+
mkdirSync7(join9(work, "assets"), { recursive: true });
|
|
22422
22616
|
sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
|
|
22423
22617
|
}
|
|
22424
22618
|
return { head: cached2.head, made_s };
|
|
22425
22619
|
}
|
|
22426
|
-
var planFile = (folder) =>
|
|
22620
|
+
var planFile = (folder) => join9(folder, ".cutroom", "plan.json");
|
|
22427
22621
|
async function composition(input) {
|
|
22428
22622
|
const pj = resolveProject(input.project);
|
|
22429
|
-
const work =
|
|
22623
|
+
const work = join9(pj.folder, ".cutroom");
|
|
22430
22624
|
const words = applyFixes(pj.words, input.fixes);
|
|
22431
|
-
const
|
|
22432
|
-
const
|
|
22433
|
-
const
|
|
22625
|
+
const all = input.effects ?? [];
|
|
22626
|
+
const local2 = localImages(work, input.images, all.filter((f) => !isCustom(f)));
|
|
22627
|
+
const brand = getBrand();
|
|
22628
|
+
const base = { duration: pj.duration, words, layout: input.layout, caption: input.caption, effects: local2.effects, images: local2.images, emphasis: input.emphasis, brand: brand ?? void 0 };
|
|
22629
|
+
const slots = all.filter(isCustom);
|
|
22630
|
+
if (slots.length) await call("compose", { ...base, custom: slots.map((c, k2) => ({ id: `cs-check${k2}00`, start: c.start, dur: customDur(c) })), check_only: true });
|
|
22631
|
+
const custom = slots.length ? await ensureCustom(work, slots, { words, layout: input.layout ?? "full-frame", brand, about: input.about }) : { slots: [], made: 0 };
|
|
22632
|
+
const cutout = await wantsCutout(input.layout, local2.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22633
|
+
const { html } = await call("compose", { ...base, custom: custom.slots.map((c) => ({ id: c.scene, start: c.start, dur: c.dur })), ...cutout ? { cutout: true, head: cutout.head } : {} });
|
|
22434
22634
|
await ensureFonts(work);
|
|
22435
|
-
|
|
22436
|
-
|
|
22635
|
+
writeFileSync7(join9(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
22636
|
+
let k = 0;
|
|
22637
|
+
const effects = all.map((f) => isCustom(f) ? custom.slots[k++] : f);
|
|
22638
|
+
return { pj, work, html, cutout, words, effects, made: custom.made };
|
|
22437
22639
|
}
|
|
22438
22640
|
var fmtT = (t) => `${t.toFixed(1)}s`;
|
|
22439
22641
|
var SHOWN = ["word", "text", "title", "number", "label", "eyebrow", "keyword", "items", "lines", "labels", "steps", "message", "before", "after", "note"];
|
|
22440
22642
|
var fxLine = (f) => SHOWN.filter((k) => f[k] !== void 0 && f[k] !== "").map((k) => k === "keyword" ? `comment "${f[k]}"` : Array.isArray(f[k]) ? f[k].join(" / ") : String(f[k])).join(" \xB7 ");
|
|
22441
22643
|
async function planReel(input) {
|
|
22442
|
-
const { pj, work, html, cutout, words } = await composition(input);
|
|
22443
|
-
const effects = [...
|
|
22644
|
+
const { pj, work, html, cutout, words, effects: filled, made } = await composition(input);
|
|
22645
|
+
const effects = [...filled].sort((a, b) => Number(a.start) - Number(b.start));
|
|
22646
|
+
const durOf = (f) => isCustom(f) ? customDur(f) : Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5);
|
|
22444
22647
|
const peak = (f) => {
|
|
22445
|
-
const d =
|
|
22446
|
-
const at = f.slug === "frame-rip" ? d * 0.5 : Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : 0.55));
|
|
22648
|
+
const d = durOf(f);
|
|
22649
|
+
const at = f.slug === "frame-rip" ? d * 0.5 : Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : isCustom(f) ? 0.7 : 0.55));
|
|
22447
22650
|
return Math.min(pj.duration - 0.1, Number(f.start) + at);
|
|
22448
22651
|
};
|
|
22449
22652
|
const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
|
|
@@ -22474,39 +22677,42 @@ async function planReel(input) {
|
|
|
22474
22677
|
const cur = sentences.filter((x) => x.t <= from).slice(-1);
|
|
22475
22678
|
return cur.length ? `(\u2026${cur[0].text.join(" ")})` : "";
|
|
22476
22679
|
};
|
|
22477
|
-
const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) +
|
|
22680
|
+
const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) + durOf(f)));
|
|
22478
22681
|
const moments = [
|
|
22479
|
-
...plainAt !== null ? [{ t: 0, end: effects.length ? Number(effects[0].start) : pj.duration, what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
|
|
22682
|
+
...plainAt !== null ? [{ t: 0, end: effects.length ? Number(effects[0].start) : pj.duration, what: "just you and the captions", tag: "you + captions", at: plainAt, custom: false }] : [],
|
|
22480
22683
|
...effects.map((f, k) => {
|
|
22684
|
+
if (isCustom(f)) return { t: Number(f.start), end: ends[k], what: `CUSTOM scene, made for this line: ${f.shows || f.title}`, tag: `CUSTOM ${f.title ?? ""}`, at: peak(f), custom: true };
|
|
22481
22685
|
const name = title(styles.effects, f.slug);
|
|
22482
|
-
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
|
|
22686
|
+
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f), custom: false };
|
|
22483
22687
|
})
|
|
22484
22688
|
];
|
|
22485
|
-
const shots =
|
|
22486
|
-
|
|
22487
|
-
|
|
22689
|
+
const shots = join9(work, "storyboard");
|
|
22690
|
+
rmSync5(shots, { recursive: true, force: true });
|
|
22691
|
+
writeFileSync7(join9(work, "index.html"), html);
|
|
22488
22692
|
let r;
|
|
22489
22693
|
try {
|
|
22490
|
-
r = sh("npx", ["--yes",
|
|
22694
|
+
r = sh("npx", ["--yes", HF2, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
|
|
22491
22695
|
} finally {
|
|
22492
|
-
|
|
22696
|
+
rmSync5(join9(work, "index.html"), { force: true });
|
|
22493
22697
|
}
|
|
22494
|
-
const pngs =
|
|
22698
|
+
const pngs = existsSync7(shots) ? readdirSync2(shots).filter((f) => f.endsWith(".png")).sort() : [];
|
|
22495
22699
|
if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
|
|
22496
22700
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22497
|
-
const board =
|
|
22701
|
+
const board = join9(pj.folder, "storyboard.jpg");
|
|
22498
22702
|
const cols = Math.min(4, moments.length);
|
|
22499
22703
|
const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
|
|
22500
|
-
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i",
|
|
22501
|
-
const
|
|
22704
|
+
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join9(shots, f)]);
|
|
22705
|
+
const mark = getBrand()?.highlight?.replace(/^#?/, "#") ?? "#8B8EF5";
|
|
22706
|
+
const badge = `,drawbox=x=0:y=0:w=270:h=480:color=${mark}:t=5,drawbox=x=8:y=8:w=86:h=26:color=${mark}:t=fill,drawtext=text='CUSTOM':x=14:y=13:fontsize=16:fontcolor=#0A0A0A`;
|
|
22707
|
+
const cells = moments.map((m, k) => `[${k}:v]scale=270:480,pad=270:540:0:0:#0A0A0A${m.custom ? badge : ""},drawtext=text='${label(`${fmtT(m.t)} ${m.tag}`)}':x=10:y=496:fontsize=18:fontcolor=#F7F3E8[c${k}]`).join(";");
|
|
22502
22708
|
const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
|
|
22503
|
-
const
|
|
22504
|
-
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${
|
|
22709
|
+
const stack2 = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
|
|
22710
|
+
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack2}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
|
|
22505
22711
|
if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
|
|
22506
22712
|
${tail(t.stderr)}`);
|
|
22507
|
-
|
|
22508
|
-
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
|
|
22509
|
-
|
|
22713
|
+
rmSync5(shots, { recursive: true, force: true });
|
|
22714
|
+
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes, about: input.about };
|
|
22715
|
+
writeFileSync7(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22510
22716
|
const text = [
|
|
22511
22717
|
`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)." : ""}`,
|
|
22512
22718
|
"",
|
|
@@ -22517,29 +22723,31 @@ ${tail(t.stderr)}`);
|
|
|
22517
22723
|
`Storyboard: ${board}`
|
|
22518
22724
|
].join("\n");
|
|
22519
22725
|
setStatus2(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
|
|
22520
|
-
|
|
22726
|
+
const nCustom = effects.filter(isCustom).length;
|
|
22727
|
+
const scenesNote = nCustom ? ` Rows marked CUSTOM are graphics Cutroom made for those lines (${made} new this time; each new one counts toward the daily limit of custom scenes, unchanged ones are reused). To change one, plan_reel again with that slot's request (e.g. request:'a phone with the feed'); to go back to a menu effect, put that effect in the slot instead.` : "";
|
|
22728
|
+
return { text, storyboard: board, plan, words_used: words.length, next: `Show this table as it is (plain words, no effect codes) and the storyboard.${scenesNote} On changes, plan_reel again with the new choice (keep the custom slots as plan_reel returned them, with their scene ids, so unchanged ones are reused). On a go, render_reel with plan:true and draft:true.` };
|
|
22521
22729
|
}
|
|
22522
22730
|
async function renderReel(input) {
|
|
22523
22731
|
if (input.plan) {
|
|
22524
22732
|
const folder = resolveProject(input.project).folder;
|
|
22525
|
-
if (!
|
|
22526
|
-
input = { ...JSON.parse(
|
|
22733
|
+
if (!existsSync7(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
|
|
22734
|
+
input = { ...JSON.parse(readFileSync8(planFile(folder), "utf8")), project: input.project, draft: input.draft };
|
|
22527
22735
|
}
|
|
22528
22736
|
const { pj, work, html, cutout } = await composition(input);
|
|
22529
|
-
|
|
22530
|
-
const out =
|
|
22531
|
-
const tmp =
|
|
22532
|
-
|
|
22737
|
+
mkdirSync7(join9(pj.folder, "renders"), { recursive: true });
|
|
22738
|
+
const out = join9(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
|
|
22739
|
+
const tmp = join9(work, "render.mp4");
|
|
22740
|
+
writeFileSync7(join9(work, "index.html"), html);
|
|
22533
22741
|
const started = Date.now();
|
|
22534
22742
|
let r;
|
|
22535
22743
|
try {
|
|
22536
|
-
r = sh("npx", ["--yes",
|
|
22744
|
+
r = sh("npx", ["--yes", HF2, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
|
|
22537
22745
|
} finally {
|
|
22538
|
-
|
|
22746
|
+
rmSync5(join9(work, "index.html"), { force: true });
|
|
22539
22747
|
}
|
|
22540
|
-
if (r.status !== 0 || !
|
|
22748
|
+
if (r.status !== 0 || !existsSync7(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
|
|
22541
22749
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22542
|
-
|
|
22750
|
+
renameSync4(tmp, out);
|
|
22543
22751
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22544
22752
|
const { report: checks, ...qa } = await qaVideo({ video: out, layout: input.layout });
|
|
22545
22753
|
addCheck2(pj.folder, checks);
|
|
@@ -22547,11 +22755,11 @@ ${tail(r.stderr) || tail(r.stdout)}`);
|
|
|
22547
22755
|
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." };
|
|
22548
22756
|
}
|
|
22549
22757
|
async function qaVideo(input) {
|
|
22550
|
-
const file = resolve3(input.video.replace(/^~/,
|
|
22551
|
-
if (!
|
|
22758
|
+
const file = resolve3(input.video.replace(/^~/, homedir3()));
|
|
22759
|
+
if (!existsSync7(file)) throw new Error(`No such file: ${file}`);
|
|
22552
22760
|
const tech = technicalChecks(file);
|
|
22553
|
-
const
|
|
22554
|
-
const { frames } = sampleFrames(file,
|
|
22761
|
+
const dir2 = mkdtempSync(join9(tmpdir(), "cutroom-qa-"));
|
|
22762
|
+
const { frames } = sampleFrames(file, dir2, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
|
|
22555
22763
|
let flags = [];
|
|
22556
22764
|
let judged = 0, judge_error = null;
|
|
22557
22765
|
try {
|
|
@@ -22561,7 +22769,7 @@ async function qaVideo(input) {
|
|
|
22561
22769
|
} catch (e) {
|
|
22562
22770
|
judge_error = e.message;
|
|
22563
22771
|
}
|
|
22564
|
-
|
|
22772
|
+
rmSync5(dir2, { recursive: true, force: true });
|
|
22565
22773
|
const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
|
|
22566
22774
|
const passed = !tech.issues.length && !flags.length && !judge_error;
|
|
22567
22775
|
const report = {
|
|
@@ -22586,6 +22794,196 @@ async function qaVideo(input) {
|
|
|
22586
22794
|
return { ...report, report: md };
|
|
22587
22795
|
}
|
|
22588
22796
|
|
|
22797
|
+
// src/community/methods.ts
|
|
22798
|
+
var MEMBER_CAROUSEL = false;
|
|
22799
|
+
var METHODS = {
|
|
22800
|
+
hooks: {
|
|
22801
|
+
title: "Video hooks",
|
|
22802
|
+
text: `Write hooks for a short video (Reel, Short, TikTok) spoken to camera. Every hook has three parts that say the same thing: the spoken line, on-screen text (3 to 7 words that add to the line, no punctuation except quotes) and the first visual (a moment from their footage moved to second zero, or a hook scene made for it).
|
|
22803
|
+
|
|
22804
|
+
1. Facts: who it's for, what they want or fear, the proof the user has, what the footage shows. Missing: ask in one message. One problem per run.
|
|
22805
|
+
2. Contrast in one line: "Viewers think A. This video shows B." If the video can't prove a contrast, say the idea is weak.
|
|
22806
|
+
3. Write 10 to 15 hooks across different formats and angles. Build each as context (topic at once), lean in (proof, pain, benefit or surprise), then snap back against what they expect.
|
|
22807
|
+
Formats: Fortune Teller (what changes for them next), Contrarian (a common belief, flipped fairly), Insider (what people inside know), Proven Winner (results first), Magnet (a sight they can't look away from), Connector (two unrelated things joined).
|
|
22808
|
+
Angles: shocking number, contrarian belief, personal confession, result transformation, industry death, secret system, time compression, cost of inaction, insider leak, story in motion.
|
|
22809
|
+
4. Rules:
|
|
22810
|
+
- Topic obvious in line one, under 15 words. Pause there: could a friend say what the video is about?
|
|
22811
|
+
- About the viewer (their problem, want or belief), not the user, their tool, team or routine. The user's story comes later, as proof.
|
|
22812
|
+
- Line two is proof: results for others beat their own results beat effort.
|
|
22813
|
+
- Every fact at its true size: an average stays an average, a count keeps its meaning ("posted three times" never becomes "flopped three times"), no times or lengths they didn't give, quote only words they gave. A missing number is a [placeholder].
|
|
22814
|
+
- Definitive, one breath, contractions, no em dashes, no clich\xE9s, no view promises.
|
|
22815
|
+
5. score_hooks with the spoken lines and the audience. Take the top 3 and fix every failure its checks name (the exact words), then score the fixed lines once more.
|
|
22816
|
+
6. Give each of the top 3 its on-screen text and first visual. Sound off: do picture and text give the topic? Eyes closed: does the line?
|
|
22817
|
+
7. Lock-in for the winner: the next 2 to 3 sentences that pay the hook off with proof, a picture of the result or the first step. No backstory, no intro.
|
|
22818
|
+
|
|
22819
|
+
Output: the top 3, each as Say / On-screen text / First visual / format / score. Then the winner, why in one line, its lock-in lines, and facts to verify before filming.`
|
|
22820
|
+
},
|
|
22821
|
+
script: {
|
|
22822
|
+
title: "Short script (8 lines)",
|
|
22823
|
+
text: `Turn one idea into a 70 to 100 word short script.
|
|
22824
|
+
|
|
22825
|
+
1. Thesis: "This video will prove that ___." One video, one idea.
|
|
22826
|
+
2. Payoff first: "By the end, viewers understand ___." This is the ending; write it before any hook.
|
|
22827
|
+
3. Story spine in 5 sentences: before, problem, cost, discovery, after. Prefer a real story with a number the user can prove.
|
|
22828
|
+
4. Hook: follow get_method('hooks') steps 2 to 6 in short (5 or 6 hooks, score_hooks, fix the failures). If the user brings a hook, run it through score_hooks and fix what fails. The hook ships as spoken line, on-screen text and first visual.
|
|
22829
|
+
5. The 8 lines: 1 hook (topic in line one, about the viewer, under 15 words), 2 lock-in (proof, a picture of the result or the first step; never backstory), 3 setup (only needed context), 4 conflict (the cost), 5 turning point, 6 mechanism or proof (proof beats explanation), 7 rehook only for scripts over 20 seconds ("But that wasn't the real problem."), 8 payoff that closes the hook's question. Optional single CTA.
|
|
22830
|
+
6. Question test after every line: what question does it create? No question and no useful answer: delete it.
|
|
22831
|
+
7. Cut pass: remove repeats, long intros, generic claims, extra CTAs. Aloud pass: most sentences under 12 words, contractions, 70 to 100 words.
|
|
22832
|
+
8. Send the script to the score_script tool. Below its pass mark, revise the weakest two lines once and score again.
|
|
22833
|
+
|
|
22834
|
+
Rules: definitive statements, no em dashes, name the outcome not the tool, one CTA at most, every number provable, correctly attributed and at its true size (an average stays an average, a count keeps its meaning).
|
|
22835
|
+
|
|
22836
|
+
Output: thesis, payoff, spine, chosen hook with its on-screen text and first visual, the numbered script with word count, the score, and numbers to verify before filming.`
|
|
22837
|
+
},
|
|
22838
|
+
caption: {
|
|
22839
|
+
title: "Post caption and comment CTA",
|
|
22840
|
+
text: `Write the post caption for a short video.
|
|
22841
|
+
|
|
22842
|
+
1. First line: the hook in under 120 characters, lowercase is fine if that is the user's style.
|
|
22843
|
+
2. Two or three short lines on what the viewer gets. No list of hashtags in the body.
|
|
22844
|
+
3. CTA: one keyword the viewer comments ("comment LOOP and I'll send you the playbook"). The keyword must match a thing the user will actually send. Ask the user for the keyword; never invent the lead magnet.
|
|
22845
|
+
4. Rules: no em dashes, no placeholder names, nothing the video does not support.
|
|
22846
|
+
|
|
22847
|
+
Output: the caption ready to paste, and the keyword with what gets sent.`
|
|
22848
|
+
},
|
|
22849
|
+
finish_video: {
|
|
22850
|
+
title: "Finishing a raw video",
|
|
22851
|
+
text: `The user gave you a raw video of them talking. Steps:
|
|
22852
|
+
|
|
22853
|
+
1. prepare_footage with the file path (add language if they do not speak English). If it says it could not hear anyone talking, tell them plainly and stop. If it returns a warning (a wide or already-edited video), tell them plainly and ask for the raw vertical take; go on only if they say this one is fine.
|
|
22854
|
+
2. From the timed transcript, list words that look misheard (names, brands, numbers) and confirm the spelling. Pass corrections as fixes later.
|
|
22855
|
+
3. Opening check: send what they say in the first 10 to 15 seconds to score_hooks as one text, with who the video is for. Tell them in plain words: who it's about (the viewer, or them and their tool), the second the topic becomes clear, each failure with its exact words, and when the contrast or payoff first comes. Suggest lines to cut or move (e.g. "start at 0:10, where you say most brands copy trends"). Cutroom keeps the take as filmed: to cut or move lines they trim the raw video or refilm the opening, then prepare_footage again. For a new opening, get_method('hooks'). Keep the chosen hook's on-screen text and first visual.
|
|
22856
|
+
4. Brand: show_brand. If none is set, offer to set one now (it changes every color in the edit); if they skip, use the default look.
|
|
22857
|
+
5. Plan: list_styles (share the gallery link so they can see each style moving), then get_method('edit_plan') and follow it, with the hook's on-screen text and first visual in the hook scene. Show the plan as the plain table it asks for.
|
|
22858
|
+
6. plan_reel with that choice. Show its table and the storyboard image (one real frame per scene). They change anything ("move the checklist to 30s", "swap orbit for card wall", "serif captions"): plan_reel again. No B-roll unless they ask.
|
|
22859
|
+
- Effects or layouts with needs "cutout" first cut them out of the background on this computer: about 6 seconds per second of video, once per video. Say so before you plan one.
|
|
22860
|
+
- Effects with pictures "optional" can use their own images (file paths); ask if a scene would be better with a screenshot, otherwise they work without.
|
|
22861
|
+
7. On a go: render_reel with plan:true and draft:true. Tell them where the file is.
|
|
22862
|
+
8. QA: technical issues (frozen picture, loudness, black frames, size) are real; fix them. Look flags are spots to check: grab each flagged frame (ffmpeg) and look at it yourself, then say which are real problems (with the fix) and which are the design.
|
|
22863
|
+
9. They approve the draft: render_reel with plan:true (no draft) for the final. After changes: plan_reel, then render again.
|
|
22864
|
+
10. Offer the post caption next (get_method('caption')).`
|
|
22865
|
+
},
|
|
22866
|
+
edit_plan: {
|
|
22867
|
+
title: "Planning the edit scene by scene",
|
|
22868
|
+
text: `You have the timed transcript and list_styles. Plan the edit so every line has something on screen, then show it so anyone can read it.
|
|
22869
|
+
|
|
22870
|
+
1. Scenes: split the transcript into scenes, one per sentence or idea, about 1.5 to 4 seconds each. The hook (first 1 to 2 seconds) is its own scene.
|
|
22871
|
+
2. Every scene gets a visual. No empty stretches: something on screen changes every 2 to 4 seconds. Effects can run back to back (the next starts when the last ends); they never overlap.
|
|
22872
|
+
3. Pick the visual from what the line does, using only effects in the menu:
|
|
22873
|
+
- a number: Number pop; money or revenue: Money count; a percentage or rate: Progress ring
|
|
22874
|
+
- asking an AI, a prompt, "I asked ChatGPT/Claude": AI chat (their prompt, its answer)
|
|
22875
|
+
- a conversation, a DM, "my friend texted me": Text thread
|
|
22876
|
+
- results coming in, sales, views, sign-ups landing: Notification stack
|
|
22877
|
+
- a decision ("should you...?") or a process: Flowchart; a simple step-by-step system: Hand-drawn pipeline
|
|
22878
|
+
- quoting a post or comment: Post card; a real client quote: Testimonial (only quotes they give you)
|
|
22879
|
+
- a comparison, A vs B, old vs new: Variant cards or Before / after
|
|
22880
|
+
- steps ("one, two, three"): each step its own visual, or a Checklist that ticks as they are said
|
|
22881
|
+
- an app, tool, dashboard or result: Phone mockup with their screenshot (ask for it), else UI world
|
|
22882
|
+
- the key line or a bold claim: Glass type, Text behind you, or Frame rip
|
|
22883
|
+
- "part 3", a title, a topic: Cover story or Paper cut
|
|
22884
|
+
- many examples, creators, posts: Card wall or Orbit (their pictures if they have them)
|
|
22885
|
+
- pointing at something on screen: Freeze and label or Doodle
|
|
22886
|
+
- the brand or style: Brand kit card
|
|
22887
|
+
- the call to action: Comment to DM (their keyword), Follow tap, or CTA card (their offer and button), as the last scene
|
|
22888
|
+
The hook gets the strongest visual (Text behind you, Orbit, Card wall or Pull-back in Full-frame; Paper cut, Frame rip or Cover story in Explainer panel or Split reel). It carries the chosen hook's on-screen text (3 to 7 words, the same thing the line says) and shows its first visual: their screenshot or picture of it when they have one, else the effect that best shows it.
|
|
22889
|
+
4. Do not repeat the same effect in two scenes in a row.
|
|
22890
|
+
5. Layout: Explainer panel or Split reel when most scenes have a visual (the space above stays busy); Full-frame when only a few do. Captions: Two-tier or Box unless they want another. Text behind you, Orbit and Card wall need Full-frame or a cut-out layout; in Explainer panel or Split reel use the others.
|
|
22891
|
+
Custom scenes (Explainer panel or Split reel only): by default the hook and the 2 or 3 key moments (the main claim, the method, the proof) are custom: {slug:'custom', start, dur} covering the whole line or idea (3 to 10 seconds), with job when it is clear (number, comparison, process, app, social-proof, list, chart, map, emphasis). Cutroom makes each one for that line, in their brand, filling the space above the face; every other scene uses the menu. For the hook slot, put the chosen hook's on-screen text and first visual in its request (e.g. request:'on-screen text "Trends without copying", a feed where 3 reels get picked'). No menu effect overlaps a custom scene. In the table write "Custom: <what it will show>". Requests like "make the 13s scene custom", "change the hook scene to a phone" (request:'a phone scrolling the feed' on that slot) or "use number pop again" (that menu effect back in the slot): plan_reel again. About 10 new custom scenes a day per member, so keep the ones they like (pass the slots back as plan_reel returned them).
|
|
22892
|
+
6. Effect text: only the user's own words and numbers, short (the effect's limits are in list_styles). For Comment to DM or Follow tap, ask their Instagram handle once (if you don't know it) and use it.
|
|
22893
|
+
7. Show the plan as a table, plain words only, never effect codes:
|
|
22894
|
+
| Time | What you say | What shows on screen |
|
|
22895
|
+
One row per scene, then one line for the look ("Layout: your face at the bottom, graphics on top. Captions: one big word in your color.").
|
|
22896
|
+
Say which scenes need their pictures or screenshots, and if any visual cuts them out of the background (about 6 seconds per second of video, once).
|
|
22897
|
+
8. Then call plan_reel with the same choice and show the storyboard. Change rows on request and plan again. Nothing renders until they say go.`
|
|
22898
|
+
},
|
|
22899
|
+
breakdown_writeup: {
|
|
22900
|
+
title: "Writing up a reel breakdown",
|
|
22901
|
+
text: `You have a breakdown report. First open sheet.jpg in its folder (every sampled frame, labelled with its time) and check the report's look against it: the matcher names the closest Cutroom parts and can be wrong (a caption it calls "none", an effect that is not there). Trust what you see; say where you corrected it. Then write it up for the user:
|
|
22902
|
+
- The hook in one line and what the first frame does without sound.
|
|
22903
|
+
- The structure as 3 to 6 beats: what happens and why it keeps people watching.
|
|
22904
|
+
- The look in plain words, using the layout, caption and effect names in the report, with timestamps.
|
|
22905
|
+
- "Make it yours": how the user would film and say their own version, on their topic, as a render plan: one layout, one caption style and 2 to 4 effects tied to moments, using ONLY effects and layouts marked "in your menu" (in_menu true). For a look they saw that is not in the menu, name the closest one that is.
|
|
22906
|
+
Rules: never copy the creator's words, footage, logos or voice. Every number comes from the report. Mention that beat words are approximate when words_timing says "approximate".`
|
|
22907
|
+
},
|
|
22908
|
+
copy_reel: {
|
|
22909
|
+
title: "Copying a reel's format onto your own topic",
|
|
22910
|
+
text: `The user wants their own version of a reel: its structure, pacing and look, with their topic, words and face. Never their words, footage, logos, voice or catchphrases.
|
|
22911
|
+
|
|
22912
|
+
1. Format card first. If <studio>/references/formats/ already has a card for this reel (same link or name), read it and go to step 4.
|
|
22913
|
+
2. breakdown_reel with the link or file. If it can't fetch an Instagram link, don't stop: ask them to save the reel to their computer and give you the file path, then run breakdown_reel on the file. Then open sheet.jpg in the breakdown folder (every sampled frame, labelled with its time) and the report together. The matcher names the closest Cutroom parts and can be wrong: trust the frames.
|
|
22914
|
+
3. Write the format card to <studio>/references/formats/<short-name>.md (lowercase, dashes). Keep what you saw separate from what you guess. Sections:
|
|
22915
|
+
- Source: link, creator, stats at capture, date.
|
|
22916
|
+
- Why it works: one line.
|
|
22917
|
+
- Hook: its angle (one of the hooks method's angles), how long it runs, what the first frame does with the sound off.
|
|
22918
|
+
- Beats: a table, one row per beat from the report: | # | time | seconds | words | what the beat does (setup, proof, list item, turn, payoff, CTA) |. Describe each beat's job, never its sentences.
|
|
22919
|
+
- Pace: words per second (words / seconds talking), cuts per minute, how often the picture changes.
|
|
22920
|
+
- Look: layout, caption style and the effects with the moments they land on, in Cutroom names. For a look not in the menu, name the closest one that is.
|
|
22921
|
+
- Ending: how it closes and the CTA pattern (keyword comment, follow, link).
|
|
22922
|
+
- Slots: what changes per topic (hook, each beat's content, proof, CTA keyword).
|
|
22923
|
+
Tell the user where the card is: they can reuse it on any topic without another breakdown.
|
|
22924
|
+
4. Ask for what you don't have: the topic, their proof (a number, result or story they own), the CTA keyword with what they will send, whether they'll be on camera or not (voice-over, faceless, an AI clone), and their target length when the source runs past about 45 seconds.
|
|
22925
|
+
5. Hooks in the same angle: write 5 to 8 new hooks in the card's hook angle (hooks method rules), score_hooks, keep the winner. New words only.
|
|
22926
|
+
6. Script: one line per beat, the same jobs in the same order. Length: each beat within about 20% of its length at the card's words per second; with a shorter target, scale every beat down evenly and merge two neighbouring beats only when they do the same job (never drop the hook, the proof or the payoff). Definitive statements, contractions, no em dashes, every number theirs and provable. A source with several CTAs gets one (two at most). score_script; below the pass mark, fix the two weakest lines once and score again, then keep whichever version scored higher and say which you kept.
|
|
22927
|
+
7. Copy check before showing: no sentence shares more than four words in a row with the source transcript, and none of the creator's names, numbers, stories or catchphrases appear. Rewrite any that do.
|
|
22928
|
+
8. Show one table and wait for a go: | Beat | Their beat does | Your line | On screen |. Then the hook score, the script score and numbers to verify.
|
|
22929
|
+
9. On a go:
|
|
22930
|
+
- On camera: the shot list. Per beat: the line, framing (from the card: close-up, medium, where they look), anything to capture besides talking (a screenshot, a prop, a screen recording), and one delivery note.
|
|
22931
|
+
- Not on camera (voice-over, faceless, AI clone): the scene list. Per beat: the line, what is on screen (their real screenshot, screen recording, photo or product, never stock that pretends to be them), and the assets to gather.
|
|
22932
|
+
Either way give the total length. Save the script and the list to <studio>/inbox/<topic>-script.md so they are next to the take when it lands.
|
|
22933
|
+
10. On camera, after they film and drop the take in inbox/: get_method('finish_video') and follow it, planning the edit from the card: its layout and caption style, effects on the matching beats (in the menu only). Show the plan; nothing renders until they say go. Not on camera: say which tool or editor turns the scene list and their voice-over into the video, and stop there.
|
|
22934
|
+
|
|
22935
|
+
Say plainly that this copies a format, not a video, and that results depend on their topic and delivery.`
|
|
22936
|
+
},
|
|
22937
|
+
copy_carousel: {
|
|
22938
|
+
internal: !MEMBER_CAROUSEL,
|
|
22939
|
+
title: "Copying a carousel's format onto your own topic",
|
|
22940
|
+
text: `The user wants their own version of a carousel (Instagram carousel or TikTok photo slideshow): its structure and, if they want, its look, with their topic and words, as new slides. Never the creator's images, words, names, numbers, stories, handle or logo.
|
|
22941
|
+
|
|
22942
|
+
1. Format card first. If <studio>/references/formats/ already has a carousel card for this one (same link or name), read it and its style.json and go to step 5.
|
|
22943
|
+
2. breakdown_carousel with the link (or a folder of slide images if the link fails: ask them to save the slides and give you the folder). Open sheet.jpg, then every slide in slides/ at full size, and breakdown.md (measured colors per slide, caption).
|
|
22944
|
+
3. Per slide, write down what you see: the layout (one of: text-hook, image-hook, image-caption, list-item, quote, before-after, screenshot-label, cta; the closest one when none fits), each text block's role (kicker, headline, body, number, label, handle) and word count, the text treatment (plain, outline, highlight-box, shadow; serif or sans or condensed or script or mono; weight; case; size as a share of slide width; alignment), the colors (from the measured palette: background, text, accent), and the image's job (hook photo, full-bleed background, screenshot, illustration, icon, none) and where it sits. Deck level: slide count, the order of jobs, where the CTA lands, swipe cues, page numbers.
|
|
22945
|
+
4. Write two files in <studio>/references/formats/:
|
|
22946
|
+
- carousel-<short-name>.md, the format card: Source (link, creator, stats at capture, date). Why it works (one line). Hook slide: its angle (a hooks-method angle), words, what it promises, why you swipe. A table | # | layout | job (hook, setup, list item, proof, turn, payoff, CTA) | words | image role |: jobs, never their sentences. Text budget (words per slide, longest line). Ending and CTA pattern. Slots (what changes per topic). Keep what you saw apart from what you guess.
|
|
22947
|
+
- carousel-<short-name>.style.json, the look as a style (format below): its colors from the measured palette, fonts mapped to the nearest Google font (serif: Instrument Serif, Playfair Display, DM Serif Display, Cormorant Garamond, EB Garamond, Libre Baskerville, Fraunces, Lora; sans: Inter, Montserrat, Poppins, DM Sans, Manrope, Plus Jakarta Sans, Space Grotesk, Archivo; condensed: Oswald, Bebas Neue, Anton, Archivo Black; mono: Roboto Mono, JetBrains Mono; hand: Caveat, Kalam, Permanent Marker; Korean: Noto Sans KR, Noto Serif KR), and its treatment, alignment, margins, page number, handle and swipe cue, and its size: the source's shape ([1080,1350] portrait, [1080,1080] square, [1080,1440] or [1080,1920] for TikTok).
|
|
22948
|
+
Tell them where the card is: it works on any topic without another breakdown.
|
|
22949
|
+
5. Ask for what you don't have: the topic, their proof (a number, result or story they own), the CTA (keyword and what they send, or follow or save), their handle, and the look: (a) the original's style (the style.json), (b) their brand (brand "mine": their brand kit's colors and font on the original's layout), or (c) another style by name (render_carousel lists them). Never pick a look for them.
|
|
22950
|
+
6. Hook slide: 5 to 8 hooks in the card's angle (hooks method rules, written to be read, not said), score_hooks, keep the winner.
|
|
22951
|
+
7. Slide text: one slide per source slide, the same jobs in the same order, within the card's text budget (a slide's words within about 20% of the source's). A list carousel keeps its count unless they ask; every list item is theirs. Definitive statements, no em dashes, every number theirs and provable. Mark the accent words with *stars* only where the source has an accent.
|
|
22952
|
+
8. Copy check before showing: no sentence shares more than four words in a row with the source slides or caption, and none of the creator's names, numbers, stories, catchphrases or handle appear. Rewrite any that do.
|
|
22953
|
+
9. Images, per slide that has one, in this order: their own (photos, screenshots, product, a folder they point to); else draw it (art: chart, chat, checklist, stat, browser, or your own SVG in an html art); else drop the image and let the text carry the slide. Never stock photos that pretend to be them, never the source's images, never anything from Pinterest. Say which slides need their pictures.
|
|
22954
|
+
10. Show one table and wait for a go: | Slide | Layout | Their slide does | Your text | Image |. Then the hook score and numbers to verify.
|
|
22955
|
+
11. On a go: render_carousel with the deck. Open preview.jpg and each slide yourself; fix every flag that is a real problem (text shrunk a lot or overflowing, low contrast, missing font or image) before showing them. Show the preview and say where the PNGs are.
|
|
22956
|
+
12. Edits by chat: change only what they asked with edits ("move the image down": image.position "bottom" or focus "50% 80%"; "make the background black": bg "#000000" on that slide, or style for all; "bigger headline": scale 1.15; new words: headline or body). Re-render, open the new slide, show it. A deck-wide look change (another style, their brand on or off) is style or a deck with a new brand: every slide re-renders.
|
|
22957
|
+
|
|
22958
|
+
Deck format (render_carousel deck):
|
|
22959
|
+
{ "title": "short name", "style": "<preset name> | <path to style.json> | { inline style } | { "path": "...", "overrides": {...} }", "brand": "mine" (optional), "handle": "@them", "slides": [ slide, ... ] }
|
|
22960
|
+
A slide: { "layout": one of the 8, plus the fields it uses:
|
|
22961
|
+
text-hook: kicker?, headline, body?, image? or art?
|
|
22962
|
+
image-hook / image-caption: headline, body?, kicker?, image (position "full" for a full-bleed photo with text over it, "top", "bottom", "framed") or art
|
|
22963
|
+
list-item: number ("01", "3."), headline, body?, image? or art?
|
|
22964
|
+
quote: quote, attribution (only real quotes they give you)
|
|
22965
|
+
before-after: headline?, before { label, text, image? or art? }, after { same }
|
|
22966
|
+
screenshot-label: kicker?, headline, sub?, body?, image (their screenshot) or art, note? (a short callout); text sits above the screenshot; with both image and art, the art is a small drawing under it
|
|
22967
|
+
cta: headline, keyword?, ask?, body?, image? or art? (a small picture under the text)
|
|
22968
|
+
text-hook, image-*, screenshot-label also take sub (a second, smaller headline line)
|
|
22969
|
+
any slide: bg, color, accent (hex), scale (text size, 1 = normal), align (left, center), valign (top, center, bottom), treatment (plain, outline, highlight-box, shadow) }
|
|
22970
|
+
image: { "src": a file path (relative to the deck folder or absolute), "position", "focus": "50% 30%", "fit": "cover" or "contain", "grade": "none", "mono", "warm", "dim" }
|
|
22971
|
+
art: { "kind": "chart", "bars": [{ "label", "value" }], "unit" } | { "kind": "chat", "messages": [{ "from": "me" or "them", "text" }] } | { "kind": "checklist", "items": [{ "text", "done" }] } | { "kind": "stat", "value", "label" } | { "kind": "browser", "url", "title", "lines": [] } | { "kind": "html", "html": "<svg ...>...</svg>" }
|
|
22972
|
+
A style (all fields optional, sizes are shares of slide width): { "size": [1080, 1350], "colors": { "bg", "text", "accent", "muted", "card", "stroke" (outline color), "box", "boxText" (highlight-box) }, "fonts": { "headline", "body", "mono" }, "headline": { "case": "none" | "upper" | "lower" | "title", "weight", "size": 0.085, "lineHeight", "letterSpacing" (em), "italicAccent": true, "underlineAccent": true (accent words underlined in the accent color instead of colored) }, "body": { "size": 0.034, "weight", "lineHeight" }, "text": { "treatment", "align", "valign", "strokeWidth" (outline, share of font size, 0.09), "offset" (extra space above top text or below bottom text, share of height), "quoteMark": false to drop the big quote mark }, "frame": { "margin": 0.08, "pageNumber": "none" | "top-right" | "top-left" | "bottom-right" | "bottom-center", "handlePos": "none" | "bottom-left" | "bottom-center" | "top-left", "swipeCue": "none" | "arrow" | "text" }, "image": { "radius", "grade", "position" }, "texture": { "grain": 0 to 0.3, "vignette": 0 to 0.7 } }
|
|
22973
|
+
|
|
22974
|
+
Say plainly that this copies a format, not a post, and that results depend on their topic. Nothing gets posted.`
|
|
22975
|
+
},
|
|
22976
|
+
teardown_writeup: {
|
|
22977
|
+
title: "Writing up a creator teardown",
|
|
22978
|
+
text: `You have a teardown report. Write it up for the user:
|
|
22979
|
+
- Their system in 4 lines: format, pacing, captions, CTA keyword.
|
|
22980
|
+
- What their best-performing quarter does differently, using only the report's numbers.
|
|
22981
|
+
- The gap: what they use that the user does not, and the one or two changes worth testing first. Recommend only effects marked in_menu (the user can render those today); if the biggest difference is an effect that is not in the menu, say so and name the closest one that is. Pacing (cuts per minute), length and caption style count as changes too.
|
|
22982
|
+
Rules: study only, never reuse their words, footage or branding. Plays are the platform's numbers at capture. Trial reels are not in the list.`
|
|
22983
|
+
}
|
|
22984
|
+
};
|
|
22985
|
+
var MEMBER_METHODS = Object.fromEntries(Object.entries(METHODS).filter(([, m]) => !m.internal));
|
|
22986
|
+
|
|
22589
22987
|
// src/community/local/prompts.ts
|
|
22590
22988
|
var say = (label, value) => value?.trim() ? ` ${label}: ${value.trim()}.` : "";
|
|
22591
22989
|
var PROMPTS = [
|
|
@@ -22596,6 +22994,13 @@ var PROMPTS = [
|
|
|
22596
22994
|
args: [{ name: "link", description: "The reel (Instagram link or a video file path)" }, { name: "topic", description: "Your topic (optional)" }],
|
|
22597
22995
|
ask: (a) => `Copy a reel's format for me: get_method('copy_reel') and follow it step by step.${say("Reel", a.link)}${say("My topic", a.topic)}${a.link ? "" : " Ask me for the reel first."}`
|
|
22598
22996
|
},
|
|
22997
|
+
{
|
|
22998
|
+
name: "copy-carousel",
|
|
22999
|
+
title: "Copy a carousel",
|
|
23000
|
+
description: "Your version of an Instagram or TikTok carousel: its structure and look (or your brand), your topic and words, as new slides.",
|
|
23001
|
+
args: [{ name: "link", description: "The carousel (Instagram or TikTok link, or a folder of slide images)" }, { name: "topic", description: "Your topic (optional)" }],
|
|
23002
|
+
ask: (a) => `Copy a carousel for me: get_method('copy_carousel') and follow it step by step.${say("Carousel", a.link)}${say("My topic", a.topic)}${a.link ? "" : " Ask me for the carousel first."}`
|
|
23003
|
+
},
|
|
22599
23004
|
{
|
|
22600
23005
|
name: "finish-video",
|
|
22601
23006
|
title: "Finish a raw video",
|
|
@@ -22652,22 +23057,641 @@ var PROMPTS = [
|
|
|
22652
23057
|
args: [],
|
|
22653
23058
|
ask: () => "Check my Cutroom setup with cutroom_doctor and give me a short checklist with the fix for anything missing."
|
|
22654
23059
|
}
|
|
22655
|
-
];
|
|
23060
|
+
].filter((p) => MEMBER_CAROUSEL || p.name !== "copy-carousel");
|
|
23061
|
+
|
|
23062
|
+
// src/community/local/carousel.ts
|
|
23063
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
23064
|
+
import { homedir as homedir5 } from "node:os";
|
|
23065
|
+
import { isAbsolute as isAbsolute3, join as join11, resolve as resolve5 } from "node:path";
|
|
23066
|
+
|
|
23067
|
+
// src/carousel.ts
|
|
23068
|
+
import { execFileSync as execFileSync2, spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
|
|
23069
|
+
import { copyFileSync as copyFileSync3, existsSync as existsSync8, mkdirSync as mkdirSync8, readdirSync as readdirSync3, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
|
|
23070
|
+
import { homedir as homedir4 } from "node:os";
|
|
23071
|
+
import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join10, resolve as resolve4 } from "node:path";
|
|
23072
|
+
|
|
23073
|
+
// src/scrapecreators.ts
|
|
23074
|
+
import { createWriteStream as createWriteStream2 } from "node:fs";
|
|
23075
|
+
import { Readable as Readable2 } from "node:stream";
|
|
23076
|
+
import { pipeline as pipeline2 } from "node:stream/promises";
|
|
23077
|
+
async function download2(url2, target, fetchImpl = fetch) {
|
|
23078
|
+
const res = await fetchImpl(url2);
|
|
23079
|
+
if (!res.ok || !res.body) throw new Error(`Download failed: HTTP ${res.status}`);
|
|
23080
|
+
await pipeline2(Readable2.fromWeb(res.body), createWriteStream2(target));
|
|
23081
|
+
return target;
|
|
23082
|
+
}
|
|
23083
|
+
|
|
23084
|
+
// src/carousel.ts
|
|
23085
|
+
function palette(file, k = 6) {
|
|
23086
|
+
const raw = spawnSync4("ffmpeg", ["-v", "error", "-i", file, "-vf", "scale=72:90", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { maxBuffer: 1 << 22 });
|
|
23087
|
+
if (raw.status !== 0 || !raw.stdout?.length) return [];
|
|
23088
|
+
return paletteFromRgb(raw.stdout, k);
|
|
23089
|
+
}
|
|
23090
|
+
function paletteFromRgb(px, k = 6) {
|
|
23091
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
23092
|
+
const total = Math.floor(px.length / 3);
|
|
23093
|
+
for (let i = 0; i + 2 < px.length; i += 3) {
|
|
23094
|
+
const key = px[i] >> 4 << 8 | px[i + 1] >> 4 << 4 | px[i + 2] >> 4;
|
|
23095
|
+
const b = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
|
|
23096
|
+
b.n++;
|
|
23097
|
+
b.r += px[i];
|
|
23098
|
+
b.g += px[i + 1];
|
|
23099
|
+
b.b += px[i + 2];
|
|
23100
|
+
buckets.set(key, b);
|
|
23101
|
+
}
|
|
23102
|
+
const merged = [];
|
|
23103
|
+
for (const b of [...buckets.values()].sort((x, y) => y.n - x.n)) {
|
|
23104
|
+
const c = { n: b.n, r: b.r / b.n, g: b.g / b.n, b: b.b / b.n };
|
|
23105
|
+
const near = merged.find((m) => Math.hypot(m.r - c.r, m.g - c.g, m.b - c.b) < 48);
|
|
23106
|
+
if (near) {
|
|
23107
|
+
const n = near.n + c.n;
|
|
23108
|
+
near.r = (near.r * near.n + c.r * c.n) / n;
|
|
23109
|
+
near.g = (near.g * near.n + c.g * c.n) / n;
|
|
23110
|
+
near.b = (near.b * near.n + c.b * c.n) / n;
|
|
23111
|
+
near.n = n;
|
|
23112
|
+
} else merged.push(c);
|
|
23113
|
+
}
|
|
23114
|
+
const hex = (v) => Math.round(v).toString(16).padStart(2, "0");
|
|
23115
|
+
return merged.sort((x, y) => y.n - x.n).slice(0, k).map((c) => ({ hex: `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`.toUpperCase(), share: Math.round(c.n / total * 100) / 100 }));
|
|
23116
|
+
}
|
|
23117
|
+
function tileSheet(files, out, cell = 324) {
|
|
23118
|
+
if (!files.length) return null;
|
|
23119
|
+
const tmp = `${out}.parts`;
|
|
23120
|
+
mkdirSync8(tmp, { recursive: true });
|
|
23121
|
+
try {
|
|
23122
|
+
const h = Math.round(cell * 1.25 / 2) * 2;
|
|
23123
|
+
const parts = files.map((f, i) => {
|
|
23124
|
+
const part = join10(tmp, `${String(i).padStart(3, "0")}.jpg`);
|
|
23125
|
+
const fit = `scale=${cell}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=${cell}:${h}:(ow-iw)/2:(oh-ih)/2:color=0x202020`;
|
|
23126
|
+
const labelled = spawnSync4("ffmpeg", ["-v", "error", "-y", "-i", f, "-vf", `${fit},drawtext=text='${i + 1}':x=10:y=10:fontsize=26:fontcolor=white:box=1:boxcolor=black@0.75:boxborderw=7`, part]);
|
|
23127
|
+
if (labelled.status !== 0) execFileSync2("ffmpeg", ["-v", "error", "-y", "-i", f, "-vf", fit, part]);
|
|
23128
|
+
return part;
|
|
23129
|
+
});
|
|
23130
|
+
const list = join10(tmp, "list.txt");
|
|
23131
|
+
writeFileSync8(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
|
|
23132
|
+
const cols = Math.min(5, parts.length), rows = Math.ceil(parts.length / cols);
|
|
23133
|
+
execFileSync2("ffmpeg", ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", list, "-vf", `tile=${cols}x${rows}:padding=6:color=black`, "-frames:v", "1", "-q:v", "3", out]);
|
|
23134
|
+
return out;
|
|
23135
|
+
} catch {
|
|
23136
|
+
return null;
|
|
23137
|
+
} finally {
|
|
23138
|
+
rmSync6(tmp, { recursive: true, force: true });
|
|
23139
|
+
}
|
|
23140
|
+
}
|
|
23141
|
+
var IMAGE_EXT = /\.(jpe?g|png|webp|heic)$/i;
|
|
23142
|
+
async function saveCarousel(meta2, dir2, opts = {}) {
|
|
23143
|
+
const slidesDir = join10(dir2, "slides");
|
|
23144
|
+
mkdirSync8(slidesDir, { recursive: true });
|
|
23145
|
+
const files = [];
|
|
23146
|
+
if (meta2) {
|
|
23147
|
+
for (const [i, img] of meta2.images.entries()) {
|
|
23148
|
+
const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}.jpg`);
|
|
23149
|
+
if (!existsSync8(target)) await download2(img.url, target, opts.fetchImpl);
|
|
23150
|
+
files.push(target);
|
|
23151
|
+
}
|
|
23152
|
+
} else if (opts.folder) {
|
|
23153
|
+
const names = readdirSync3(opts.folder).filter((n) => IMAGE_EXT.test(n)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
|
|
23154
|
+
if (!names.length) throw new Error(`No images in ${opts.folder} (jpg, png, webp).`);
|
|
23155
|
+
for (const [i, n] of names.entries()) {
|
|
23156
|
+
const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}${extname2(n).toLowerCase()}`);
|
|
23157
|
+
copyFileSync3(join10(opts.folder, n), target);
|
|
23158
|
+
files.push(target);
|
|
23159
|
+
}
|
|
23160
|
+
}
|
|
23161
|
+
const slides = files.map((f, i) => ({ n: i + 1, file: f, size: imageSize(f), video_cover: !!meta2?.images[i]?.video, palette: palette(f) }));
|
|
23162
|
+
const sheet = tileSheet(files, join10(dir2, "sheet.jpg"));
|
|
23163
|
+
if (meta2?.caption) writeFileSync8(join10(dir2, "caption.txt"), meta2.caption);
|
|
23164
|
+
const info = { source: meta2?.url ?? opts.folder, platform: meta2?.platform ?? "folder", owner: meta2?.owner, code: meta2?.code, views: meta2?.views, likes: meta2?.likes, comments: meta2?.comments, timestamp: meta2?.timestamp, slides: slides.length };
|
|
23165
|
+
writeFileSync8(join10(dir2, "meta.json"), JSON.stringify({ ...info, slides }, null, 2));
|
|
23166
|
+
const md = [
|
|
23167
|
+
"# Carousel breakdown",
|
|
23168
|
+
"",
|
|
23169
|
+
`Source: ${info.source}${info.owner ? ` (@${info.owner})` : ""}`,
|
|
23170
|
+
...meta2 && (info.likes !== void 0 || info.comments !== void 0) ? [`Stats at capture: ${info.views !== void 0 ? `${info.views} views, ` : ""}${info.likes ?? "?"} likes, ${info.comments ?? "?"} comments${info.timestamp ? `, posted ${String(info.timestamp).slice(0, 10)}` : ""}`] : [],
|
|
23171
|
+
`${slides.length} slides. Every slide, numbered: sheet.jpg. Each one full size: slides/.`,
|
|
23172
|
+
"",
|
|
23173
|
+
"Study only. Rebuild the structure and look with your own words and images; never reuse theirs.",
|
|
23174
|
+
"",
|
|
23175
|
+
"## Measured colors per slide (most common first, share of the slide)",
|
|
23176
|
+
...slides.map((s) => `- ${s.n}${s.video_cover ? " (video, cover frame)" : ""}: ${s.size ? `${s.size.width}x${s.size.height}, ` : ""}${s.palette.map((p) => `${p.hex} ${Math.round(p.share * 100)}%`).join(", ")}`),
|
|
23177
|
+
"",
|
|
23178
|
+
"## Caption",
|
|
23179
|
+
meta2?.caption ?? "(none)",
|
|
23180
|
+
""
|
|
23181
|
+
].join("\n");
|
|
23182
|
+
writeFileSync8(join10(dir2, "breakdown.md"), md);
|
|
23183
|
+
return { folder: dir2, report: join10(dir2, "breakdown.md"), sheet, slides: slides.map(({ n, file, size, video_cover, palette: palette2 }) => ({ n, file, size, video_cover, palette: palette2 })), caption: meta2?.caption ?? null, meta: info };
|
|
23184
|
+
}
|
|
23185
|
+
function imageSize(file) {
|
|
23186
|
+
const r = spawnSync4("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file], { encoding: "utf8" });
|
|
23187
|
+
const [w, h] = String(r.stdout ?? "").trim().split(",").map(Number);
|
|
23188
|
+
return w && h ? { width: w, height: h } : null;
|
|
23189
|
+
}
|
|
23190
|
+
var BASE = {
|
|
23191
|
+
name: "base",
|
|
23192
|
+
size: [1080, 1350],
|
|
23193
|
+
colors: { bg: "#FFFFFF", text: "#111111", accent: "#2B59FF", muted: "#6B6B6B", card: "#F2F2F2", stroke: "#000000", box: "#FFFFFF", boxText: "#111111" },
|
|
23194
|
+
fonts: { headline: "Inter", body: "Inter", mono: "Roboto Mono" },
|
|
23195
|
+
headline: { case: "none", weight: 800, size: 0.085, lineHeight: 1.05, letterSpacing: -0.02, italicAccent: false },
|
|
23196
|
+
body: { size: 0.034, weight: 400, lineHeight: 1.35 },
|
|
23197
|
+
text: { treatment: "plain", align: "left", valign: "center", strokeWidth: 0.09, offset: 0, quoteMark: true },
|
|
23198
|
+
frame: { margin: 0.08, pageNumber: "top-right", handle: "", handlePos: "bottom-left", swipeCue: "arrow" },
|
|
23199
|
+
image: { radius: 0, grade: "none", position: "top" },
|
|
23200
|
+
texture: { grain: 0, vignette: 0 }
|
|
23201
|
+
};
|
|
23202
|
+
function mergeStyle(base, over) {
|
|
23203
|
+
if (!over) return base;
|
|
23204
|
+
const out = { ...base };
|
|
23205
|
+
for (const [k, v] of Object.entries(over)) {
|
|
23206
|
+
if (v && typeof v === "object" && !Array.isArray(v) && typeof base[k] === "object") out[k] = { ...base[k], ...v };
|
|
23207
|
+
else if (v !== void 0) out[k] = v;
|
|
23208
|
+
}
|
|
23209
|
+
return out;
|
|
23210
|
+
}
|
|
23211
|
+
var STYLE_PRESETS = {
|
|
23212
|
+
"editorial-serif": {
|
|
23213
|
+
about: "Warm paper ground, big serif headlines with an italic accent word, small sans body. 'N principles' and essay carousels.",
|
|
23214
|
+
style: { colors: { bg: "#F3EEE4", text: "#1B1915", accent: "#9C4A2F", muted: "#6E675C", card: "#E8E1D3" }, fonts: { headline: "Instrument Serif", body: "Inter" }, headline: { weight: 400, size: 0.1, lineHeight: 1, letterSpacing: -0.01, italicAccent: true }, frame: { pageNumber: "top-right", swipeCue: "text" } }
|
|
23215
|
+
},
|
|
23216
|
+
"tiktok-outline": {
|
|
23217
|
+
about: "TikTok slideshow: full-bleed photo, centered white sans text with a black outline.",
|
|
23218
|
+
style: { colors: { bg: "#111111", text: "#FFFFFF", accent: "#FFFFFF", muted: "#DDDDDD", stroke: "#000000" }, fonts: { headline: "TikTok Sans", body: "TikTok Sans" }, headline: { weight: 700, size: 0.062, lineHeight: 1.2, letterSpacing: 0 }, body: { size: 0.045, weight: 600 }, text: { treatment: "outline", align: "center", valign: "center" }, frame: { pageNumber: "none", swipeCue: "none", handlePos: "none" }, image: { position: "full" } }
|
|
23219
|
+
},
|
|
23220
|
+
"tiktok-box": {
|
|
23221
|
+
about: "TikTok slideshow: full-bleed photo, centered text on white rounded label boxes.",
|
|
23222
|
+
style: { colors: { bg: "#111111", text: "#111111", accent: "#111111", muted: "#333333", box: "#FFFFFF", boxText: "#111111" }, fonts: { headline: "TikTok Sans", body: "TikTok Sans" }, headline: { weight: 700, size: 0.055, lineHeight: 1.35, letterSpacing: 0 }, body: { size: 0.042, weight: 600 }, text: { treatment: "highlight-box", align: "center", valign: "center" }, frame: { pageNumber: "none", swipeCue: "none", handlePos: "none" }, image: { position: "full" } }
|
|
23223
|
+
},
|
|
23224
|
+
"bold-minimal": {
|
|
23225
|
+
about: "White ground, heavy tight sans, one bright accent. Hot takes and frameworks.",
|
|
23226
|
+
style: { colors: { bg: "#FFFFFF", text: "#0E0E0E", accent: "#2B59FF", muted: "#707070", card: "#F1F1F1" }, fonts: { headline: "Inter", body: "Inter" }, headline: { weight: 800, size: 0.095, letterSpacing: -0.035 } }
|
|
23227
|
+
},
|
|
23228
|
+
"notes-app": {
|
|
23229
|
+
about: "Phone notes look: off-white ground, plain dark text, a yellow accent. Personal lists and confessions.",
|
|
23230
|
+
style: { colors: { bg: "#FFFDF6", text: "#1C1C1E", accent: "#D9A400", muted: "#8E8E93", card: "#F2EFE6" }, fonts: { headline: "Inter", body: "Inter" }, headline: { weight: 700, size: 0.07 }, body: { size: 0.04 }, text: { valign: "top" }, frame: { pageNumber: "none", swipeCue: "none" } }
|
|
23231
|
+
},
|
|
23232
|
+
cinematic: {
|
|
23233
|
+
internal: true,
|
|
23234
|
+
about: "Keepy Labs house style (internal only): dark grainy ground, cream text, one blue accent.",
|
|
23235
|
+
style: { colors: { bg: "#0A0A0A", text: "#F7F3E8", accent: "#292DEB", muted: "#A39E93", card: "#181716" }, fonts: { headline: "Inter", body: "Inter", mono: "Roboto Mono" }, headline: { weight: 700, size: 0.085, letterSpacing: -0.025 }, text: { align: "center" }, texture: { grain: 0.14, vignette: 0.55 } }
|
|
23236
|
+
}
|
|
23237
|
+
};
|
|
23238
|
+
function applyBrand(style, brand) {
|
|
23239
|
+
if (!brand) return style;
|
|
23240
|
+
const c = style.colors;
|
|
23241
|
+
const colors = { ...c, bg: brand.bg ?? c.bg, text: brand.text ?? c.text, accent: brand.accent ?? c.accent, muted: brand.muted ?? c.muted, card: brand.card ?? c.card };
|
|
23242
|
+
if (style.text.treatment === "outline") colors.stroke = brand.bg ?? c.stroke;
|
|
23243
|
+
if (style.text.treatment === "highlight-box") {
|
|
23244
|
+
colors.box = brand.text ?? c.box;
|
|
23245
|
+
colors.boxText = brand.bg ?? c.boxText;
|
|
23246
|
+
}
|
|
23247
|
+
return {
|
|
23248
|
+
...style,
|
|
23249
|
+
name: `${style.name}+brand`,
|
|
23250
|
+
colors,
|
|
23251
|
+
fonts: { headline: brand.headline ?? style.fonts.headline, body: brand.body ?? style.fonts.body, mono: brand.mono ?? style.fonts.mono },
|
|
23252
|
+
texture: { grain: brand.grain ?? style.texture.grain, vignette: brand.vignette ?? style.texture.vignette }
|
|
23253
|
+
};
|
|
23254
|
+
}
|
|
23255
|
+
function brandFromJson(j) {
|
|
23256
|
+
if (!j) return null;
|
|
23257
|
+
const font = j.font === "serif" ? "Instrument Serif" : j.font === "mono" ? "Roboto Mono" : j.font === "sans" ? "Inter" : void 0;
|
|
23258
|
+
return { bg: j.background, text: j.text, accent: j.accent ?? j.highlight, headline: font, body: font === "Instrument Serif" ? "Inter" : font };
|
|
23259
|
+
}
|
|
23260
|
+
var LAYOUTS = ["text-hook", "image-hook", "image-caption", "list-item", "quote", "before-after", "screenshot-label", "cta"];
|
|
23261
|
+
function validateDeck(deck) {
|
|
23262
|
+
if (!deck || typeof deck !== "object") throw new Error("deck must be an object with style and slides.");
|
|
23263
|
+
if (!Array.isArray(deck.slides) || !deck.slides.length) throw new Error("deck.slides must be a non-empty list.");
|
|
23264
|
+
if (deck.slides.length > 20) throw new Error("At most 20 slides (Instagram's limit).");
|
|
23265
|
+
deck.slides.forEach((s, i) => {
|
|
23266
|
+
if (!LAYOUTS.includes(s?.layout)) throw new Error(`Slide ${i + 1}: layout must be one of ${LAYOUTS.join(", ")}.`);
|
|
23267
|
+
const hasText = ["headline", "sub", "body", "quote", "keyword", "number"].some((k) => typeof s[k] === "string" && s[k].trim()) || s.before || s.after;
|
|
23268
|
+
if (!hasText) throw new Error(`Slide ${i + 1}: needs some text (headline, body, quote...).`);
|
|
23269
|
+
});
|
|
23270
|
+
return deck;
|
|
23271
|
+
}
|
|
23272
|
+
function resolveStyle(spec, opts = {}) {
|
|
23273
|
+
const load = (ref) => {
|
|
23274
|
+
const preset = STYLE_PRESETS[ref];
|
|
23275
|
+
if (preset) {
|
|
23276
|
+
if (preset.internal && !opts.internal) throw new Error(`The ${ref} style is not available here. Pick another: ${Object.keys(STYLE_PRESETS).filter((k) => !STYLE_PRESETS[k].internal).join(", ")}, or the original's style.`);
|
|
23277
|
+
return mergeStyle({ ...BASE, name: ref }, preset.style);
|
|
23278
|
+
}
|
|
23279
|
+
const file = isAbsolute2(ref) ? ref : resolve4(opts.baseDir ?? ".", ref.replace(/^~/, homedir4()));
|
|
23280
|
+
if (!existsSync8(file)) throw new Error(`No style called ${ref} (presets: ${Object.keys(STYLE_PRESETS).join(", ")}; or a style.json path).`);
|
|
23281
|
+
const j = JSON.parse(readFileSync9(file, "utf8"));
|
|
23282
|
+
return j.preset || j.path ? resolveStyle(j, { ...opts, baseDir: dirname2(file) }) : mergeStyle({ ...BASE, name: basename3(file, ".json") }, j);
|
|
23283
|
+
};
|
|
23284
|
+
if (typeof spec === "string") return load(spec);
|
|
23285
|
+
const s = spec ?? {};
|
|
23286
|
+
if (s.preset || s.path) return mergeStyle(load(s.preset ?? s.path), s.overrides);
|
|
23287
|
+
return mergeStyle({ ...BASE, name: s.name ?? "custom" }, s);
|
|
23288
|
+
}
|
|
23289
|
+
var FONT_SPECS = {
|
|
23290
|
+
"Inter": "ital,wght@0,100..900;1,100..900",
|
|
23291
|
+
"Instrument Serif": "ital@0;1",
|
|
23292
|
+
"Playfair Display": "ital,wght@0,400..900;1,400..900",
|
|
23293
|
+
"DM Serif Display": "ital@0;1",
|
|
23294
|
+
"Cormorant Garamond": "ital,wght@0,300..700;1,300..700",
|
|
23295
|
+
"EB Garamond": "ital,wght@0,400..800;1,400..800",
|
|
23296
|
+
"Libre Baskerville": "ital,wght@0,400;0,700;1,400",
|
|
23297
|
+
"Fraunces": "ital,wght@0,100..900;1,100..900",
|
|
23298
|
+
"Lora": "ital,wght@0,400..700;1,400..700",
|
|
23299
|
+
"Montserrat": "ital,wght@0,100..900;1,100..900",
|
|
23300
|
+
"Poppins": "ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,700",
|
|
23301
|
+
"DM Sans": "ital,wght@0,100..1000;1,100..1000",
|
|
23302
|
+
"Manrope": "wght@200..800",
|
|
23303
|
+
"Plus Jakarta Sans": "ital,wght@0,200..800;1,200..800",
|
|
23304
|
+
"Space Grotesk": "wght@300..700",
|
|
23305
|
+
"Archivo": "ital,wght@0,100..900;1,100..900",
|
|
23306
|
+
"Oswald": "wght@200..700",
|
|
23307
|
+
"Bebas Neue": "",
|
|
23308
|
+
"Anton": "",
|
|
23309
|
+
"Archivo Black": "",
|
|
23310
|
+
"Roboto Mono": "ital,wght@0,100..700;1,100..700",
|
|
23311
|
+
"JetBrains Mono": "ital,wght@0,100..800;1,100..800",
|
|
23312
|
+
"Caveat": "wght@400..700",
|
|
23313
|
+
"Permanent Marker": "",
|
|
23314
|
+
"Kalam": "wght@300;400;700",
|
|
23315
|
+
"Noto Sans KR": "wght@100..900",
|
|
23316
|
+
"TikTok Sans": "wght@300..900",
|
|
23317
|
+
"Noto Serif KR": "wght@200..900"
|
|
23318
|
+
};
|
|
23319
|
+
var SERIF = /serif|garamond|baskerville|playfair|fraunces|lora|cormorant|merriweather/i;
|
|
23320
|
+
function fontLinks(families) {
|
|
23321
|
+
return [...new Set(families.filter(Boolean))].map((f) => {
|
|
23322
|
+
const spec = FONT_SPECS[f];
|
|
23323
|
+
const fam = f.trim().replace(/ /g, "+") + (spec ? `:${spec}` : "");
|
|
23324
|
+
return `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${fam}&display=block">`;
|
|
23325
|
+
}).join("\n");
|
|
23326
|
+
}
|
|
23327
|
+
var stack = (f) => `"${f}", ${SERIF.test(f) && !/sans/i.test(f) ? "Georgia, serif" : "Helvetica, Arial, sans-serif"}`;
|
|
23328
|
+
var esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
23329
|
+
function rich(s) {
|
|
23330
|
+
if (!s) return "";
|
|
23331
|
+
return esc2(s).replace(/\*([^*]+)\*/g, '<em class="hl">$1</em>').replace(/\n/g, "<br>");
|
|
23332
|
+
}
|
|
23333
|
+
var fileUrl = (p) => /^(https?:|data:|file:)/.test(p) ? p : `file://${encodeURI(p)}`;
|
|
23334
|
+
function imageEl(img, style, baseDir, cls = "img") {
|
|
23335
|
+
const src = /^(https?:|data:)/.test(img.src) ? img.src : resolve4(baseDir, img.src.replace(/^~/, homedir4()));
|
|
23336
|
+
const grade = img.grade ?? style.image.grade;
|
|
23337
|
+
const filter = grade === "mono" ? "grayscale(1) contrast(1.05)" : grade === "warm" ? "sepia(0.25) saturate(1.1)" : grade === "dim" ? "brightness(0.7)" : "none";
|
|
23338
|
+
return `<div class="${cls}" style="background-image:url('${fileUrl(src).replace(/'/g, "%27")}');background-size:${img.fit ?? "cover"};background-position:${esc2(img.focus ?? "50% 50%")};filter:${filter}"></div>`;
|
|
23339
|
+
}
|
|
23340
|
+
function artHtml(art) {
|
|
23341
|
+
if (!art) return "";
|
|
23342
|
+
switch (art.kind) {
|
|
23343
|
+
case "html":
|
|
23344
|
+
return `<div class="art art-html">${art.html}</div>`;
|
|
23345
|
+
case "stat":
|
|
23346
|
+
return `<div class="art art-stat"><div class="stat-v">${esc2(art.value)}</div>${art.label ? `<div class="stat-l">${esc2(art.label)}</div>` : ""}</div>`;
|
|
23347
|
+
case "chart": {
|
|
23348
|
+
const max = Math.max(...art.bars.map((b) => b.value), 1);
|
|
23349
|
+
return `<div class="art art-chart">${art.bars.map((b, i) => `<div class="bar-row"><div class="bar-l">${esc2(b.label)}</div><div class="bar-t"><div class="bar${i === art.bars.length - 1 ? " bar-hi" : ""}" style="width:${Math.max(4, b.value / max * 100)}%"></div></div><div class="bar-v">${esc2(String(b.value))}${esc2(art.unit ?? "")}</div></div>`).join("")}</div>`;
|
|
23350
|
+
}
|
|
23351
|
+
case "chat":
|
|
23352
|
+
return `<div class="art art-chat">${art.messages.map((m) => `<div class="msg msg-${m.from === "me" ? "me" : "them"}">${rich(m.text)}</div>`).join("")}</div>`;
|
|
23353
|
+
case "checklist":
|
|
23354
|
+
return `<div class="art art-check">${art.items.map((it) => `<div class="chk${it.done ? " done" : ""}"><span class="box">${it.done ? "✓" : ""}</span><span>${rich(it.text)}</span></div>`).join("")}</div>`;
|
|
23355
|
+
case "browser":
|
|
23356
|
+
return `<div class="art art-browser"><div class="bb"><i></i><i></i><i></i><span>${esc2(art.url ?? "")}</span></div><div class="bc">${art.title ? `<div class="bt">${rich(art.title)}</div>` : ""}${(art.lines ?? []).map((l) => `<div class="bl">${rich(l)}</div>`).join("")}${art.lines?.length ? "" : '<div class="sk"></div><div class="sk s2"></div><div class="sk s3"></div>'}</div></div>`;
|
|
23357
|
+
}
|
|
23358
|
+
}
|
|
23359
|
+
var visual = (s, style, baseDir, cls = "img") => s.image ? imageEl(s.image, style, baseDir, cls) : s.art ? `<div class="${cls} artwrap">${artHtml(s.art)}</div>` : "";
|
|
23360
|
+
function slideHtml(slide, style, n, total, opts) {
|
|
23361
|
+
const [W, H] = style.size;
|
|
23362
|
+
const m = Math.round(style.frame.margin * W);
|
|
23363
|
+
const c = { ...style.colors, bg: slide.bg ?? style.colors.bg, text: slide.color ?? style.colors.text, accent: slide.accent ?? style.colors.accent };
|
|
23364
|
+
const sc = slide.scale ?? 1;
|
|
23365
|
+
const hs = Math.round(style.headline.size * W * sc), bs = Math.round(style.body.size * W * sc);
|
|
23366
|
+
const align = slide.align ?? style.text.align, valign = slide.valign ?? style.text.valign;
|
|
23367
|
+
const treat = slide.treatment ?? style.text.treatment;
|
|
23368
|
+
const handle = opts.handle ?? style.frame.handle;
|
|
23369
|
+
const j = valign === "top" ? "flex-start" : valign === "bottom" ? "flex-end" : "center";
|
|
23370
|
+
const off = Math.round(style.text.offset * H);
|
|
23371
|
+
const textBlock = (parts) => `<div class="fitbox" style="justify-content:${j};${off ? valign === "bottom" ? `padding-bottom:${off}px` : valign === "top" ? `padding-top:${off}px` : "" : ""}">${parts}</div>`;
|
|
23372
|
+
const kicker = slide.kicker ? `<div class="kicker" data-fit>${rich(slide.kicker)}</div>` : "";
|
|
23373
|
+
const head = slide.headline ? `<div class="headline" data-fit>${treat === "highlight-box" ? `<span class="tb">${rich(slide.headline)}</span>` : rich(slide.headline)}</div>` : "";
|
|
23374
|
+
const sub = slide.sub ? `<div class="sub" data-fit>${rich(slide.sub)}</div>` : "";
|
|
23375
|
+
const body = slide.body ? `<div class="body" data-fit>${treat === "highlight-box" ? `<span class="tb">${rich(slide.body)}</span>` : rich(slide.body)}</div>` : "";
|
|
23376
|
+
const fullBleed = (img) => (img?.position ?? style.image.position) === "full";
|
|
23377
|
+
let inner = "";
|
|
23378
|
+
switch (slide.layout) {
|
|
23379
|
+
case "text-hook":
|
|
23380
|
+
inner = `<div class="col pad">${textBlock(kicker + head + sub + body)}${slide.image || slide.art ? `<div class="media media-sm">${visual(slide, style, opts.baseDir)}</div>` : ""}</div>`;
|
|
23381
|
+
break;
|
|
23382
|
+
case "image-hook":
|
|
23383
|
+
case "image-caption": {
|
|
23384
|
+
const pos = slide.image?.position ?? style.image.position;
|
|
23385
|
+
if (fullBleed(slide.image) && slide.image) {
|
|
23386
|
+
inner = `${imageEl(slide.image, style, opts.baseDir, "bleed")}${treat === "plain" ? '<div class="scrim"></div>' : ""}<div class="col pad over">${textBlock(kicker + head + sub + body)}</div>`;
|
|
23387
|
+
} else {
|
|
23388
|
+
const media = `<div class="media ${pos === "framed" ? "framed" : ""}">${visual(slide, style, opts.baseDir)}</div>`;
|
|
23389
|
+
const text = `<div class="txt">${textBlock(kicker + head + sub + body)}</div>`;
|
|
23390
|
+
inner = `<div class="col pad split">${pos === "bottom" ? text + media : media + text}</div>`;
|
|
23391
|
+
}
|
|
23392
|
+
break;
|
|
23393
|
+
}
|
|
23394
|
+
case "list-item":
|
|
23395
|
+
inner = `<div class="col pad">${textBlock(`${slide.number ? `<div class="num" data-fit>${esc2(slide.number)}</div>` : ""}${kicker}${head}${body}`)}${slide.image || slide.art ? `<div class="media media-sm">${visual(slide, style, opts.baseDir)}</div>` : ""}</div>`;
|
|
23396
|
+
break;
|
|
23397
|
+
case "quote":
|
|
23398
|
+
inner = `<div class="col pad">${textBlock(`${style.text.quoteMark ? '<div class="qmark">“</div>' : ""}<div class="headline quote" data-fit>${rich(slide.quote ?? slide.headline)}</div>${slide.attribution ? `<div class="attr" data-fit>${rich(slide.attribution)}</div>` : ""}`)}</div>`;
|
|
23399
|
+
break;
|
|
23400
|
+
case "before-after": {
|
|
23401
|
+
const panel = (p, cls, fallback) => `<div class="panel ${cls}"><div class="plabel">${esc2(p?.label ?? fallback)}</div>${p?.image || p?.art ? `<div class="pmedia">${visual(p, style, opts.baseDir)}</div>` : ""}${p?.text ? `<div class="ptext" data-fit>${rich(p.text)}</div>` : ""}</div>`;
|
|
23402
|
+
inner = `<div class="col pad">${slide.headline ? `<div class="bahead">${head}</div>` : ""}<div class="ba">${panel(slide.before, "before", "Before")}${panel(slide.after, "after", "After")}</div></div>`;
|
|
23403
|
+
break;
|
|
23404
|
+
}
|
|
23405
|
+
case "screenshot-label":
|
|
23406
|
+
{
|
|
23407
|
+
const doodle = slide.image && slide.art ? `<div class="doodle">${artHtml(slide.art)}</div>` : "";
|
|
23408
|
+
inner = `<div class="col pad">${textBlock(kicker + head + sub + body)}<div class="shot${doodle ? " shot-sm" : ""}">${visual(slide, style, opts.baseDir)}${slide.note ? `<div class="note">${rich(slide.note)}</div>` : ""}</div>${doodle}</div>`;
|
|
23409
|
+
break;
|
|
23410
|
+
}
|
|
23411
|
+
break;
|
|
23412
|
+
case "cta":
|
|
23413
|
+
inner = `<div class="col pad">${textBlock(`${kicker}${head}${slide.keyword ? `<div class="kw" data-fit>${esc2(slide.keyword)}</div>` : ""}${slide.ask ? `<div class="body" data-fit>${rich(slide.ask)}</div>` : ""}${body}`)}${slide.image || slide.art ? `<div class="media media-sm">${visual(slide, style, opts.baseDir)}</div>` : ""}</div>`;
|
|
23414
|
+
break;
|
|
23415
|
+
}
|
|
23416
|
+
const page2 = style.frame.pageNumber !== "none" && total > 1 ? `<div class="page page-${style.frame.pageNumber}">${n}/${total}</div>` : "";
|
|
23417
|
+
const hand = handle && style.frame.handlePos !== "none" ? `<div class="handle handle-${style.frame.handlePos}">${esc2(handle.startsWith("@") ? handle : `@${handle}`)}</div>` : "";
|
|
23418
|
+
const swipe = n < total && style.frame.swipeCue !== "none" && slide.layout !== "cta" ? `<div class="swipe">${style.frame.swipeCue === "text" ? "Swipe →" : "→"}</div>` : "";
|
|
23419
|
+
const grain = style.texture.grain > 0 ? `<svg class="grain" width="${W}" height="${H}"><filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter><rect width="100%" height="100%" filter="url(#g)"/></svg>` : "";
|
|
23420
|
+
const vig = style.texture.vignette > 0 ? `<div class="vig"></div>` : "";
|
|
23421
|
+
const ff = style.fonts;
|
|
23422
|
+
const strokeW = Math.max(2, Math.round(hs * style.text.strokeWidth));
|
|
23423
|
+
const ring = (w) => Array.from({ length: 16 }, (_, i) => {
|
|
23424
|
+
const t = i / 16 * Math.PI * 2;
|
|
23425
|
+
return `${(Math.cos(t) * w).toFixed(1)}px ${(Math.sin(t) * w).toFixed(1)}px 0 ${c.stroke}`;
|
|
23426
|
+
}).join(",");
|
|
23427
|
+
const outline = treat === "outline" ? `text-shadow:${ring(strokeW)},${ring(strokeW / 2)};` : "";
|
|
23428
|
+
const shadow = treat === "shadow" || treat === "plain" && fullBleed(slide.image) && slide.image ? "text-shadow:0 2px 18px rgba(0,0,0,.55);" : "";
|
|
23429
|
+
const css = `
|
|
23430
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
23431
|
+
html,body{width:${W}px;height:${H}px;overflow:hidden;background:${c.bg}}
|
|
23432
|
+
body{position:relative;color:${c.text};font-family:${stack(ff.body)};-webkit-font-smoothing:antialiased}
|
|
23433
|
+
.pad{position:absolute;inset:0;padding:${m}px;display:flex;flex-direction:column;gap:${Math.round(m * 0.45)}px}
|
|
23434
|
+
.over{z-index:3}
|
|
23435
|
+
.fitbox{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:${Math.round(m * 0.3)}px;text-align:${align};align-items:${align === "center" ? "center" : "flex-start"};overflow:hidden}
|
|
23436
|
+
.headline,.ptext,.kw{text-wrap:balance}.body,.attr,.msg{text-wrap:pretty}
|
|
23437
|
+
.kicker{font-family:${stack(ff.mono)};font-size:${Math.round(bs * 0.8)}px;letter-spacing:.08em;text-transform:uppercase;color:${treat === "plain" ? c.accent : c.text};${outline}${shadow}}
|
|
23438
|
+
.headline{font-family:${stack(ff.headline)};font-weight:${style.headline.weight};font-size:${hs}px;line-height:${style.headline.lineHeight};letter-spacing:${style.headline.letterSpacing}em;text-transform:${{ none: "none", upper: "uppercase", lower: "lowercase", title: "capitalize" }[style.headline.case]};${outline}${shadow}}
|
|
23439
|
+
.body{font-size:${bs}px;font-weight:${style.body.weight};line-height:${style.body.lineHeight};color:${treat === "plain" && !fullBleed(slide.image) ? c.muted : c.text};max-width:${align === "center" ? "92%" : "100%"};${treat === "outline" ? `text-shadow:${ring(Math.max(2, bs * style.text.strokeWidth))},${ring(Math.max(1, bs * style.text.strokeWidth / 2))};` : ""}${shadow}}
|
|
23440
|
+
.hl{font-style:${style.headline.italicAccent ? "italic" : "normal"};color:${treat === "plain" ? c.accent : "inherit"}}
|
|
23441
|
+
${style.headline.underlineAccent ? `.headline .hl{color:inherit;text-decoration:underline;text-decoration-color:${c.accent};text-decoration-thickness:.07em;text-underline-offset:.14em;text-decoration-skip-ink:none}` : ""}
|
|
23442
|
+
.sub{font-family:${stack(ff.headline)};font-weight:${style.headline.weight};font-size:${Math.round(hs * 0.6)}px;line-height:1.15;text-wrap:balance;${outline}${shadow}}
|
|
23443
|
+
.tb{background:${c.box};color:${c.boxText};padding:.12em .38em;border-radius:.22em;-webkit-box-decoration-break:clone;box-decoration-break:clone;line-height:1.5}
|
|
23444
|
+
.tb .hl{color:${c.boxText}}
|
|
23445
|
+
.num{font-family:${stack(ff.headline)};font-size:${Math.round(hs * 1.9)}px;line-height:.9;color:${c.accent};font-weight:${style.headline.weight};${outline}}
|
|
23446
|
+
.media{flex:0 0 auto;height:52%;position:relative;border-radius:${style.image.radius}px;overflow:hidden}
|
|
23447
|
+
.media-sm{height:38%}
|
|
23448
|
+
.framed{border-radius:${Math.max(style.image.radius, 18)}px;box-shadow:0 18px 50px rgba(0,0,0,.25)}
|
|
23449
|
+
.split .txt{flex:1 1 auto;min-height:0;display:flex}
|
|
23450
|
+
.img{position:absolute;inset:0;background-repeat:no-repeat}
|
|
23451
|
+
.bleed{position:absolute;inset:0;background-repeat:no-repeat;z-index:1}
|
|
23452
|
+
.scrim{position:absolute;inset:0;z-index:2;background:linear-gradient(180deg,rgba(0,0,0,0) 35%,rgba(0,0,0,.72) 100%)}
|
|
23453
|
+
.artwrap{display:flex;align-items:center;justify-content:center;background:${c.card};border-radius:${Math.max(style.image.radius, 14)}px}
|
|
23454
|
+
.qmark{font-family:${stack(ff.headline)};font-size:${Math.round(hs * 2.4)}px;line-height:.6;color:${c.accent};height:${Math.round(hs * 1.1)}px}
|
|
23455
|
+
.quote{font-style:${style.headline.italicAccent ? "italic" : "normal"}}
|
|
23456
|
+
.attr{font-size:${bs}px;color:${c.muted};${outline}${shadow}}
|
|
23457
|
+
.bahead{flex:0 0 auto}
|
|
23458
|
+
.ba{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:${Math.round(m * 0.35)}px}
|
|
23459
|
+
.panel{flex:1 1 0;min-height:0;border-radius:${Math.max(style.image.radius, 16)}px;padding:${Math.round(m * 0.55)}px;display:flex;flex-direction:column;gap:${Math.round(m * 0.25)}px;overflow:hidden}
|
|
23460
|
+
.before{background:${c.card};color:${c.muted}}
|
|
23461
|
+
.after{background:${c.card};border:3px solid ${c.accent}}
|
|
23462
|
+
.plabel{font-family:${stack(ff.mono)};font-size:${Math.round(bs * 0.75)}px;letter-spacing:.1em;text-transform:uppercase}
|
|
23463
|
+
.after .plabel{color:${c.accent}}
|
|
23464
|
+
.ptext{font-family:${stack(ff.headline)};font-size:${Math.round(hs * 0.62)}px;line-height:1.12;font-weight:${style.headline.weight};color:${c.text}}
|
|
23465
|
+
.before .ptext{color:${c.muted}}
|
|
23466
|
+
.pmedia{flex:1 1 auto;position:relative;min-height:0;border-radius:10px;overflow:hidden}
|
|
23467
|
+
.shot-sm{height:42% !important}
|
|
23468
|
+
.doodle{flex:0 0 auto;height:19%;color:${c.text}}.doodle .art{padding:0}.doodle svg,.doodle img{height:100%;width:auto}
|
|
23469
|
+
.shot{flex:0 0 auto;height:56%;position:relative;border-radius:22px;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.28);border:2px solid ${c.card}}
|
|
23470
|
+
.note{position:absolute;right:16px;bottom:16px;background:${c.accent};color:${c.bg};font-size:${Math.round(bs * 0.85)}px;font-weight:700;padding:10px 18px;border-radius:999px;z-index:2}
|
|
23471
|
+
.kw{font-family:${stack(ff.headline)};font-weight:800;font-size:${Math.round(hs * 1.25)}px;color:${c.bg};background:${c.accent};padding:.08em .4em;border-radius:.18em;letter-spacing:.02em}
|
|
23472
|
+
.page,.handle,.swipe{position:absolute;z-index:4;font-family:${stack(ff.mono)};font-size:${Math.round(W * 0.022)}px;color:${c.muted};letter-spacing:.05em}
|
|
23473
|
+
.page-top-right{top:${Math.round(m * 0.45)}px;right:${m}px}.page-top-left{top:${Math.round(m * 0.45)}px;left:${m}px}.page-bottom-right{bottom:${Math.round(m * 0.45)}px;right:${m}px}.page-bottom-center{bottom:${Math.round(m * 0.45)}px;left:0;right:0;text-align:center}
|
|
23474
|
+
.handle-bottom-left{bottom:${Math.round(m * 0.45)}px;left:${m}px}.handle-bottom-center{bottom:${Math.round(m * 0.45)}px;left:0;right:0;text-align:center}.handle-top-left{top:${Math.round(m * 0.45)}px;left:${m}px}
|
|
23475
|
+
.swipe{bottom:${Math.round(m * 0.45)}px;right:${m}px;color:${c.accent}}
|
|
23476
|
+
.grain{position:absolute;inset:0;z-index:5;opacity:${style.texture.grain};mix-blend-mode:overlay;pointer-events:none}
|
|
23477
|
+
.vig{position:absolute;inset:0;z-index:5;pointer-events:none;background:radial-gradient(ellipse at center,rgba(0,0,0,0) 45%,rgba(0,0,0,${style.texture.vignette}) 100%)}
|
|
23478
|
+
.art{width:100%;height:100%;padding:${Math.round(m * 0.5)}px;display:flex;flex-direction:column;justify-content:center;gap:${Math.round(m * 0.22)}px;color:${c.text};font-size:${Math.round(bs * 1.05)}px}
|
|
23479
|
+
.art-html{padding:0;align-items:center}.art-html svg,.art-html img{max-width:100%;max-height:100%}.artwrap:has(.art-html){background:none}
|
|
23480
|
+
.art-stat{align-items:center;text-align:center}.stat-v{font-family:${stack(ff.headline)};font-size:${Math.round(hs * 1.6)}px;font-weight:800;color:${c.accent};line-height:1}.stat-l{color:${c.muted}}
|
|
23481
|
+
.bar-row{display:grid;grid-template-columns:28% 1fr auto;align-items:center;gap:14px}.bar-t{height:${Math.round(bs * 1.1)}px;background:${c.bg};border-radius:8px;overflow:hidden}.bar{height:100%;background:${c.muted};border-radius:8px}.bar-hi{background:${c.accent}}.bar-v{font-family:${stack(ff.mono)}}
|
|
23482
|
+
.msg{max-width:78%;padding:14px 20px;border-radius:24px;line-height:1.3}.msg-them{background:${c.bg};align-self:flex-start}.msg-me{background:${c.accent};color:${c.bg};align-self:flex-end}.msg-me .hl,.done .hl{color:inherit;font-weight:800}
|
|
23483
|
+
.chk{display:flex;gap:16px;align-items:center}.box{width:${Math.round(bs * 1.1)}px;height:${Math.round(bs * 1.1)}px;border:3px solid ${c.muted};border-radius:8px;display:flex;align-items:center;justify-content:center;flex:0 0 auto}.done .box{background:${c.accent};border-color:${c.accent};color:${c.bg}}
|
|
23484
|
+
.art-browser{padding:0;justify-content:flex-start;background:${c.bg};border-radius:14px;overflow:hidden}.bb{display:flex;gap:8px;align-items:center;padding:14px 18px;background:${c.card};border-bottom:2px solid ${c.bg}}.bb i{width:14px;height:14px;border-radius:50%;background:${c.muted};display:block}.bb span{margin-left:14px;font-family:${stack(ff.mono)};font-size:${Math.round(bs * 0.6)}px;color:${c.muted}}
|
|
23485
|
+
.bc{padding:${Math.round(m * 0.45)}px;display:flex;flex-direction:column;gap:14px}.bt{font-family:${stack(ff.headline)};font-weight:700;font-size:${Math.round(bs * 1.15)}px}.sk{height:${Math.round(bs * 0.6)}px;border-radius:6px;background:${c.card}}.s2{width:82%}.s3{width:64%}
|
|
23486
|
+
`;
|
|
23487
|
+
const fit = `
|
|
23488
|
+
<script>
|
|
23489
|
+
(async function(){
|
|
23490
|
+
try { await document.fonts.ready; } catch(e) {}
|
|
23491
|
+
const want = ${JSON.stringify([ff.headline, ff.body, ff.mono])};
|
|
23492
|
+
try { await Promise.all(want.map(f => document.fonts.load('40px "' + f + '"'))); } catch(e) {}
|
|
23493
|
+
const missing = want.filter(f => !document.fonts.check('40px "' + f + '"'));
|
|
23494
|
+
const boxes = [...document.querySelectorAll('.fitbox,.panel,.ba,.pad')];
|
|
23495
|
+
const over = () => boxes.some(b => b.scrollHeight > b.clientHeight + 2 || b.scrollWidth > b.clientWidth + 2) || [...document.querySelectorAll('[data-fit]')].some(e => e.scrollWidth > e.clientWidth + 2);
|
|
23496
|
+
const els = [...document.querySelectorAll('[data-fit]')];
|
|
23497
|
+
const base = els.map(e => parseFloat(getComputedStyle(e).fontSize));
|
|
23498
|
+
let k = 1;
|
|
23499
|
+
while (over() && k > 0.55) { k *= 0.95; els.forEach((e, i) => e.style.fontSize = (base[i] * k) + 'px'); }
|
|
23500
|
+
document.body.setAttribute('data-qa', JSON.stringify({ shrunk: Math.round((1 - k) * 100), overflow: over(), missing_fonts: missing }));
|
|
23501
|
+
})();
|
|
23502
|
+
</script>`;
|
|
23503
|
+
return `<!doctype html><html><head><meta charset="utf-8">${fontLinks([ff.headline, ff.body, ff.mono])}<style>${css}</style></head><body>${inner}${page2}${hand}${swipe}${grain}${vig}${fit}</body></html>`;
|
|
23504
|
+
}
|
|
23505
|
+
function findChrome(env = process.env) {
|
|
23506
|
+
const cands = [
|
|
23507
|
+
env.CHROME_PATH,
|
|
23508
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
23509
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
23510
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
23511
|
+
"/usr/bin/google-chrome",
|
|
23512
|
+
"/usr/bin/chromium",
|
|
23513
|
+
"/usr/bin/chromium-browser",
|
|
23514
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
23515
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
23516
|
+
].filter(Boolean);
|
|
23517
|
+
for (const c of cands) if (existsSync8(c)) return c;
|
|
23518
|
+
const pw = join10(homedir4(), "Library", "Caches", "ms-playwright");
|
|
23519
|
+
if (existsSync8(pw)) {
|
|
23520
|
+
for (const d of readdirSync3(pw).filter((n) => /^chromium-\d+$/.test(n)).sort().reverse()) {
|
|
23521
|
+
for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"]) {
|
|
23522
|
+
if (existsSync8(join10(pw, d, sub))) return join10(pw, d, sub);
|
|
23523
|
+
}
|
|
23524
|
+
}
|
|
23525
|
+
}
|
|
23526
|
+
return null;
|
|
23527
|
+
}
|
|
23528
|
+
function chrome(bin, args, timeoutMs = 45e3) {
|
|
23529
|
+
return new Promise((done) => {
|
|
23530
|
+
const p = spawn2(bin, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run", "--no-default-browser-check", "--allow-file-access-from-files", "--force-device-scale-factor=1", "--run-all-compositor-stages-before-draw", "--virtual-time-budget=15000", ...args]);
|
|
23531
|
+
let out = "";
|
|
23532
|
+
p.stdout.on("data", (d) => out += d);
|
|
23533
|
+
const t = setTimeout(() => p.kill("SIGKILL"), timeoutMs);
|
|
23534
|
+
p.on("close", (code) => {
|
|
23535
|
+
clearTimeout(t);
|
|
23536
|
+
done({ code, out });
|
|
23537
|
+
});
|
|
23538
|
+
p.on("error", () => {
|
|
23539
|
+
clearTimeout(t);
|
|
23540
|
+
done({ code: -1, out });
|
|
23541
|
+
});
|
|
23542
|
+
});
|
|
23543
|
+
}
|
|
23544
|
+
function contrast(a, b) {
|
|
23545
|
+
const lum = (hex) => {
|
|
23546
|
+
const h = hex.replace("#", "");
|
|
23547
|
+
const v = (h.length === 3 ? h.split("").map((x2) => x2 + x2) : h.match(/../g) ?? ["0", "0", "0"]).slice(0, 3).map((x2) => parseInt(x2, 16) / 255).map((x2) => x2 <= 0.03928 ? x2 / 12.92 : ((x2 + 0.055) / 1.055) ** 2.4);
|
|
23548
|
+
return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
|
|
23549
|
+
};
|
|
23550
|
+
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
|
|
23551
|
+
return Math.round((x + 0.05) / (y + 0.05) * 100) / 100;
|
|
23552
|
+
}
|
|
23553
|
+
async function renderDeck(folder, opts = {}) {
|
|
23554
|
+
const deckFile = join10(folder, "deck.json");
|
|
23555
|
+
if (!existsSync8(deckFile)) throw new Error(`No deck.json in ${folder}.`);
|
|
23556
|
+
const deck = validateDeck(JSON.parse(readFileSync9(deckFile, "utf8")));
|
|
23557
|
+
const bin = findChrome();
|
|
23558
|
+
if (!bin) throw new Error("Rendering slides needs Google Chrome (or Chromium) on this computer. Install Chrome, or set CHROME_PATH to it.");
|
|
23559
|
+
let style = resolveStyle(deck.style, { baseDir: folder, internal: opts.internal });
|
|
23560
|
+
const brand = typeof deck.brand === "string" ? opts.brand?.(deck.brand) ?? null : deck.brand ?? null;
|
|
23561
|
+
if (typeof deck.brand === "string" && !brand) throw new Error(`No brand kit called ${deck.brand}.`);
|
|
23562
|
+
style = applyBrand(style, brand);
|
|
23563
|
+
const total = deck.slides.length;
|
|
23564
|
+
const targets = (opts.only?.length ? opts.only : deck.slides.map((_, i) => i + 1)).filter((n) => n >= 1 && n <= total);
|
|
23565
|
+
const renders = join10(folder, "renders"), html = join10(folder, "html"), history = join10(renders, "history");
|
|
23566
|
+
for (const d of [renders, html]) mkdirSync8(d, { recursive: true });
|
|
23567
|
+
const queue = [...targets];
|
|
23568
|
+
const results = [];
|
|
23569
|
+
const worker = async () => {
|
|
23570
|
+
for (let n = queue.shift(); n !== void 0; n = queue.shift()) {
|
|
23571
|
+
const name = String(n).padStart(2, "0");
|
|
23572
|
+
const page2 = join10(html, `${name}.html`), png = join10(renders, `${name}.png`);
|
|
23573
|
+
writeFileSync8(page2, slideHtml(deck.slides[n - 1], style, n, total, { baseDir: folder, handle: deck.handle }));
|
|
23574
|
+
if (existsSync8(png)) {
|
|
23575
|
+
mkdirSync8(history, { recursive: true });
|
|
23576
|
+
let v = 1;
|
|
23577
|
+
while (existsSync8(join10(history, `${name}-v${v}.png`))) v++;
|
|
23578
|
+
copyFileSync3(png, join10(history, `${name}-v${v}.png`));
|
|
23579
|
+
}
|
|
23580
|
+
const [W, H] = style.size;
|
|
23581
|
+
const shot = await chrome(bin, [`--window-size=${W},${H}`, `--screenshot=${png}`, fileUrl(page2)]);
|
|
23582
|
+
if (!existsSync8(png) || shot.code !== 0) throw new Error(`Chrome could not render slide ${n}.`);
|
|
23583
|
+
const dom = await chrome(bin, [`--window-size=${W},${H}`, "--dump-dom", fileUrl(page2)]);
|
|
23584
|
+
let qa = {};
|
|
23585
|
+
try {
|
|
23586
|
+
qa = JSON.parse((dom.out.match(/data-qa="([^"]*)"/)?.[1] ?? "{}").replace(/"/g, '"'));
|
|
23587
|
+
} catch {
|
|
23588
|
+
}
|
|
23589
|
+
const s = deck.slides[n - 1];
|
|
23590
|
+
const ratio = s.image && (s.image.position ?? style.image.position) === "full" && style.text.treatment !== "plain" ? void 0 : contrast(s.color ?? style.colors.text, s.bg ?? style.colors.bg);
|
|
23591
|
+
const flags = [
|
|
23592
|
+
...qa.overflow ? ["text still does not fit: shorten it"] : [],
|
|
23593
|
+
...qa.shrunk >= 25 ? [`text shrunk ${qa.shrunk}% to fit: consider fewer words`] : [],
|
|
23594
|
+
...qa.missing_fonts?.length ? [`font not loaded (fallback used): ${qa.missing_fonts.join(", ")}`] : [],
|
|
23595
|
+
...ratio !== void 0 && ratio < 4.5 ? [`low contrast ${ratio}:1 (text on background)`] : [],
|
|
23596
|
+
...s.image && !/^(https?:|data:)/.test(s.image.src) && !existsSync8(resolve4(folder, s.image.src.replace(/^~/, homedir4()))) ? [`image missing: ${s.image.src}`] : []
|
|
23597
|
+
];
|
|
23598
|
+
results.push({ n, png, shrunk: qa.shrunk, overflow: qa.overflow, missing_fonts: qa.missing_fonts, contrast: ratio, flags });
|
|
23599
|
+
}
|
|
23600
|
+
};
|
|
23601
|
+
await Promise.all(Array.from({ length: Math.min(opts.concurrency ?? 4, targets.length) }, worker));
|
|
23602
|
+
results.sort((a, b) => a.n - b.n);
|
|
23603
|
+
const all = deck.slides.map((_, i) => join10(renders, `${String(i + 1).padStart(2, "0")}.png`)).filter(existsSync8);
|
|
23604
|
+
const preview = tileSheet(all, join10(folder, "preview.jpg"), 360);
|
|
23605
|
+
return { folder, style: style.name, rendered: results, slides: all, preview, next: "Show the user the preview (and any slide they asked about). For changes, edit deck.json or pass edits, then render only those slides." };
|
|
23606
|
+
}
|
|
23607
|
+
function applyEdits(deck, edits) {
|
|
23608
|
+
const touched = [];
|
|
23609
|
+
for (const e of edits) {
|
|
23610
|
+
const s = deck.slides[e.n - 1];
|
|
23611
|
+
if (!s) throw new Error(`No slide ${e.n} (the deck has ${deck.slides.length}).`);
|
|
23612
|
+
for (const [k, v] of Object.entries(e.set)) {
|
|
23613
|
+
if (v === null) delete s[k];
|
|
23614
|
+
else if (v && typeof v === "object" && !Array.isArray(v) && s[k] && typeof s[k] === "object") s[k] = { ...s[k], ...v };
|
|
23615
|
+
else s[k] = v;
|
|
23616
|
+
}
|
|
23617
|
+
touched.push(e.n);
|
|
23618
|
+
}
|
|
23619
|
+
validateDeck(deck);
|
|
23620
|
+
return { deck, touched };
|
|
23621
|
+
}
|
|
23622
|
+
async function renderCarousel(input, opts = {}) {
|
|
23623
|
+
const folder = resolve4(input.folder.replace(/^~/, homedir4()));
|
|
23624
|
+
mkdirSync8(folder, { recursive: true });
|
|
23625
|
+
const file = join10(folder, "deck.json");
|
|
23626
|
+
let only = input.only;
|
|
23627
|
+
if (input.deck) {
|
|
23628
|
+
writeFileSync8(file, JSON.stringify(validateDeck(input.deck), null, 2));
|
|
23629
|
+
} else {
|
|
23630
|
+
if (!existsSync8(file)) throw new Error(`No deck.json in ${folder}: pass deck first.`);
|
|
23631
|
+
let deck = validateDeck(JSON.parse(readFileSync9(file, "utf8")));
|
|
23632
|
+
if (input.style !== void 0) deck.style = input.style;
|
|
23633
|
+
if (input.edits?.length) {
|
|
23634
|
+
const r = applyEdits(deck, input.edits);
|
|
23635
|
+
deck = r.deck;
|
|
23636
|
+
if (!only?.length && input.style === void 0) only = r.touched;
|
|
23637
|
+
}
|
|
23638
|
+
writeFileSync8(file, JSON.stringify(deck, null, 2));
|
|
23639
|
+
}
|
|
23640
|
+
return renderDeck(folder, { only, internal: opts.internal, brand: opts.brand });
|
|
23641
|
+
}
|
|
23642
|
+
|
|
23643
|
+
// src/community/local/carousel.ts
|
|
23644
|
+
var home = (p) => resolve5(p.replace(/^~/, homedir5()));
|
|
23645
|
+
async function breakdownCarousel(input) {
|
|
23646
|
+
ensureStudio();
|
|
23647
|
+
const src = input.source.trim();
|
|
23648
|
+
const base = join11(studio.references(), "carousels");
|
|
23649
|
+
const next = "Open sheet.jpg and every slide, then follow get_method('copy_carousel').";
|
|
23650
|
+
if (/^https?:\/\//i.test(src)) {
|
|
23651
|
+
const { meta: meta2 } = await call("carousel", { url: src }).catch((e) => {
|
|
23652
|
+
throw new Error(`Couldn't fetch that carousel right now (${e instanceof Error ? e.message : e}). Save the slides as images in one folder and give me the folder instead.`);
|
|
23653
|
+
});
|
|
23654
|
+
const dir2 = join11(base, `${day()}-${slug(meta2.owner ?? meta2.platform)}-${meta2.code ?? "post"}`);
|
|
23655
|
+
return { ...await saveCarousel(meta2, dir2), next };
|
|
23656
|
+
}
|
|
23657
|
+
const folder = home(src);
|
|
23658
|
+
if (!existsSync9(folder)) throw new Error(`No such folder: ${folder}`);
|
|
23659
|
+
return { ...await saveCarousel(null, join11(base, `${day()}-${slug(folder.split(/[\\/]/).pop() ?? "carousel")}`), { folder }), next };
|
|
23660
|
+
}
|
|
23661
|
+
function renderCarouselLocal(input) {
|
|
23662
|
+
ensureStudio();
|
|
23663
|
+
const root = join11(HOME(), "carousels");
|
|
23664
|
+
const folder = input.folder ? isAbsolute3(input.folder) || input.folder.startsWith("~") ? home(input.folder) : join11(root, input.folder) : join11(root, `${day()}-${slug(input.title ?? input.deck?.title ?? "carousel")}`);
|
|
23665
|
+
return renderCarousel({ folder, deck: input.deck, edits: input.edits, style: input.style, only: input.only }, { brand: (ref) => ref === "mine" ? brandFromJson(getBrand()) : null });
|
|
23666
|
+
}
|
|
22656
23667
|
|
|
22657
23668
|
// src/community/local/server.ts
|
|
22658
|
-
process.env.PATH = (WIN ? [process.env.PATH,
|
|
23669
|
+
process.env.PATH = (WIN ? [process.env.PATH, join12(process.env.LOCALAPPDATA ?? join12(homedir6(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join12(homedir6(), ".local", "bin")]).filter(Boolean).join(delimiter2);
|
|
23670
|
+
useTools();
|
|
22659
23671
|
var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
22660
23672
|
var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
|
|
22661
23673
|
var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
|
|
23674
|
+
async function tools() {
|
|
23675
|
+
const s = await ensureTools();
|
|
23676
|
+
if (s.failed.length) throw new Error(`Couldn't download Cutroom's video tools (${s.failed.map((f) => f.error).join("; ")}). Check the internet connection, then run cutroom_doctor.`);
|
|
23677
|
+
return s.note;
|
|
23678
|
+
}
|
|
23679
|
+
var local = (op) => Promise.resolve().then(async () => {
|
|
23680
|
+
const note = await tools();
|
|
23681
|
+
const out = result(await op());
|
|
23682
|
+
return note ? { content: [{ type: "text", text: note }, ...out.content] } : out;
|
|
23683
|
+
}).catch(failure2);
|
|
22662
23684
|
var INSTRUCTIONS = `Cutroom: short-form video help. Speak plainly; the user is not technical. Never show tool or effect codes; use plain names.
|
|
22663
23685
|
|
|
22664
|
-
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.
|
|
23686
|
+
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. Cutroom downloads its own video tools once into the Cutroom folder; the first check or first video takes a minute longer for it, so say "one-time setup, about a minute" before it starts. Node.js 22 or newer is the only thing to install: if it is missing or old, ask, then run the fix cutroom_doctor gives, and tell them to restart the Claude app afterwards. Never mention ffmpeg, whisper or other tool names unless they ask.
|
|
22665
23687
|
|
|
22666
23688
|
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.
|
|
22667
23689
|
|
|
22668
23690
|
Hooks, scripts, captions: get_method('hooks', 'script' or 'caption') and follow it.
|
|
22669
23691
|
|
|
22670
|
-
Copy a reel (their own version of a reel's format, on their topic): get_method('copy_reel') and follow it step by step. Nothing gets filmed or rendered until they approve the script
|
|
23692
|
+
Copy a reel (their own version of a reel's format, on their topic): get_method('copy_reel') and follow it step by step. Nothing gets filmed or rendered until they approve the script.${MEMBER_CAROUSEL ? `
|
|
23693
|
+
|
|
23694
|
+
Copy a carousel (their own version of an Instagram or TikTok carousel, on their topic, as new slides): get_method('copy_carousel') and follow it step by step. Nothing renders until they approve the slide text.` : ""}
|
|
22671
23695
|
|
|
22672
23696
|
Break down a reel: breakdown_reel, then get_method('breakdown_writeup').
|
|
22673
23697
|
Tear down a creator: creator_teardown (compare_to = their handle), then get_method('teardown_writeup').
|
|
@@ -22680,7 +23704,7 @@ Their studio is the Cutroom folder in their home folder (cutroom_doctor returns
|
|
|
22680
23704
|
await serveStdio(() => {
|
|
22681
23705
|
const server = new McpServer({ name: "cutroom", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
22682
23706
|
server.registerTool("cutroom_doctor", {
|
|
22683
|
-
description: "Check Cutroom is ready: makes their studio folders,
|
|
23707
|
+
description: "Check Cutroom is ready: makes their studio folders, downloads its video tools the first time (about a minute, once), checks Node.js 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.",
|
|
22684
23708
|
inputSchema: object({})
|
|
22685
23709
|
}, async () => safe(() => doctor()));
|
|
22686
23710
|
server.registerTool("cutroom_sign_in", {
|
|
@@ -22692,7 +23716,7 @@ await serveStdio(() => {
|
|
|
22692
23716
|
inputSchema: object({})
|
|
22693
23717
|
}, async () => safe(() => call("methods")));
|
|
22694
23718
|
server.registerTool("get_method", {
|
|
22695
|
-
description:
|
|
23719
|
+
description: `Get a Cutroom method and follow it step by step: 'hooks' for video hooks, 'script' for an 8-line short script, 'caption' for the post caption and comment CTA, 'copy_reel' to copy a reel's format onto their topic${MEMBER_CAROUSEL ? ", 'copy_carousel' to rebuild a carousel on their topic" : ""}, 'breakdown_writeup' and 'teardown_writeup' after those tools. Always fetch the method fresh; it is updated on the server.`,
|
|
22696
23720
|
inputSchema: object({ name: string2().min(1) })
|
|
22697
23721
|
}, async ({ name }) => safe(async () => {
|
|
22698
23722
|
const m = await call("method", { name });
|
|
@@ -22701,7 +23725,7 @@ await serveStdio(() => {
|
|
|
22701
23725
|
${m.text}`;
|
|
22702
23726
|
}));
|
|
22703
23727
|
server.registerTool("score_hooks", {
|
|
22704
|
-
description: "Score and rank 1 to 20
|
|
23728
|
+
description: "Score and rank 1 to 20 spoken hook lines (stops the right viewer, clarity, specificity, tension, one breath, and about the viewer rather than the creator or their tool, which counts double). The top 3 also get four failure checks (delay, confusion, irrelevance, disinterest) with the exact failing words, where the topic becomes clear and where the contrast first comes. Also checks a filmed opening (its first 10 to 15 seconds as one text). Returns a score out of 100 and the pass mark.",
|
|
22705
23729
|
inputSchema: object({ hooks: array(string2().min(1)).min(1).max(20), audience: string2().optional().describe("Who the video is for, e.g. 'founders of B2B SaaS'.") })
|
|
22706
23730
|
}, async ({ hooks, audience }) => safe(() => call("score", { kind: "hook", texts: hooks, audience })));
|
|
22707
23731
|
server.registerTool("score_script", {
|
|
@@ -22711,11 +23735,28 @@ ${m.text}`;
|
|
|
22711
23735
|
server.registerTool("breakdown_reel", {
|
|
22712
23736
|
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.",
|
|
22713
23737
|
inputSchema: object({ source: string2().min(1).describe("Instagram reel link or a local video file path.") })
|
|
22714
|
-
}, async (input) =>
|
|
23738
|
+
}, async (input) => local(() => breakdownReel(input)));
|
|
23739
|
+
if (MEMBER_CAROUSEL) {
|
|
23740
|
+
server.registerTool("breakdown_carousel", {
|
|
23741
|
+
description: "Fetch one carousel (Instagram carousel or TikTok photo slideshow link, or a folder of slide images on this computer) to copy its format. Saves the slides, sheet.jpg (all slides numbered), the caption and each slide's measured colors in a folder in their studio's references/carousels/. Then use get_method('copy_carousel'). Study only: never reuse the creator's images, words, names or logos.",
|
|
23742
|
+
inputSchema: object({ source: string2().min(1).describe("Instagram or TikTok carousel link, or a folder of slide images.") })
|
|
23743
|
+
}, async (input) => safe(() => breakdownCarousel(input)));
|
|
23744
|
+
server.registerTool("render_carousel", {
|
|
23745
|
+
description: `Render a carousel deck to 1080x1350 PNG slides on this computer (needs Google Chrome): renders/ and preview.jpg in their studio's carousels/<date>-<name>/. Pass deck (style + slides) to start; edits [{n, set:{...}}] to change slides (only those re-render, the old ones are kept in renders/history/); style to change the whole look. Layouts: ${LAYOUTS.join(", ")}. Styles: ${Object.keys(STYLE_PRESETS).filter((k) => !STYLE_PRESETS[k].internal).join(", ")}, a style.json path, or an inline style; brand "mine" puts their brand kit's colors and font on top. Returns flags per slide to check. The deck format is in get_method('copy_carousel').`,
|
|
23746
|
+
inputSchema: object({
|
|
23747
|
+
folder: string2().optional().describe("The carousel's folder (a name in carousels/, or a full path). Default a new one from the title."),
|
|
23748
|
+
title: string2().optional(),
|
|
23749
|
+
deck: any().optional().describe("The whole deck: { title, style, brand?, handle?, slides: [...] }."),
|
|
23750
|
+
edits: array(object({ n: number2().int().min(1), set: record(string2(), any()) })).optional(),
|
|
23751
|
+
style: any().optional(),
|
|
23752
|
+
only: array(number2().int().min(1)).optional()
|
|
23753
|
+
})
|
|
23754
|
+
}, async (input) => safe(() => renderCarouselLocal(input)));
|
|
23755
|
+
}
|
|
22715
23756
|
server.registerTool("creator_teardown", {
|
|
22716
23757
|
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').",
|
|
22717
23758
|
inputSchema: object({ handle: string2().min(1), limit: number2().int().min(1).max(30).default(12), compare_to: string2().optional() })
|
|
22718
|
-
}, async (input) =>
|
|
23759
|
+
}, async (input) => local(() => creatorTeardown(input)));
|
|
22719
23760
|
server.registerTool("list_styles", {
|
|
22720
23761
|
description: "The Cutroom finishing menu: layouts, caption styles and effects (with the text each effect needs), plus timing rules. Read it before render_reel.",
|
|
22721
23762
|
inputSchema: object({})
|
|
@@ -22744,24 +23785,26 @@ ${m.text}`;
|
|
|
22744
23785
|
cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second."),
|
|
22745
23786
|
language: string2().optional().describe("Spoken language code if not English, e.g. ko, es, ja (uses a larger model, downloaded once).")
|
|
22746
23787
|
})
|
|
22747
|
-
}, async (input) =>
|
|
23788
|
+
}, async (input) => local(() => prepareFootage(input)));
|
|
22748
23789
|
server.registerTool("plan_reel", {
|
|
22749
|
-
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.",
|
|
23790
|
+
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; custom scenes are marked CUSTOM. About 10 to 20 seconds (plus 1 to 2 minutes for new custom scenes, 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.",
|
|
22750
23791
|
inputSchema: object({
|
|
22751
23792
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22752
23793
|
layout: string2().default("full-frame"),
|
|
22753
23794
|
caption: string2().default("two-tier"),
|
|
22754
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(
|
|
23795
|
+
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(15).optional() }).passthrough()).max(24).default([]).describe("Same as render_reel: e.g. [{slug:'number-pop', start:3.2, number:'24', label:'hours later'}]. Menu effects last 1.2 to 5s. A custom scene, made by Cutroom for that line (Explainer Panel and Split Reel layouts): {slug:'custom', start, dur (1.5 to 15s, the whole line or idea), request (optional, what they want it to show, e.g. 'a phone scrolling a feed'), job (optional: number, comparison, process, app, social-proof, list, chart, map, emphasis)}. plan_reel returns each custom slot with its scene id: pass it back unchanged to reuse the scene; change request (or add redo:true) for a new one. A new scene takes about 1 to 2 minutes; they are written at the same time."),
|
|
23796
|
+
about: string2().optional().describe("What the video is about, in a sentence or two (for custom scenes)."),
|
|
22755
23797
|
images: array(string2()).max(8).optional().describe("Pictures for the layout, full paths on this computer."),
|
|
22756
23798
|
emphasis: array(string2()).optional(),
|
|
22757
23799
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word.")
|
|
22758
23800
|
})
|
|
22759
23801
|
}, async (input) => {
|
|
22760
23802
|
try {
|
|
23803
|
+
const note = await tools();
|
|
22761
23804
|
const r = await planReel(input);
|
|
22762
|
-
return { content: [{ type: "text", text: `${r.text}
|
|
23805
|
+
return { content: [...note ? [{ type: "text", text: note }] : [], { type: "text", text: `${r.text}
|
|
22763
23806
|
|
|
22764
|
-
${r.next}` }, { type: "image", data:
|
|
23807
|
+
${r.next}` }, { type: "image", data: readFileSync10(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
|
|
22765
23808
|
} catch (e) {
|
|
22766
23809
|
return failure2(e);
|
|
22767
23810
|
}
|
|
@@ -22772,18 +23815,19 @@ ${r.next}` }, { type: "image", data: readFileSync6(r.storyboard).toString("base6
|
|
|
22772
23815
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22773
23816
|
layout: string2().default("full-frame"),
|
|
22774
23817
|
caption: string2().default("two-tier"),
|
|
22775
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(
|
|
23818
|
+
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(15).optional() }).passthrough()).max(24).default([]).describe("e.g. [{slug:'number-pop', start:3.2, number:'24', label:'hours later'}]. Params per effect are in list_styles. Effect params image / images are picture files on this computer (full paths, jpg/png/webp); they are copied into the render folder, only the file name goes to Cutroom."),
|
|
22776
23819
|
images: array(string2()).max(8).optional().describe("Pictures for the layout (image-cluster: 3 to 5, cutout-grid: 1 to 8, cutout-backdrop: optional): full paths of jpg, png or webp files on this computer. 8 pictures at most per reel, layout and effects together."),
|
|
22777
23820
|
emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
|
|
22778
23821
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word."),
|
|
23822
|
+
about: string2().optional().describe("What the video is about (for custom scenes)."),
|
|
22779
23823
|
draft: boolean2().default(false),
|
|
22780
23824
|
plan: boolean2().default(false).describe("Render the plan saved by the last plan_reel for this project (ignores layout, caption, effects and images given here).")
|
|
22781
23825
|
})
|
|
22782
|
-
}, async (input) =>
|
|
23826
|
+
}, async (input) => local(() => renderReel(input)));
|
|
22783
23827
|
server.registerTool("qa_video", {
|
|
22784
23828
|
description: "QA any finished vertical video: black or frozen picture, loudness, dead air, size, and every sampled frame judged for visual problems (cut-off text, covered face, empty frames). Returns passed plus the issues with timestamps.",
|
|
22785
23829
|
inputSchema: object({ video: string2().min(1), layout: string2().optional().describe("The layout used, if it puts the face in a card (e.g. explainer-panel), so the card edge is not flagged.") })
|
|
22786
|
-
}, async ({ video, layout: layout2 }) =>
|
|
23830
|
+
}, async ({ video, layout: layout2 }) => local(() => qaVideo({ video, layout: layout2 })));
|
|
22787
23831
|
for (const p of PROMPTS) {
|
|
22788
23832
|
const shape = Object.fromEntries(p.args.map((a) => [a.name, string2().optional().describe(a.description)]));
|
|
22789
23833
|
server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema: object(shape) }, (args) => ({
|