@keepylabs/cutroom 0.7.1 → 0.9.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 +1412 -273
- 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 join11 } 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,
|
|
@@ -19201,8 +19196,8 @@ var require_formats = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
19201
19196
|
if (!matches) return false;
|
|
19202
19197
|
const year = +matches[1];
|
|
19203
19198
|
const month = +matches[2];
|
|
19204
|
-
const
|
|
19205
|
-
return month >= 1 && month <= 12 &&
|
|
19199
|
+
const day4 = +matches[3];
|
|
19200
|
+
return month >= 1 && month <= 12 && day4 >= 1 && day4 <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]);
|
|
19206
19201
|
}
|
|
19207
19202
|
function compareDate(d1, d2) {
|
|
19208
19203
|
if (!(d1 && d2)) return void 0;
|
|
@@ -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,17 +21550,17 @@ function toError(value) {
|
|
|
21555
21550
|
}
|
|
21556
21551
|
|
|
21557
21552
|
// src/community/local/server.ts
|
|
21558
|
-
import { readFileSync as
|
|
21553
|
+
import { readFileSync as readFileSync9 } 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 existsSync6, mkdirSync as mkdirSync6, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync7, renameSync as renameSync4, rmSync as rmSync5, statSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
21558
|
+
import { homedir as homedir3, tmpdir } from "node:os";
|
|
21559
|
+
import { basename as basename2, extname, join as join8, 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";
|
|
21568
|
-
import { mkdirSync, readFileSync } from "node:fs";
|
|
21563
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21569
21564
|
import { join } from "node:path";
|
|
21570
21565
|
var ffmpegErr = (args) => spawnSync2("ffmpeg", ["-nostdin", "-hide_banner", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).stderr ?? "";
|
|
21571
21566
|
function probe(file) {
|
|
@@ -21609,6 +21604,34 @@ function sampleFrames(file, dir, duration3, interval = 1, max = 40) {
|
|
|
21609
21604
|
}
|
|
21610
21605
|
return { frames: out, interval: step };
|
|
21611
21606
|
}
|
|
21607
|
+
function sheetGrid(n) {
|
|
21608
|
+
const cols = Math.max(1, Math.min(8, n));
|
|
21609
|
+
return { cols, rows: Math.max(1, Math.ceil(n / cols)) };
|
|
21610
|
+
}
|
|
21611
|
+
function contactSheet(frames, out) {
|
|
21612
|
+
if (!frames.length) return null;
|
|
21613
|
+
const tmp = `${out}.parts`;
|
|
21614
|
+
mkdirSync(tmp, { recursive: true });
|
|
21615
|
+
try {
|
|
21616
|
+
const parts = frames.map((f, i) => {
|
|
21617
|
+
const part = join(tmp, `${String(i).padStart(3, "0")}.jpg`);
|
|
21618
|
+
const label = `${Math.floor(f.t / 60)}\\:${(f.t % 60).toFixed(1).padStart(4, "0")}`;
|
|
21619
|
+
const scale = "scale=200:-2";
|
|
21620
|
+
const labelled = spawnSync2("ffmpeg", ["-v", "error", "-y", "-i", f.path, "-vf", `${scale},drawtext=text='${label}':x=8:y=8:fontsize=22:fontcolor=white:box=1:boxcolor=black@0.7:boxborderw=6`, part]);
|
|
21621
|
+
if (labelled.status !== 0) execFileSync("ffmpeg", ["-v", "error", "-y", "-i", f.path, "-vf", scale, part]);
|
|
21622
|
+
return part;
|
|
21623
|
+
});
|
|
21624
|
+
const list = join(tmp, "list.txt");
|
|
21625
|
+
writeFileSync(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
|
|
21626
|
+
const { cols, rows } = sheetGrid(parts.length);
|
|
21627
|
+
execFileSync("ffmpeg", ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", list, "-vf", `tile=${cols}x${rows}:padding=4:color=black`, "-frames:v", "1", "-q:v", "4", out]);
|
|
21628
|
+
return out;
|
|
21629
|
+
} catch {
|
|
21630
|
+
return null;
|
|
21631
|
+
} finally {
|
|
21632
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
21633
|
+
}
|
|
21634
|
+
}
|
|
21612
21635
|
function parseSilences(log, duration3) {
|
|
21613
21636
|
const out = [];
|
|
21614
21637
|
let start = null;
|
|
@@ -21658,29 +21681,146 @@ function technicalChecks(file) {
|
|
|
21658
21681
|
}
|
|
21659
21682
|
|
|
21660
21683
|
// src/community/local/studio.ts
|
|
21661
|
-
import {
|
|
21662
|
-
import {
|
|
21684
|
+
import { existsSync as existsSync5, renameSync as renameSync3 } from "node:fs";
|
|
21685
|
+
import { join as join7 } from "node:path";
|
|
21686
|
+
|
|
21687
|
+
// src/studio-core.ts
|
|
21688
|
+
var studio_core_exports = {};
|
|
21689
|
+
__export(studio_core_exports, {
|
|
21690
|
+
STATUSES: () => STATUSES,
|
|
21691
|
+
addCheck: () => addCheck,
|
|
21692
|
+
day: () => day,
|
|
21693
|
+
ensureLayout: () => ensureLayout,
|
|
21694
|
+
keepRender: () => keepRender,
|
|
21695
|
+
layout: () => layout,
|
|
21696
|
+
nextVersion: () => nextVersion,
|
|
21697
|
+
setStatus: () => setStatus,
|
|
21698
|
+
slug: () => slug,
|
|
21699
|
+
startVideoNotes: () => startVideoNotes,
|
|
21700
|
+
statusOf: () => statusOf,
|
|
21701
|
+
takeRaw: () => takeRaw,
|
|
21702
|
+
videoFolderOf: () => videoFolderOf,
|
|
21703
|
+
videoPath: () => videoPath
|
|
21704
|
+
});
|
|
21705
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
21706
|
+
import { basename, dirname, isAbsolute, join as join2, relative, resolve } from "node:path";
|
|
21707
|
+
var STATUSES = ["footage ready", "plan ready", "preview", "final", "posted"];
|
|
21708
|
+
var day = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
21709
|
+
var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
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);
|
|
21713
|
+
for (const d of [s.brand, s.references, s.inbox, s.videos]) mkdirSync2(d, { recursive: true });
|
|
21714
|
+
const claude = join2(home2, "CLAUDE.md");
|
|
21715
|
+
if (!existsSync(claude)) writeFileSync2(claude, claudeMd);
|
|
21716
|
+
return s;
|
|
21717
|
+
}
|
|
21718
|
+
function videoPath(home2, topic, date4 = day()) {
|
|
21719
|
+
const videos = layout(home2).videos;
|
|
21720
|
+
const t = slug(topic) || "video";
|
|
21721
|
+
const existing = existsSync(videos) ? readdirSync(videos).filter((name) => /^\d{4}-\d{2}-\d{2}-/.test(name) && name.slice(11) === t).sort() : [];
|
|
21722
|
+
return join2(videos, existing.at(-1) ?? `${date4}-${t}`);
|
|
21723
|
+
}
|
|
21724
|
+
function videoFolderOf(path) {
|
|
21725
|
+
for (let p = resolve(path); p !== dirname(p); p = dirname(p)) {
|
|
21726
|
+
if (basename(dirname(p)) === "videos" && existsSync(join2(p, "plan.md"))) return p;
|
|
21727
|
+
}
|
|
21728
|
+
return null;
|
|
21729
|
+
}
|
|
21730
|
+
var inside = (child, parent) => {
|
|
21731
|
+
const r = relative(parent, child);
|
|
21732
|
+
return !!r && !r.startsWith("..") && !isAbsolute(r);
|
|
21733
|
+
};
|
|
21734
|
+
function takeRaw(source, folder, inbox) {
|
|
21735
|
+
const raw = join2(folder, "raw");
|
|
21736
|
+
const file = resolve(source);
|
|
21737
|
+
if (inside(file, raw)) return { file, how: "already here" };
|
|
21738
|
+
mkdirSync2(raw, { recursive: true });
|
|
21739
|
+
let target = join2(raw, basename(file));
|
|
21740
|
+
for (let n = 2; existsSync(target); n++) target = join2(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
|
|
21741
|
+
if (inbox && inside(file, inbox)) {
|
|
21742
|
+
try {
|
|
21743
|
+
renameSync(file, target);
|
|
21744
|
+
return { file: target, how: "moved from inbox" };
|
|
21745
|
+
} catch {
|
|
21746
|
+
}
|
|
21747
|
+
}
|
|
21748
|
+
copyFileSync(file, target);
|
|
21749
|
+
return { file: target, how: "copied" };
|
|
21750
|
+
}
|
|
21751
|
+
function setStatus(folder, status, plan) {
|
|
21752
|
+
const file = join2(folder, "plan.md");
|
|
21753
|
+
const current = existsSync(file) ? readFileSync2(file, "utf8") : `# ${basename(folder)}
|
|
21754
|
+
|
|
21755
|
+
status:
|
|
21756
|
+
`;
|
|
21757
|
+
const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
|
|
21758
|
+
status: ${status}
|
|
21759
|
+
`);
|
|
21760
|
+
writeFileSync2(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
|
|
21761
|
+
|
|
21762
|
+
## Plan
|
|
21763
|
+
${plan}
|
|
21764
|
+
`);
|
|
21765
|
+
}
|
|
21766
|
+
var statusOf = (folder) => {
|
|
21767
|
+
const file = join2(folder, "plan.md");
|
|
21768
|
+
return existsSync(file) ? readFileSync2(file, "utf8").match(/^status: *(.*)$/m)?.[1].trim() ?? null : null;
|
|
21769
|
+
};
|
|
21770
|
+
var NO_SCRIPT = "(no script yet)";
|
|
21771
|
+
function startVideoNotes(folder, script, who = "is said", source = "from the footage; fix any misheard words here") {
|
|
21772
|
+
const scriptFile = join2(folder, "script.md"), facts = join2(folder, "facts.md"), notes = join2(folder, "references", "notes.md");
|
|
21773
|
+
mkdirSync2(join2(folder, "references"), { recursive: true });
|
|
21774
|
+
if (!existsSync(notes)) writeFileSync2(notes, "# References for this video\n\nWhat this video copies and why it works, one line each.\n\n");
|
|
21775
|
+
if (!existsSync(facts)) writeFileSync2(facts, "# Facts\n\nEvery number or claim that shows on screen, with where it comes from.\n\n| on screen | source |\n| - | - |\n");
|
|
21776
|
+
const waiting = !existsSync(scriptFile) || script !== void 0 && readFileSync2(scriptFile, "utf8").includes(NO_SCRIPT);
|
|
21777
|
+
if (waiting) writeFileSync2(scriptFile, `# Script
|
|
21778
|
+
|
|
21779
|
+
What ${who}, word for word${script ? ` (${source})` : ""}.
|
|
21780
|
+
|
|
21781
|
+
${script ?? NO_SCRIPT}
|
|
21782
|
+
`);
|
|
21783
|
+
}
|
|
21784
|
+
function addCheck(folder, entry) {
|
|
21785
|
+
const file = join2(folder, "checks.md");
|
|
21786
|
+
if (!existsSync(file)) writeFileSync2(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
|
|
21787
|
+
appendFileSync(file, `
|
|
21788
|
+
${entry.trim()}
|
|
21789
|
+
`);
|
|
21790
|
+
}
|
|
21791
|
+
function nextVersion(base, ext, dirs) {
|
|
21792
|
+
let n = 1;
|
|
21793
|
+
while (dirs.some((d) => existsSync(join2(d, `${base}-v${n}${ext}`)))) n++;
|
|
21794
|
+
return `${base}-v${n}${ext}`;
|
|
21795
|
+
}
|
|
21796
|
+
function keepRender(folder, file, name = basename(file)) {
|
|
21797
|
+
mkdirSync2(join2(folder, "renders"), { recursive: true });
|
|
21798
|
+
let target = join2(folder, "renders", name);
|
|
21799
|
+
for (let n = 2; existsSync(target); n++) target = join2(folder, "renders", name.replace(/(\.[^.]*)?$/, `-${n}$1`));
|
|
21800
|
+
copyFileSync(file, target);
|
|
21801
|
+
return target;
|
|
21802
|
+
}
|
|
21663
21803
|
|
|
21664
21804
|
// src/community/local/tools.ts
|
|
21665
|
-
import { createWriteStream, existsSync as
|
|
21666
|
-
import { homedir } from "node:os";
|
|
21667
|
-
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";
|
|
21668
21808
|
import { Readable } from "node:stream";
|
|
21669
21809
|
import { pipeline } from "node:stream/promises";
|
|
21670
21810
|
|
|
21671
21811
|
// src/community/local/signin.ts
|
|
21672
21812
|
import { spawn } from "node:child_process";
|
|
21673
21813
|
import { createHash, randomBytes } from "node:crypto";
|
|
21674
|
-
import { chmodSync, existsSync, mkdirSync as
|
|
21814
|
+
import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
21675
21815
|
import { createServer } from "node:http";
|
|
21676
|
-
import { join as
|
|
21816
|
+
import { join as join3 } from "node:path";
|
|
21677
21817
|
var WHOP_APP = "app_bFhJFdBJn9jZ0v";
|
|
21678
21818
|
var PORTS = [53171, 53172, 53173];
|
|
21679
21819
|
var WAIT_MS = 5 * 6e4;
|
|
21680
|
-
var keyFile = () =>
|
|
21820
|
+
var keyFile = () => join3(HOME(), ".cutroom-key");
|
|
21681
21821
|
function currentKey() {
|
|
21682
21822
|
try {
|
|
21683
|
-
const k =
|
|
21823
|
+
const k = readFileSync3(keyFile(), "utf8").trim();
|
|
21684
21824
|
if (k) return k;
|
|
21685
21825
|
} catch {
|
|
21686
21826
|
}
|
|
@@ -21688,14 +21828,14 @@ function currentKey() {
|
|
|
21688
21828
|
return env && !env.includes("${") ? env : null;
|
|
21689
21829
|
}
|
|
21690
21830
|
function saveKey(key) {
|
|
21691
|
-
|
|
21692
|
-
|
|
21831
|
+
mkdirSync3(HOME(), { recursive: true });
|
|
21832
|
+
writeFileSync3(keyFile(), key, { mode: 384 });
|
|
21693
21833
|
try {
|
|
21694
21834
|
chmodSync(keyFile(), 384);
|
|
21695
21835
|
} catch {
|
|
21696
21836
|
}
|
|
21697
21837
|
}
|
|
21698
|
-
var forgetKey = () =>
|
|
21838
|
+
var forgetKey = () => rmSync2(keyFile(), { force: true });
|
|
21699
21839
|
function openBrowser(url2) {
|
|
21700
21840
|
const [cmd, args] = process.platform === "darwin" ? ["open", [url2]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url2]] : ["xdg-open", [url2]];
|
|
21701
21841
|
try {
|
|
@@ -21787,10 +21927,163 @@ ${authorize}
|
|
|
21787
21927
|
}
|
|
21788
21928
|
}
|
|
21789
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, dir, language, deps = {}) {
|
|
22044
|
+
const audio = extractAudio(video, join5(dir, "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, dir, 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", dir, ...lang === "en" ? [] : ["--language", lang, "-m", "large-v3"]], { cwd: dir, 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(dir, "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, dir, language) {
|
|
22074
|
+
try {
|
|
22075
|
+
return await cloudWords(video, dir, language);
|
|
22076
|
+
} catch (e) {
|
|
22077
|
+
const local2 = localWhisperWords(video, dir, language);
|
|
22078
|
+
if (local2) return local2;
|
|
22079
|
+
throw e;
|
|
22080
|
+
}
|
|
22081
|
+
}
|
|
22082
|
+
|
|
21790
22083
|
// src/community/local/tools.ts
|
|
21791
|
-
var VERSION = "0.
|
|
22084
|
+
var VERSION = "0.9.0";
|
|
21792
22085
|
var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
|
|
21793
|
-
var HOME = () => process.env.CUTROOM_HOME ||
|
|
22086
|
+
var HOME = () => process.env.CUTROOM_HOME || join6(homedir2(), "Cutroom");
|
|
21794
22087
|
async function call(op, body = {}, fetchImpl = fetch) {
|
|
21795
22088
|
const key = currentKey();
|
|
21796
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 });
|
|
@@ -21810,8 +22103,8 @@ async function call(op, body = {}, fetchImpl = fetch) {
|
|
|
21810
22103
|
if (!res.ok) throw Object.assign(new Error(json?.error ?? `Cutroom error (HTTP ${res.status}).`), { status: res.status });
|
|
21811
22104
|
return json;
|
|
21812
22105
|
}
|
|
21813
|
-
var
|
|
21814
|
-
var
|
|
22106
|
+
var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
22107
|
+
var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
21815
22108
|
var fmt = (t) => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, "0")}`;
|
|
21816
22109
|
var pct = (n) => `${Math.round(n * 100)}%`;
|
|
21817
22110
|
async function download(url2, target) {
|
|
@@ -21821,9 +22114,12 @@ async function download(url2, target) {
|
|
|
21821
22114
|
}
|
|
21822
22115
|
async function doctor(opts = {}) {
|
|
21823
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("; ");
|
|
21824
22121
|
const checks = [
|
|
21825
|
-
{ name:
|
|
21826
|
-
{ 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.` },
|
|
21827
22123
|
(() => {
|
|
21828
22124
|
const v = has("npx") ? nodeMajor() : 0;
|
|
21829
22125
|
if (!v) return { name: "Node.js (for rendering)", ok: false, fix: installLine("node") };
|
|
@@ -21851,7 +22147,7 @@ async function doctor(opts = {}) {
|
|
|
21851
22147
|
} catch (e) {
|
|
21852
22148
|
checks.push({ name: "Cutroom membership (signed in with Whop)", ok: false, fix: e.message });
|
|
21853
22149
|
}
|
|
21854
|
-
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 };
|
|
21855
22151
|
}
|
|
21856
22152
|
function approxWords(text, duration3) {
|
|
21857
22153
|
const words = (text ?? "").split(/\s+/).filter(Boolean);
|
|
@@ -21865,13 +22161,9 @@ function beatBounds(duration3, cuts, words) {
|
|
|
21865
22161
|
for (const x of all) if (x - out[out.length - 1] >= MIN_BEAT && duration3 - x >= MIN_BEAT / 2) out.push(x);
|
|
21866
22162
|
return [...out, duration3];
|
|
21867
22163
|
}
|
|
21868
|
-
function
|
|
21869
|
-
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, dir) {
|
|
21870
22165
|
try {
|
|
21871
|
-
const
|
|
21872
|
-
if (r.status !== 0 || !meta2.ok) return null;
|
|
21873
|
-
const raw = JSON.parse(readFileSync3(meta2.transcriptPath ?? join3(dir, "transcript.json"), "utf8"));
|
|
21874
|
-
const words = raw.map((w) => ({ t: Math.round(w.start * 100) / 100, e: Math.round(w.end * 100) / 100, w: w.text.trim() })).filter((w) => w.w);
|
|
22166
|
+
const words = await transcribeVideo(file, dir);
|
|
21875
22167
|
return words.length ? words : null;
|
|
21876
22168
|
} catch {
|
|
21877
22169
|
return null;
|
|
@@ -21891,23 +22183,26 @@ async function breakdownReel(input) {
|
|
|
21891
22183
|
let meta2 = null, transcript = null, file;
|
|
21892
22184
|
const src = input.source.trim();
|
|
21893
22185
|
if (/^https?:\/\//i.test(src)) {
|
|
21894
|
-
const r = await call("reel", { url: src })
|
|
22186
|
+
const r = await call("reel", { url: src }).catch((e) => {
|
|
22187
|
+
throw new Error(`Couldn't fetch that Instagram link right now (${e instanceof Error ? e.message : e}). Save the reel to this computer and give me the file path instead: breakdown_reel works the same on a file.`);
|
|
22188
|
+
});
|
|
21895
22189
|
meta2 = r.meta;
|
|
21896
22190
|
transcript = r.transcript;
|
|
21897
|
-
const dir2 =
|
|
21898
|
-
|
|
21899
|
-
file =
|
|
21900
|
-
if (!
|
|
22191
|
+
const dir2 = join6(studio.references(), `${day2()}-${slug2(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
|
|
22192
|
+
mkdirSync5(dir2, { recursive: true });
|
|
22193
|
+
file = join6(dir2, "source.mp4");
|
|
22194
|
+
if (!existsSync4(file)) await download(meta2.videoUrl, file);
|
|
21901
22195
|
} else {
|
|
21902
|
-
file =
|
|
21903
|
-
if (!
|
|
22196
|
+
file = resolve2(src.replace(/^~/, homedir2()));
|
|
22197
|
+
if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
|
|
21904
22198
|
}
|
|
21905
|
-
const dir = /^https?:/i.test(src) ?
|
|
21906
|
-
|
|
22199
|
+
const dir = /^https?:/i.test(src) ? join6(file, "..") : join6(studio.references(), `${day2()}-${slug2(file.split(/[\\/]/).pop() ?? "video")}`);
|
|
22200
|
+
mkdirSync5(dir, { recursive: true });
|
|
21907
22201
|
const m = measure(file);
|
|
21908
|
-
const { frames, interval } = sampleFrames(file,
|
|
22202
|
+
const { frames, interval } = sampleFrames(file, join6(dir, "frames"), m.duration, 1, 40);
|
|
21909
22203
|
const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
|
|
21910
|
-
const
|
|
22204
|
+
const sheet = contactSheet(frames, join6(dir, "sheet.jpg"));
|
|
22205
|
+
const timed = await timedWords(file, dir);
|
|
21911
22206
|
const words = timed ?? approxWords(transcript, m.duration);
|
|
21912
22207
|
const bs = beats(m, words, match.frames);
|
|
21913
22208
|
if (!transcript && timed) transcript = timed.map((w) => w.w).join(" ");
|
|
@@ -21929,6 +22224,7 @@ async function breakdownReel(input) {
|
|
|
21929
22224
|
hook ? `- first line: "${hook}"` : "- no transcript (silent video or local file)",
|
|
21930
22225
|
"",
|
|
21931
22226
|
"## The look",
|
|
22227
|
+
sheet ? "Every sampled frame, labelled with its time: sheet.jpg. The look below is the closest Cutroom parts; check it against the sheet." : "Sampled frames are in frames/. The look below is the closest Cutroom parts; check it against them.",
|
|
21932
22228
|
"Layout, share of sampled frames:",
|
|
21933
22229
|
...Object.entries(s.layout_share).map(([k, v]) => `- ${k}: ${pct(v)}`),
|
|
21934
22230
|
"Captions:",
|
|
@@ -21945,9 +22241,9 @@ async function breakdownReel(input) {
|
|
|
21945
22241
|
transcript ?? "(none)",
|
|
21946
22242
|
""
|
|
21947
22243
|
].join("\n");
|
|
21948
|
-
|
|
21949
|
-
const out = { folder: dir, report:
|
|
21950
|
-
|
|
22244
|
+
writeFileSync5(join6(dir, "breakdown.md"), md);
|
|
22245
|
+
const out = { folder: dir, report: join6(dir, "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(dir, "breakdown.json"), JSON.stringify(out, null, 2));
|
|
21951
22247
|
return out;
|
|
21952
22248
|
}
|
|
21953
22249
|
function ctaKeyword(caption) {
|
|
@@ -21956,15 +22252,15 @@ function ctaKeyword(caption) {
|
|
|
21956
22252
|
}
|
|
21957
22253
|
async function teardownOne(handle, limit) {
|
|
21958
22254
|
const { reels } = await call("reels", { handle, limit });
|
|
21959
|
-
const dir =
|
|
21960
|
-
|
|
22255
|
+
const dir = join6(studio.references(), `${day2()}-${slug2(handle)}-creator`);
|
|
22256
|
+
mkdirSync5(join6(dir, "raw"), { recursive: true });
|
|
21961
22257
|
const rows = [];
|
|
21962
22258
|
for (const [i, r] of reels.entries()) {
|
|
21963
22259
|
try {
|
|
21964
|
-
const file =
|
|
21965
|
-
if (!
|
|
22260
|
+
const file = join6(dir, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
|
|
22261
|
+
if (!existsSync4(file)) await download(r.videoUrl, file);
|
|
21966
22262
|
const m = measure(file);
|
|
21967
|
-
const { frames, interval } = sampleFrames(file,
|
|
22263
|
+
const { frames, interval } = sampleFrames(file, join6(dir, "frames", r.shortCode), m.duration, 2, 20);
|
|
21968
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 }))]);
|
|
21969
22265
|
const top = (o) => Object.keys(o)[0] ?? null;
|
|
21970
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) });
|
|
@@ -22036,18 +22332,18 @@ async function creatorTeardown(input) {
|
|
|
22036
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)} |`),
|
|
22037
22333
|
""
|
|
22038
22334
|
].join("\n");
|
|
22039
|
-
|
|
22040
|
-
const out = { folder: a.dir, report:
|
|
22041
|
-
|
|
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));
|
|
22042
22338
|
return out;
|
|
22043
22339
|
}
|
|
22044
22340
|
|
|
22045
22341
|
// src/community/local/studio.ts
|
|
22046
22342
|
var studio = {
|
|
22047
|
-
brand: () =>
|
|
22048
|
-
references: () =>
|
|
22049
|
-
inbox: () =>
|
|
22050
|
-
videos: () =>
|
|
22343
|
+
brand: () => layout(HOME()).brand,
|
|
22344
|
+
references: () => layout(HOME()).references,
|
|
22345
|
+
inbox: () => layout(HOME()).inbox,
|
|
22346
|
+
videos: () => layout(HOME()).videos
|
|
22051
22347
|
};
|
|
22052
22348
|
var CLAUDE_MD = `# My Cutroom studio
|
|
22053
22349
|
|
|
@@ -22075,82 +22371,27 @@ Everything for my videos lives in this folder. Open Claude Code here so it can s
|
|
|
22075
22371
|
## My rules
|
|
22076
22372
|
`;
|
|
22077
22373
|
function ensureStudio() {
|
|
22078
|
-
|
|
22079
|
-
const
|
|
22080
|
-
if (!
|
|
22081
|
-
const oldBrand = join4(HOME(), "brand.json"), newBrand = join4(studio.brand(), "brand.json");
|
|
22082
|
-
if (existsSync3(oldBrand) && !existsSync3(newBrand)) renameSync(oldBrand, newBrand);
|
|
22374
|
+
ensureLayout(HOME(), CLAUDE_MD);
|
|
22375
|
+
const oldBrand = join7(HOME(), "brand.json"), newBrand = join7(studio.brand(), "brand.json");
|
|
22376
|
+
if (existsSync5(oldBrand) && !existsSync5(newBrand)) renameSync3(oldBrand, newBrand);
|
|
22083
22377
|
return HOME();
|
|
22084
22378
|
}
|
|
22085
|
-
var
|
|
22086
|
-
|
|
22087
|
-
|
|
22088
|
-
};
|
|
22089
|
-
function takeRaw(source, folder) {
|
|
22090
|
-
const raw = join4(folder, "raw");
|
|
22091
|
-
const file = resolve2(source);
|
|
22092
|
-
if (inside(file, raw)) return { file, how: "already here" };
|
|
22093
|
-
mkdirSync4(raw, { recursive: true });
|
|
22094
|
-
let target = join4(raw, basename(file));
|
|
22095
|
-
for (let n = 2; existsSync3(target); n++) target = join4(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
|
|
22096
|
-
if (inside(file, studio.inbox())) {
|
|
22097
|
-
try {
|
|
22098
|
-
renameSync(file, target);
|
|
22099
|
-
return { file: target, how: "moved from inbox" };
|
|
22100
|
-
} catch {
|
|
22101
|
-
}
|
|
22102
|
-
}
|
|
22103
|
-
copyFileSync(file, target);
|
|
22104
|
-
return { file: target, how: "copied" };
|
|
22105
|
-
}
|
|
22106
|
-
function setStatus(folder, status, plan) {
|
|
22107
|
-
const file = join4(folder, "plan.md");
|
|
22108
|
-
const name = basename(folder);
|
|
22109
|
-
const current = existsSync3(file) ? readFileSync4(file, "utf8") : `# ${name}
|
|
22110
|
-
|
|
22111
|
-
status:
|
|
22112
|
-
`;
|
|
22113
|
-
const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
|
|
22114
|
-
status: ${status}
|
|
22115
|
-
`);
|
|
22116
|
-
writeFileSync3(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
|
|
22117
|
-
|
|
22118
|
-
## Plan
|
|
22119
|
-
${plan}
|
|
22120
|
-
`);
|
|
22121
|
-
}
|
|
22122
|
-
function startVideoNotes(folder, transcript) {
|
|
22123
|
-
const script = join4(folder, "script.md"), facts = join4(folder, "facts.md"), notes = join4(folder, "references", "notes.md");
|
|
22124
|
-
mkdirSync4(join4(folder, "references"), { recursive: true });
|
|
22125
|
-
if (!existsSync3(notes)) writeFileSync3(notes, "# References for this video\n\nWhat this video copies and why it works, one line each.\n\n");
|
|
22126
|
-
if (!existsSync3(script)) writeFileSync3(script, `# Script
|
|
22127
|
-
|
|
22128
|
-
What I say, word for word (from the footage; fix any misheard words here).
|
|
22129
|
-
|
|
22130
|
-
${transcript}
|
|
22131
|
-
`);
|
|
22132
|
-
if (!existsSync3(facts)) writeFileSync3(facts, "# Facts\n\nEvery number or claim that shows on screen, with where it comes from.\n\n| on screen | source |\n| - | - |\n");
|
|
22133
|
-
}
|
|
22134
|
-
function addCheck(folder, entry) {
|
|
22135
|
-
const file = join4(folder, "checks.md");
|
|
22136
|
-
if (!existsSync3(file)) writeFileSync3(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
|
|
22137
|
-
appendFileSync(file, `
|
|
22138
|
-
${entry.trim()}
|
|
22139
|
-
`);
|
|
22140
|
-
}
|
|
22379
|
+
var takeRaw2 = (source, folder) => takeRaw(source, folder, studio.inbox());
|
|
22380
|
+
var startVideoNotes2 = (folder, transcript) => startVideoNotes(folder, transcript, "I say");
|
|
22381
|
+
var { setStatus: setStatus2, addCheck: addCheck2 } = studio_core_exports;
|
|
22141
22382
|
|
|
22142
22383
|
// src/community/local/render.ts
|
|
22143
|
-
var
|
|
22144
|
-
var
|
|
22145
|
-
var
|
|
22384
|
+
var HF2 = "hyperframes@0.8.99";
|
|
22385
|
+
var day3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
22386
|
+
var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
|
|
22146
22387
|
var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
22147
22388
|
var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
|
|
22148
|
-
var projectFile = (folder) =>
|
|
22389
|
+
var projectFile = (folder) => join8(folder, ".cutroom", "project.json");
|
|
22149
22390
|
var resolveProject = (p) => {
|
|
22150
|
-
const folder = resolve3(p.replace(/^~/,
|
|
22151
|
-
const file = [folder,
|
|
22391
|
+
const folder = resolve3(p.replace(/^~/, homedir3()));
|
|
22392
|
+
const file = [folder, join8(studio.videos(), p), join8(HOME(), "renders", p)].map(projectFile).find((f) => existsSync6(f)) ?? null;
|
|
22152
22393
|
if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
|
|
22153
|
-
return JSON.parse(
|
|
22394
|
+
return JSON.parse(readFileSync7(file, "utf8"));
|
|
22154
22395
|
};
|
|
22155
22396
|
function cutAndNormalize(src, out, segments) {
|
|
22156
22397
|
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("");
|
|
@@ -22165,40 +22406,35 @@ function looksLikeSpeech(words, duration3) {
|
|
|
22165
22406
|
return readable.length >= Math.max(3, duration3 * 0.6) && readable.length >= words.length * 0.8;
|
|
22166
22407
|
}
|
|
22167
22408
|
async function prepareFootage(input) {
|
|
22168
|
-
const source = resolve3(input.video.replace(/^~/,
|
|
22169
|
-
if (!
|
|
22409
|
+
const source = resolve3(input.video.replace(/^~/, homedir3()));
|
|
22410
|
+
if (!existsSync6(source)) throw new Error(`No such file: ${source}`);
|
|
22170
22411
|
const p = probe(source);
|
|
22171
22412
|
if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
|
|
22172
22413
|
if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
|
|
22173
|
-
const name =
|
|
22414
|
+
const name = slug3(input.name ?? basename2(source, extname(source)));
|
|
22174
22415
|
ensureStudio();
|
|
22175
|
-
const folder =
|
|
22176
|
-
const work =
|
|
22177
|
-
|
|
22178
|
-
const clip =
|
|
22416
|
+
const folder = join8(studio.videos(), `${day3()}-${name}`);
|
|
22417
|
+
const work = join8(folder, ".cutroom");
|
|
22418
|
+
mkdirSync6(work, { recursive: true });
|
|
22419
|
+
const clip = takeRaw2(source, folder);
|
|
22179
22420
|
const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
|
|
22180
22421
|
if (!segments.length) throw new Error("Couldn't find any speech in that video.");
|
|
22181
|
-
const footage =
|
|
22422
|
+
const footage = join8(work, "footage.mp4");
|
|
22182
22423
|
cutAndNormalize(clip.file, footage, segments);
|
|
22183
22424
|
const duration3 = probe(footage).duration;
|
|
22184
|
-
const lang =
|
|
22185
|
-
|
|
22186
|
-
|
|
22187
|
-
|
|
22188
|
-
|
|
22189
|
-
|
|
22190
|
-
|
|
22191
|
-
|
|
22192
|
-
|
|
22193
|
-
if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
|
|
22194
|
-
${meta2.error ?? tail(t.stderr)}`);
|
|
22195
|
-
const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(work, "transcript.json"), "utf8"));
|
|
22196
|
-
const words = raw.map((w) => ({ t: Math.round(w.start * 100) / 100, e: Math.round(w.end * 100) / 100, w: w.text.trim() })).filter((w) => w.w);
|
|
22197
|
-
if (!looksLikeSpeech(words, duration3)) throw new Error(lang === "en" ? "I couldn't hear anyone talking in this video (it sounds like music or silence). Cutroom finishing needs you talking to camera. If you speak another language, tell me which one and I'll try again." : `I couldn't make out the speech in this video, even in that language. Check the sound is you talking to camera.`);
|
|
22425
|
+
const lang = input.language ? input.language.toLowerCase().slice(0, 5) : void 0;
|
|
22426
|
+
let words;
|
|
22427
|
+
try {
|
|
22428
|
+
words = await transcribeVideo(footage, work, lang);
|
|
22429
|
+
} catch (e) {
|
|
22430
|
+
throw new Error(`Transcription failed. Run cutroom_doctor.
|
|
22431
|
+
${e.message}`);
|
|
22432
|
+
}
|
|
22433
|
+
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.`);
|
|
22198
22434
|
const project = { name, folder, source: clip.file, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
|
|
22199
|
-
|
|
22200
|
-
|
|
22201
|
-
|
|
22435
|
+
writeFileSync6(projectFile(folder), JSON.stringify(project, null, 2));
|
|
22436
|
+
startVideoNotes2(folder, words.map((w) => w.w).join(" "));
|
|
22437
|
+
setStatus2(folder, "footage ready");
|
|
22202
22438
|
const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
|
|
22203
22439
|
return {
|
|
22204
22440
|
project: folder,
|
|
@@ -22211,15 +22447,15 @@ ${meta2.error ?? tail(t.stderr)}`);
|
|
|
22211
22447
|
};
|
|
22212
22448
|
}
|
|
22213
22449
|
async function ensureFonts(work) {
|
|
22214
|
-
const cache =
|
|
22450
|
+
const cache = join8(HOME(), ".kit", "fonts");
|
|
22215
22451
|
const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
|
|
22216
|
-
if (!need.every((f) =>
|
|
22452
|
+
if (!need.every((f) => existsSync6(join8(cache, f)))) {
|
|
22217
22453
|
const { fonts } = await call("fonts");
|
|
22218
|
-
|
|
22219
|
-
for (const [f, b64] of Object.entries(fonts))
|
|
22454
|
+
mkdirSync6(cache, { recursive: true });
|
|
22455
|
+
for (const [f, b64] of Object.entries(fonts)) writeFileSync6(join8(cache, basename2(f)), Buffer.from(b64, "base64"));
|
|
22220
22456
|
}
|
|
22221
|
-
|
|
22222
|
-
for (const f of need)
|
|
22457
|
+
mkdirSync6(join8(work, "fonts"), { recursive: true });
|
|
22458
|
+
for (const f of need) writeFileSync6(join8(work, "fonts", f), readFileSync7(join8(cache, f)));
|
|
22223
22459
|
}
|
|
22224
22460
|
function applyFixes(words, fixes = []) {
|
|
22225
22461
|
return words.map((w) => {
|
|
@@ -22227,11 +22463,11 @@ function applyFixes(words, fixes = []) {
|
|
|
22227
22463
|
return f ? { ...w, w: f.to } : w;
|
|
22228
22464
|
}).filter((w) => w.w);
|
|
22229
22465
|
}
|
|
22230
|
-
var brandFile = () =>
|
|
22466
|
+
var brandFile = () => join8(studio.brand(), "brand.json");
|
|
22231
22467
|
function getBrand() {
|
|
22232
|
-
for (const f of [brandFile(),
|
|
22468
|
+
for (const f of [brandFile(), join8(HOME(), "brand.json")]) {
|
|
22233
22469
|
try {
|
|
22234
|
-
return JSON.parse(
|
|
22470
|
+
return JSON.parse(readFileSync7(f, "utf8"));
|
|
22235
22471
|
} catch {
|
|
22236
22472
|
}
|
|
22237
22473
|
}
|
|
@@ -22239,14 +22475,14 @@ function getBrand() {
|
|
|
22239
22475
|
}
|
|
22240
22476
|
async function setBrand(input) {
|
|
22241
22477
|
if (input.reset) {
|
|
22242
|
-
|
|
22478
|
+
rmSync5(brandFile(), { force: true });
|
|
22243
22479
|
return { brand: null, saved: "Back to the default look." };
|
|
22244
22480
|
}
|
|
22245
22481
|
const { reset, ...fields } = input;
|
|
22246
22482
|
const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
|
|
22247
22483
|
await call("compose", { duration: 2, words: [], brand: next, check_only: true });
|
|
22248
22484
|
ensureStudio();
|
|
22249
|
-
|
|
22485
|
+
writeFileSync6(brandFile(), JSON.stringify(next, null, 2));
|
|
22250
22486
|
return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
|
|
22251
22487
|
}
|
|
22252
22488
|
var PICTURE = /\.(jpe?g|png|webp)$/i;
|
|
@@ -22254,21 +22490,21 @@ function safeAssetName(file, i) {
|
|
|
22254
22490
|
const m = basename2(file).match(PICTURE);
|
|
22255
22491
|
if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
|
|
22256
22492
|
const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
|
|
22257
|
-
return `${i + 1}-${
|
|
22493
|
+
return `${i + 1}-${slug3(basename2(file, extname(file)))}.${ext}`;
|
|
22258
22494
|
}
|
|
22259
22495
|
function localImages(work, layoutImages, effects) {
|
|
22260
22496
|
const names = /* @__PURE__ */ new Map();
|
|
22261
|
-
const dir =
|
|
22497
|
+
const dir = join8(work, "assets");
|
|
22262
22498
|
const swap = (v) => {
|
|
22263
22499
|
const raw = String(v ?? "").trim();
|
|
22264
|
-
if (/^assets\/[\w.-]+$/.test(raw) &&
|
|
22265
|
-
const file = resolve3(raw.replace(/^~(?=$|\/)/,
|
|
22500
|
+
if (/^assets\/[\w.-]+$/.test(raw) && existsSync6(join8(work, raw))) return raw;
|
|
22501
|
+
const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir3()));
|
|
22266
22502
|
if (!names.has(file)) {
|
|
22267
|
-
if (!
|
|
22503
|
+
if (!existsSync6(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.`);
|
|
22268
22504
|
if (names.size >= 8) throw new Error("8 pictures at most per reel.");
|
|
22269
22505
|
const name = safeAssetName(file, names.size);
|
|
22270
|
-
|
|
22271
|
-
copyFileSync2(file,
|
|
22506
|
+
mkdirSync6(dir, { recursive: true });
|
|
22507
|
+
copyFileSync2(file, join8(dir, name));
|
|
22272
22508
|
names.set(file, `assets/${name}`);
|
|
22273
22509
|
}
|
|
22274
22510
|
return names.get(file);
|
|
@@ -22281,10 +22517,10 @@ function localImages(work, layoutImages, effects) {
|
|
|
22281
22517
|
}));
|
|
22282
22518
|
return { images, effects: fx, count: names.size };
|
|
22283
22519
|
}
|
|
22284
|
-
async function wantsCutout(
|
|
22520
|
+
async function wantsCutout(layout2, effects) {
|
|
22285
22521
|
const styles = await call("styles");
|
|
22286
22522
|
const slugs = new Set(effects.map((f) => String(f.slug)));
|
|
22287
|
-
return [...styles.layouts.filter((l) => l.slug === (
|
|
22523
|
+
return [...styles.layouts.filter((l) => l.slug === (layout2 ?? "full-frame")), ...styles.effects.filter((e) => slugs.has(e.slug))].some((x) => x.needs?.includes("cutout"));
|
|
22288
22524
|
}
|
|
22289
22525
|
function headTop(work, duration3) {
|
|
22290
22526
|
const tops = [];
|
|
@@ -22305,40 +22541,40 @@ function headTop(work, duration3) {
|
|
|
22305
22541
|
return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
|
|
22306
22542
|
}
|
|
22307
22543
|
function ensureCutout(work, duration3) {
|
|
22308
|
-
const footage =
|
|
22544
|
+
const footage = join8(work, "footage.mp4"), cut = join8(work, "cut.webm"), stamp = join8(work, "cut.json"), still = join8(work, "assets", "still.jpg");
|
|
22309
22545
|
const st = statSync(footage);
|
|
22310
22546
|
const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
22311
22547
|
let cached2 = null;
|
|
22312
22548
|
try {
|
|
22313
|
-
cached2 = JSON.parse(
|
|
22549
|
+
cached2 = JSON.parse(readFileSync7(stamp, "utf8"));
|
|
22314
22550
|
} catch {
|
|
22315
22551
|
}
|
|
22316
22552
|
let made_s = 0;
|
|
22317
|
-
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime &&
|
|
22553
|
+
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync6(cut))) {
|
|
22318
22554
|
const started = Date.now();
|
|
22319
|
-
const r = sh("npx", ["--yes",
|
|
22320
|
-
if (r.status !== 0 || !
|
|
22555
|
+
const r = sh("npx", ["--yes", HF2, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
|
|
22556
|
+
if (r.status !== 0 || !existsSync6(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
|
|
22321
22557
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22322
22558
|
made_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22323
22559
|
cached2 = { ...key, head: headTop(work, duration3), made_s };
|
|
22324
|
-
|
|
22560
|
+
writeFileSync6(stamp, JSON.stringify(cached2));
|
|
22325
22561
|
}
|
|
22326
|
-
if (!
|
|
22327
|
-
|
|
22562
|
+
if (!existsSync6(still)) {
|
|
22563
|
+
mkdirSync6(join8(work, "assets"), { recursive: true });
|
|
22328
22564
|
sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
|
|
22329
22565
|
}
|
|
22330
22566
|
return { head: cached2.head, made_s };
|
|
22331
22567
|
}
|
|
22332
|
-
var planFile = (folder) =>
|
|
22568
|
+
var planFile = (folder) => join8(folder, ".cutroom", "plan.json");
|
|
22333
22569
|
async function composition(input) {
|
|
22334
22570
|
const pj = resolveProject(input.project);
|
|
22335
|
-
const work =
|
|
22571
|
+
const work = join8(pj.folder, ".cutroom");
|
|
22336
22572
|
const words = applyFixes(pj.words, input.fixes);
|
|
22337
|
-
const
|
|
22338
|
-
const cutout = await wantsCutout(input.layout,
|
|
22339
|
-
const { html } = await call("compose", { duration: pj.duration, words, layout: input.layout, caption: input.caption, effects:
|
|
22573
|
+
const local2 = localImages(work, input.images, input.effects ?? []);
|
|
22574
|
+
const cutout = await wantsCutout(input.layout, local2.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22575
|
+
const { html } = await call("compose", { duration: pj.duration, words, layout: input.layout, caption: input.caption, effects: local2.effects, images: local2.images, emphasis: input.emphasis, brand: getBrand() ?? void 0, ...cutout ? { cutout: true, head: cutout.head } : {} });
|
|
22340
22576
|
await ensureFonts(work);
|
|
22341
|
-
|
|
22577
|
+
writeFileSync6(join8(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
22342
22578
|
return { pj, work, html, cutout, words };
|
|
22343
22579
|
}
|
|
22344
22580
|
var fmtT = (t) => `${t.toFixed(1)}s`;
|
|
@@ -22354,7 +22590,7 @@ async function planReel(input) {
|
|
|
22354
22590
|
};
|
|
22355
22591
|
const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
|
|
22356
22592
|
const styles = await call("styles");
|
|
22357
|
-
const title = (list,
|
|
22593
|
+
const title = (list, slug4) => (list.find((x) => x.slug === slug4)?.title ?? String(slug4)).replace(/\s*\(.*\)$/, "");
|
|
22358
22594
|
const sentences = words.reduce((out, w, k) => {
|
|
22359
22595
|
if (!out.length || /[.!?]["”']?$/.test(words[k - 1]?.w ?? "")) out.push({ t: w.t, text: [] });
|
|
22360
22596
|
out[out.length - 1].text.push(w.w);
|
|
@@ -22388,31 +22624,31 @@ async function planReel(input) {
|
|
|
22388
22624
|
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
|
|
22389
22625
|
})
|
|
22390
22626
|
];
|
|
22391
|
-
const shots =
|
|
22392
|
-
|
|
22393
|
-
|
|
22627
|
+
const shots = join8(work, "storyboard");
|
|
22628
|
+
rmSync5(shots, { recursive: true, force: true });
|
|
22629
|
+
writeFileSync6(join8(work, "index.html"), html);
|
|
22394
22630
|
let r;
|
|
22395
22631
|
try {
|
|
22396
|
-
r = sh("npx", ["--yes",
|
|
22632
|
+
r = sh("npx", ["--yes", HF2, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
|
|
22397
22633
|
} finally {
|
|
22398
|
-
|
|
22634
|
+
rmSync5(join8(work, "index.html"), { force: true });
|
|
22399
22635
|
}
|
|
22400
|
-
const pngs =
|
|
22636
|
+
const pngs = existsSync6(shots) ? readdirSync2(shots).filter((f) => f.endsWith(".png")).sort() : [];
|
|
22401
22637
|
if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
|
|
22402
22638
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22403
|
-
const board =
|
|
22639
|
+
const board = join8(pj.folder, "storyboard.jpg");
|
|
22404
22640
|
const cols = Math.min(4, moments.length);
|
|
22405
22641
|
const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
|
|
22406
|
-
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i",
|
|
22642
|
+
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join8(shots, f)]);
|
|
22407
22643
|
const cells = moments.map((m, k) => `[${k}:v]scale=270:480,pad=270:540:0:0:#0A0A0A,drawtext=text='${label(`${fmtT(m.t)} ${m.tag}`)}':x=10:y=496:fontsize=18:fontcolor=#F7F3E8[c${k}]`).join(";");
|
|
22408
22644
|
const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
|
|
22409
|
-
const
|
|
22410
|
-
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${
|
|
22645
|
+
const stack2 = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
|
|
22646
|
+
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack2}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
|
|
22411
22647
|
if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
|
|
22412
22648
|
${tail(t.stderr)}`);
|
|
22413
|
-
|
|
22649
|
+
rmSync5(shots, { recursive: true, force: true });
|
|
22414
22650
|
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
|
|
22415
|
-
|
|
22651
|
+
writeFileSync6(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22416
22652
|
const text = [
|
|
22417
22653
|
`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)." : ""}`,
|
|
22418
22654
|
"",
|
|
@@ -22422,41 +22658,41 @@ ${tail(t.stderr)}`);
|
|
|
22422
22658
|
"",
|
|
22423
22659
|
`Storyboard: ${board}`
|
|
22424
22660
|
].join("\n");
|
|
22425
|
-
|
|
22661
|
+
setStatus2(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
|
|
22426
22662
|
return { text, storyboard: board, plan, words_used: words.length, next: "Show this table as it is (plain words, no effect codes) and the storyboard. On changes, plan_reel again with the new choice. On a go, render_reel with plan:true and draft:true." };
|
|
22427
22663
|
}
|
|
22428
22664
|
async function renderReel(input) {
|
|
22429
22665
|
if (input.plan) {
|
|
22430
22666
|
const folder = resolveProject(input.project).folder;
|
|
22431
|
-
if (!
|
|
22432
|
-
input = { ...JSON.parse(
|
|
22667
|
+
if (!existsSync6(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
|
|
22668
|
+
input = { ...JSON.parse(readFileSync7(planFile(folder), "utf8")), project: input.project, draft: input.draft };
|
|
22433
22669
|
}
|
|
22434
22670
|
const { pj, work, html, cutout } = await composition(input);
|
|
22435
|
-
|
|
22436
|
-
const out =
|
|
22437
|
-
const tmp =
|
|
22438
|
-
|
|
22671
|
+
mkdirSync6(join8(pj.folder, "renders"), { recursive: true });
|
|
22672
|
+
const out = join8(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
|
|
22673
|
+
const tmp = join8(work, "render.mp4");
|
|
22674
|
+
writeFileSync6(join8(work, "index.html"), html);
|
|
22439
22675
|
const started = Date.now();
|
|
22440
22676
|
let r;
|
|
22441
22677
|
try {
|
|
22442
|
-
r = sh("npx", ["--yes",
|
|
22678
|
+
r = sh("npx", ["--yes", HF2, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
|
|
22443
22679
|
} finally {
|
|
22444
|
-
|
|
22680
|
+
rmSync5(join8(work, "index.html"), { force: true });
|
|
22445
22681
|
}
|
|
22446
|
-
if (r.status !== 0 || !
|
|
22682
|
+
if (r.status !== 0 || !existsSync6(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
|
|
22447
22683
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22448
|
-
|
|
22684
|
+
renameSync4(tmp, out);
|
|
22449
22685
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22450
22686
|
const { report: checks, ...qa } = await qaVideo({ video: out, layout: input.layout });
|
|
22451
|
-
|
|
22452
|
-
|
|
22687
|
+
addCheck2(pj.folder, checks);
|
|
22688
|
+
setStatus2(pj.folder, input.draft ? "preview" : "final");
|
|
22453
22689
|
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." };
|
|
22454
22690
|
}
|
|
22455
22691
|
async function qaVideo(input) {
|
|
22456
|
-
const file = resolve3(input.video.replace(/^~/,
|
|
22457
|
-
if (!
|
|
22692
|
+
const file = resolve3(input.video.replace(/^~/, homedir3()));
|
|
22693
|
+
if (!existsSync6(file)) throw new Error(`No such file: ${file}`);
|
|
22458
22694
|
const tech = technicalChecks(file);
|
|
22459
|
-
const dir = mkdtempSync(
|
|
22695
|
+
const dir = mkdtempSync(join8(tmpdir(), "cutroom-qa-"));
|
|
22460
22696
|
const { frames } = sampleFrames(file, dir, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
|
|
22461
22697
|
let flags = [];
|
|
22462
22698
|
let judged = 0, judge_error = null;
|
|
@@ -22467,7 +22703,7 @@ async function qaVideo(input) {
|
|
|
22467
22703
|
} catch (e) {
|
|
22468
22704
|
judge_error = e.message;
|
|
22469
22705
|
}
|
|
22470
|
-
|
|
22706
|
+
rmSync5(dir, { recursive: true, force: true });
|
|
22471
22707
|
const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
|
|
22472
22708
|
const passed = !tech.issues.length && !flags.length && !judge_error;
|
|
22473
22709
|
const report = {
|
|
@@ -22492,18 +22728,897 @@ async function qaVideo(input) {
|
|
|
22492
22728
|
return { ...report, report: md };
|
|
22493
22729
|
}
|
|
22494
22730
|
|
|
22731
|
+
// src/community/methods.ts
|
|
22732
|
+
var MEMBER_CAROUSEL = false;
|
|
22733
|
+
var METHODS = {
|
|
22734
|
+
hooks: {
|
|
22735
|
+
title: "Video hooks",
|
|
22736
|
+
text: `Write hooks for a short video (Reel, Short, TikTok) that will be spoken to camera.
|
|
22737
|
+
|
|
22738
|
+
1. Problem in one line: the specific problem and who feels it. If the topic is broad, narrow it with the user first. One problem per run.
|
|
22739
|
+
2. Write 10 to 15 hooks across these angles (1 to 2 each, pick the 5 to 10 that fit):
|
|
22740
|
+
shocking number, contrarian belief, personal confession, result transformation (before to after, timed), industry death (old way ending), secret system (tease the mechanism), time compression, cost of inaction, insider leak, story already in motion.
|
|
22741
|
+
Every hook carries at least one of: a specific number, a contrarian claim, a timeframe, a personal admission, a curiosity gap.
|
|
22742
|
+
3. Rules: definitive statements (no maybe, might, could). One breath when spoken. Strongest word early. Contractions. No em dashes. No clich\xE9s (game-changer, unlock, level up). Any number must be one the user can prove and must belong to them, not a client, unless they say so.
|
|
22743
|
+
4. Send all hooks to the score_hooks tool. Keep the top 3 by its ranking.
|
|
22744
|
+
5. Read each of the top 3 aloud in your head; rewrite to natural speech.
|
|
22745
|
+
6. Pick the winner, one line on why, and write its second sentence: it deepens the question, moves toward the answer, no self-introduction, no repetition.
|
|
22746
|
+
|
|
22747
|
+
Output: top 3 with their angle, the winner, the second sentence, and any number the user must verify before filming.`
|
|
22748
|
+
},
|
|
22749
|
+
script: {
|
|
22750
|
+
title: "Short script (8 lines)",
|
|
22751
|
+
text: `Turn one idea into a 70 to 100 word short script.
|
|
22752
|
+
|
|
22753
|
+
1. Thesis: "This video will prove that ___." One video, one idea.
|
|
22754
|
+
2. Payoff first: "By the end, viewers understand ___." This is the ending; write it before any hook.
|
|
22755
|
+
3. Story spine in 5 sentences: before, problem, cost, discovery, after. Prefer a real story with a number the user can prove.
|
|
22756
|
+
4. Three hooks (problem, contradiction, curiosity). Choose the clearest, not the cleverest. If the user brings a hook, use it.
|
|
22757
|
+
5. The 8 lines: 1 hook (tension), 2 second hook (deepen the question), 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.
|
|
22758
|
+
6. Question test after every line: what question does it create? No question and no useful answer: delete it.
|
|
22759
|
+
7. Cut pass: remove repeats, long intros, generic claims, extra CTAs. Aloud pass: most sentences under 12 words, contractions, 70 to 100 words.
|
|
22760
|
+
8. Send the script to the score_script tool. Below its pass mark, revise the weakest two lines once and score again.
|
|
22761
|
+
|
|
22762
|
+
Rules: definitive statements, no em dashes, name the outcome not the tool, one CTA at most, every number provable and correctly attributed.
|
|
22763
|
+
|
|
22764
|
+
Output: thesis, payoff, spine, chosen hook, the numbered script with word count, the score, and numbers to verify before filming.`
|
|
22765
|
+
},
|
|
22766
|
+
caption: {
|
|
22767
|
+
title: "Post caption and comment CTA",
|
|
22768
|
+
text: `Write the post caption for a short video.
|
|
22769
|
+
|
|
22770
|
+
1. First line: the hook in under 120 characters, lowercase is fine if that is the user's style.
|
|
22771
|
+
2. Two or three short lines on what the viewer gets. No list of hashtags in the body.
|
|
22772
|
+
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.
|
|
22773
|
+
4. Rules: no em dashes, no placeholder names, nothing the video does not support.
|
|
22774
|
+
|
|
22775
|
+
Output: the caption ready to paste, and the keyword with what gets sent.`
|
|
22776
|
+
},
|
|
22777
|
+
finish_video: {
|
|
22778
|
+
title: "Finishing a raw video",
|
|
22779
|
+
text: `The user gave you a raw video of them talking. Steps:
|
|
22780
|
+
|
|
22781
|
+
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.
|
|
22782
|
+
2. From the timed transcript, list words that look misheard (names, brands, numbers) and confirm the spelling. Pass corrections as fixes later.
|
|
22783
|
+
3. 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.
|
|
22784
|
+
4. Plan: list_styles (share the gallery link so they can see each style moving), then get_method('edit_plan') and follow it. Show the plan as the plain table it asks for.
|
|
22785
|
+
5. 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.
|
|
22786
|
+
- 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.
|
|
22787
|
+
- 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.
|
|
22788
|
+
6. On a go: render_reel with plan:true and draft:true. Tell them where the file is.
|
|
22789
|
+
7. 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.
|
|
22790
|
+
8. They approve the draft: render_reel with plan:true (no draft) for the final. After changes: plan_reel, then render again.
|
|
22791
|
+
9. Offer the post caption next (get_method('caption')).`
|
|
22792
|
+
},
|
|
22793
|
+
edit_plan: {
|
|
22794
|
+
title: "Planning the edit scene by scene",
|
|
22795
|
+
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.
|
|
22796
|
+
|
|
22797
|
+
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.
|
|
22798
|
+
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.
|
|
22799
|
+
3. Pick the visual from what the line does, using only effects in the menu:
|
|
22800
|
+
- a number: Number pop; money or revenue: Money count; a percentage or rate: Progress ring
|
|
22801
|
+
- asking an AI, a prompt, "I asked ChatGPT/Claude": AI chat (their prompt, its answer)
|
|
22802
|
+
- a conversation, a DM, "my friend texted me": Text thread
|
|
22803
|
+
- results coming in, sales, views, sign-ups landing: Notification stack
|
|
22804
|
+
- a decision ("should you...?") or a process: Flowchart; a simple step-by-step system: Hand-drawn pipeline
|
|
22805
|
+
- quoting a post or comment: Post card; a real client quote: Testimonial (only quotes they give you)
|
|
22806
|
+
- a comparison, A vs B, old vs new: Variant cards or Before / after
|
|
22807
|
+
- steps ("one, two, three"): each step its own visual, or a Checklist that ticks as they are said
|
|
22808
|
+
- an app, tool, dashboard or result: Phone mockup with their screenshot (ask for it), else UI world
|
|
22809
|
+
- the key line or a bold claim: Glass type, Text behind you, or Frame rip
|
|
22810
|
+
- "part 3", a title, a topic: Cover story or Paper cut
|
|
22811
|
+
- many examples, creators, posts: Card wall or Orbit (their pictures if they have them)
|
|
22812
|
+
- pointing at something on screen: Freeze and label or Doodle
|
|
22813
|
+
- the brand or style: Brand kit card
|
|
22814
|
+
- the call to action: Comment to DM (their keyword), Follow tap, or CTA card (their offer and button), as the last scene
|
|
22815
|
+
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).
|
|
22816
|
+
4. Do not repeat the same effect in two scenes in a row.
|
|
22817
|
+
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.
|
|
22818
|
+
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.
|
|
22819
|
+
7. Show the plan as a table, plain words only, never effect codes:
|
|
22820
|
+
| Time | What you say | What shows on screen |
|
|
22821
|
+
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.").
|
|
22822
|
+
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).
|
|
22823
|
+
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.`
|
|
22824
|
+
},
|
|
22825
|
+
breakdown_writeup: {
|
|
22826
|
+
title: "Writing up a reel breakdown",
|
|
22827
|
+
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:
|
|
22828
|
+
- The hook in one line and what the first frame does without sound.
|
|
22829
|
+
- The structure as 3 to 6 beats: what happens and why it keeps people watching.
|
|
22830
|
+
- The look in plain words, using the layout, caption and effect names in the report, with timestamps.
|
|
22831
|
+
- "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.
|
|
22832
|
+
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".`
|
|
22833
|
+
},
|
|
22834
|
+
copy_reel: {
|
|
22835
|
+
title: "Copying a reel's format onto your own topic",
|
|
22836
|
+
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.
|
|
22837
|
+
|
|
22838
|
+
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.
|
|
22839
|
+
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.
|
|
22840
|
+
3. Write the format card to <studio>/references/formats/<short-name>.md (lowercase, dashes). Keep what you saw separate from what you guess. Sections:
|
|
22841
|
+
- Source: link, creator, stats at capture, date.
|
|
22842
|
+
- Why it works: one line.
|
|
22843
|
+
- Hook: its angle (one of the hooks method's angles), how long it runs, what the first frame does with the sound off.
|
|
22844
|
+
- 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.
|
|
22845
|
+
- Pace: words per second (words / seconds talking), cuts per minute, how often the picture changes.
|
|
22846
|
+
- 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.
|
|
22847
|
+
- Ending: how it closes and the CTA pattern (keyword comment, follow, link).
|
|
22848
|
+
- Slots: what changes per topic (hook, each beat's content, proof, CTA keyword).
|
|
22849
|
+
Tell the user where the card is: they can reuse it on any topic without another breakdown.
|
|
22850
|
+
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.
|
|
22851
|
+
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.
|
|
22852
|
+
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.
|
|
22853
|
+
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.
|
|
22854
|
+
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.
|
|
22855
|
+
9. On a go:
|
|
22856
|
+
- 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.
|
|
22857
|
+
- 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.
|
|
22858
|
+
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.
|
|
22859
|
+
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.
|
|
22860
|
+
|
|
22861
|
+
Say plainly that this copies a format, not a video, and that results depend on their topic and delivery.`
|
|
22862
|
+
},
|
|
22863
|
+
copy_carousel: {
|
|
22864
|
+
internal: !MEMBER_CAROUSEL,
|
|
22865
|
+
title: "Copying a carousel's format onto your own topic",
|
|
22866
|
+
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.
|
|
22867
|
+
|
|
22868
|
+
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.
|
|
22869
|
+
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).
|
|
22870
|
+
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.
|
|
22871
|
+
4. Write two files in <studio>/references/formats/:
|
|
22872
|
+
- 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.
|
|
22873
|
+
- 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).
|
|
22874
|
+
Tell them where the card is: it works on any topic without another breakdown.
|
|
22875
|
+
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.
|
|
22876
|
+
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.
|
|
22877
|
+
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.
|
|
22878
|
+
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.
|
|
22879
|
+
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.
|
|
22880
|
+
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.
|
|
22881
|
+
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.
|
|
22882
|
+
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.
|
|
22883
|
+
|
|
22884
|
+
Deck format (render_carousel deck):
|
|
22885
|
+
{ "title": "short name", "style": "<preset name> | <path to style.json> | { inline style } | { "path": "...", "overrides": {...} }", "brand": "mine" (optional), "handle": "@them", "slides": [ slide, ... ] }
|
|
22886
|
+
A slide: { "layout": one of the 8, plus the fields it uses:
|
|
22887
|
+
text-hook: kicker?, headline, body?, image? or art?
|
|
22888
|
+
image-hook / image-caption: headline, body?, kicker?, image (position "full" for a full-bleed photo with text over it, "top", "bottom", "framed") or art
|
|
22889
|
+
list-item: number ("01", "3."), headline, body?, image? or art?
|
|
22890
|
+
quote: quote, attribution (only real quotes they give you)
|
|
22891
|
+
before-after: headline?, before { label, text, image? or art? }, after { same }
|
|
22892
|
+
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
|
|
22893
|
+
cta: headline, keyword?, ask?, body?, image? or art? (a small picture under the text)
|
|
22894
|
+
text-hook, image-*, screenshot-label also take sub (a second, smaller headline line)
|
|
22895
|
+
any slide: bg, color, accent (hex), scale (text size, 1 = normal), align (left, center), valign (top, center, bottom), treatment (plain, outline, highlight-box, shadow) }
|
|
22896
|
+
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" }
|
|
22897
|
+
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>" }
|
|
22898
|
+
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 } }
|
|
22899
|
+
|
|
22900
|
+
Say plainly that this copies a format, not a post, and that results depend on their topic. Nothing gets posted.`
|
|
22901
|
+
},
|
|
22902
|
+
teardown_writeup: {
|
|
22903
|
+
title: "Writing up a creator teardown",
|
|
22904
|
+
text: `You have a teardown report. Write it up for the user:
|
|
22905
|
+
- Their system in 4 lines: format, pacing, captions, CTA keyword.
|
|
22906
|
+
- What their best-performing quarter does differently, using only the report's numbers.
|
|
22907
|
+
- 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.
|
|
22908
|
+
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.`
|
|
22909
|
+
}
|
|
22910
|
+
};
|
|
22911
|
+
var MEMBER_METHODS = Object.fromEntries(Object.entries(METHODS).filter(([, m]) => !m.internal));
|
|
22912
|
+
|
|
22913
|
+
// src/community/local/prompts.ts
|
|
22914
|
+
var say = (label, value) => value?.trim() ? ` ${label}: ${value.trim()}.` : "";
|
|
22915
|
+
var PROMPTS = [
|
|
22916
|
+
{
|
|
22917
|
+
name: "copy-reel",
|
|
22918
|
+
title: "Copy a reel's format",
|
|
22919
|
+
description: "Your version of a reel: its structure, pacing and look, with your topic and words.",
|
|
22920
|
+
args: [{ name: "link", description: "The reel (Instagram link or a video file path)" }, { name: "topic", description: "Your topic (optional)" }],
|
|
22921
|
+
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."}`
|
|
22922
|
+
},
|
|
22923
|
+
{
|
|
22924
|
+
name: "copy-carousel",
|
|
22925
|
+
title: "Copy a carousel",
|
|
22926
|
+
description: "Your version of an Instagram or TikTok carousel: its structure and look (or your brand), your topic and words, as new slides.",
|
|
22927
|
+
args: [{ name: "link", description: "The carousel (Instagram or TikTok link, or a folder of slide images)" }, { name: "topic", description: "Your topic (optional)" }],
|
|
22928
|
+
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."}`
|
|
22929
|
+
},
|
|
22930
|
+
{
|
|
22931
|
+
name: "finish-video",
|
|
22932
|
+
title: "Finish a raw video",
|
|
22933
|
+
description: "Turn a raw video of you talking into a finished reel: captions, effects, your brand.",
|
|
22934
|
+
args: [{ name: "video", description: "The raw video file (optional: otherwise the newest in your inbox)" }],
|
|
22935
|
+
ask: (a) => `Finish my raw video into a reel: get_method('finish_video') and follow it step by step.${say("Video", a.video)}${a.video ? "" : " Use the newest video in my studio's inbox/ and tell me which one you picked."}`
|
|
22936
|
+
},
|
|
22937
|
+
{
|
|
22938
|
+
name: "breakdown",
|
|
22939
|
+
title: "Break down a reel",
|
|
22940
|
+
description: "Why a reel works: hook, beats, pacing and the look, with how you'd make your own.",
|
|
22941
|
+
args: [{ name: "link", description: "The reel (Instagram link or a video file path)" }],
|
|
22942
|
+
ask: (a) => `Break down this reel: breakdown_reel, then get_method('breakdown_writeup') and follow it.${say("Reel", a.link)}${a.link ? "" : " Ask me for the reel first."}`
|
|
22943
|
+
},
|
|
22944
|
+
{
|
|
22945
|
+
name: "teardown",
|
|
22946
|
+
title: "Tear down a creator",
|
|
22947
|
+
description: "A creator's system from their latest reels, and the gap to yours.",
|
|
22948
|
+
args: [{ name: "handle", description: "Their Instagram handle" }, { name: "compare_to", description: "Your handle (optional)" }],
|
|
22949
|
+
ask: (a) => `Tear down this creator: creator_teardown, then get_method('teardown_writeup') and follow it.${say("Their handle", a.handle)}${say("Compare to my handle", a.compare_to)}${a.handle ? "" : " Ask me for their handle first."}`
|
|
22950
|
+
},
|
|
22951
|
+
{
|
|
22952
|
+
name: "hooks",
|
|
22953
|
+
title: "Write hooks",
|
|
22954
|
+
description: "Hooks for your next video, scored and ranked.",
|
|
22955
|
+
args: [{ name: "topic", description: "What the video is about" }],
|
|
22956
|
+
ask: (a) => `Write hooks for my next video: get_method('hooks') and follow it.${say("Topic", a.topic)}${a.topic ? "" : " Ask me for the topic first."}`
|
|
22957
|
+
},
|
|
22958
|
+
{
|
|
22959
|
+
name: "script",
|
|
22960
|
+
title: "Write a short script",
|
|
22961
|
+
description: "An 8-line short script for one idea, scored.",
|
|
22962
|
+
args: [{ name: "topic", description: "The idea" }],
|
|
22963
|
+
ask: (a) => `Write a short script: get_method('script') and follow it.${say("Idea", a.topic)}${a.topic ? "" : " Ask me for the idea first."}`
|
|
22964
|
+
},
|
|
22965
|
+
{
|
|
22966
|
+
name: "caption",
|
|
22967
|
+
title: "Write the post caption",
|
|
22968
|
+
description: "The caption and comment-keyword CTA for a finished video.",
|
|
22969
|
+
args: [{ name: "topic", description: "The video (optional)" }],
|
|
22970
|
+
ask: (a) => `Write the post caption: get_method('caption') and follow it.${say("Video", a.topic)}`
|
|
22971
|
+
},
|
|
22972
|
+
{
|
|
22973
|
+
name: "brand",
|
|
22974
|
+
title: "Set your brand",
|
|
22975
|
+
description: "See or change the colors and font every render uses.",
|
|
22976
|
+
args: [],
|
|
22977
|
+
ask: () => "Show my Cutroom brand kit with show_brand. If none is set, offer to set one with set_brand (from colors, a website or a logo), show me the colors, and save only on my yes."
|
|
22978
|
+
},
|
|
22979
|
+
{
|
|
22980
|
+
name: "doctor",
|
|
22981
|
+
title: "Check Cutroom setup",
|
|
22982
|
+
description: "Check everything Cutroom needs on this computer and fix what's missing.",
|
|
22983
|
+
args: [],
|
|
22984
|
+
ask: () => "Check my Cutroom setup with cutroom_doctor and give me a short checklist with the fix for anything missing."
|
|
22985
|
+
}
|
|
22986
|
+
].filter((p) => MEMBER_CAROUSEL || p.name !== "copy-carousel");
|
|
22987
|
+
|
|
22988
|
+
// src/community/local/carousel.ts
|
|
22989
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
22990
|
+
import { homedir as homedir5 } from "node:os";
|
|
22991
|
+
import { isAbsolute as isAbsolute3, join as join10, resolve as resolve5 } from "node:path";
|
|
22992
|
+
|
|
22993
|
+
// src/carousel.ts
|
|
22994
|
+
import { execFileSync as execFileSync2, spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
|
|
22995
|
+
import { copyFileSync as copyFileSync3, existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync3, readFileSync as readFileSync8, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
|
|
22996
|
+
import { homedir as homedir4 } from "node:os";
|
|
22997
|
+
import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join9, resolve as resolve4 } from "node:path";
|
|
22998
|
+
|
|
22999
|
+
// src/scrapecreators.ts
|
|
23000
|
+
import { createWriteStream as createWriteStream2 } from "node:fs";
|
|
23001
|
+
import { Readable as Readable2 } from "node:stream";
|
|
23002
|
+
import { pipeline as pipeline2 } from "node:stream/promises";
|
|
23003
|
+
async function download2(url2, target, fetchImpl = fetch) {
|
|
23004
|
+
const res = await fetchImpl(url2);
|
|
23005
|
+
if (!res.ok || !res.body) throw new Error(`Download failed: HTTP ${res.status}`);
|
|
23006
|
+
await pipeline2(Readable2.fromWeb(res.body), createWriteStream2(target));
|
|
23007
|
+
return target;
|
|
23008
|
+
}
|
|
23009
|
+
|
|
23010
|
+
// src/carousel.ts
|
|
23011
|
+
function palette(file, k = 6) {
|
|
23012
|
+
const raw = spawnSync4("ffmpeg", ["-v", "error", "-i", file, "-vf", "scale=72:90", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { maxBuffer: 1 << 22 });
|
|
23013
|
+
if (raw.status !== 0 || !raw.stdout?.length) return [];
|
|
23014
|
+
return paletteFromRgb(raw.stdout, k);
|
|
23015
|
+
}
|
|
23016
|
+
function paletteFromRgb(px, k = 6) {
|
|
23017
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
23018
|
+
const total = Math.floor(px.length / 3);
|
|
23019
|
+
for (let i = 0; i + 2 < px.length; i += 3) {
|
|
23020
|
+
const key = px[i] >> 4 << 8 | px[i + 1] >> 4 << 4 | px[i + 2] >> 4;
|
|
23021
|
+
const b = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
|
|
23022
|
+
b.n++;
|
|
23023
|
+
b.r += px[i];
|
|
23024
|
+
b.g += px[i + 1];
|
|
23025
|
+
b.b += px[i + 2];
|
|
23026
|
+
buckets.set(key, b);
|
|
23027
|
+
}
|
|
23028
|
+
const merged = [];
|
|
23029
|
+
for (const b of [...buckets.values()].sort((x, y) => y.n - x.n)) {
|
|
23030
|
+
const c = { n: b.n, r: b.r / b.n, g: b.g / b.n, b: b.b / b.n };
|
|
23031
|
+
const near = merged.find((m) => Math.hypot(m.r - c.r, m.g - c.g, m.b - c.b) < 48);
|
|
23032
|
+
if (near) {
|
|
23033
|
+
const n = near.n + c.n;
|
|
23034
|
+
near.r = (near.r * near.n + c.r * c.n) / n;
|
|
23035
|
+
near.g = (near.g * near.n + c.g * c.n) / n;
|
|
23036
|
+
near.b = (near.b * near.n + c.b * c.n) / n;
|
|
23037
|
+
near.n = n;
|
|
23038
|
+
} else merged.push(c);
|
|
23039
|
+
}
|
|
23040
|
+
const hex = (v) => Math.round(v).toString(16).padStart(2, "0");
|
|
23041
|
+
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 }));
|
|
23042
|
+
}
|
|
23043
|
+
function tileSheet(files, out, cell = 324) {
|
|
23044
|
+
if (!files.length) return null;
|
|
23045
|
+
const tmp = `${out}.parts`;
|
|
23046
|
+
mkdirSync7(tmp, { recursive: true });
|
|
23047
|
+
try {
|
|
23048
|
+
const h = Math.round(cell * 1.25 / 2) * 2;
|
|
23049
|
+
const parts = files.map((f, i) => {
|
|
23050
|
+
const part = join9(tmp, `${String(i).padStart(3, "0")}.jpg`);
|
|
23051
|
+
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`;
|
|
23052
|
+
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]);
|
|
23053
|
+
if (labelled.status !== 0) execFileSync2("ffmpeg", ["-v", "error", "-y", "-i", f, "-vf", fit, part]);
|
|
23054
|
+
return part;
|
|
23055
|
+
});
|
|
23056
|
+
const list = join9(tmp, "list.txt");
|
|
23057
|
+
writeFileSync7(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
|
|
23058
|
+
const cols = Math.min(5, parts.length), rows = Math.ceil(parts.length / cols);
|
|
23059
|
+
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]);
|
|
23060
|
+
return out;
|
|
23061
|
+
} catch {
|
|
23062
|
+
return null;
|
|
23063
|
+
} finally {
|
|
23064
|
+
rmSync6(tmp, { recursive: true, force: true });
|
|
23065
|
+
}
|
|
23066
|
+
}
|
|
23067
|
+
var IMAGE_EXT = /\.(jpe?g|png|webp|heic)$/i;
|
|
23068
|
+
async function saveCarousel(meta2, dir, opts = {}) {
|
|
23069
|
+
const slidesDir = join9(dir, "slides");
|
|
23070
|
+
mkdirSync7(slidesDir, { recursive: true });
|
|
23071
|
+
const files = [];
|
|
23072
|
+
if (meta2) {
|
|
23073
|
+
for (const [i, img] of meta2.images.entries()) {
|
|
23074
|
+
const target = join9(slidesDir, `${String(i + 1).padStart(2, "0")}.jpg`);
|
|
23075
|
+
if (!existsSync7(target)) await download2(img.url, target, opts.fetchImpl);
|
|
23076
|
+
files.push(target);
|
|
23077
|
+
}
|
|
23078
|
+
} else if (opts.folder) {
|
|
23079
|
+
const names = readdirSync3(opts.folder).filter((n) => IMAGE_EXT.test(n)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
|
|
23080
|
+
if (!names.length) throw new Error(`No images in ${opts.folder} (jpg, png, webp).`);
|
|
23081
|
+
for (const [i, n] of names.entries()) {
|
|
23082
|
+
const target = join9(slidesDir, `${String(i + 1).padStart(2, "0")}${extname2(n).toLowerCase()}`);
|
|
23083
|
+
copyFileSync3(join9(opts.folder, n), target);
|
|
23084
|
+
files.push(target);
|
|
23085
|
+
}
|
|
23086
|
+
}
|
|
23087
|
+
const slides = files.map((f, i) => ({ n: i + 1, file: f, size: imageSize(f), video_cover: !!meta2?.images[i]?.video, palette: palette(f) }));
|
|
23088
|
+
const sheet = tileSheet(files, join9(dir, "sheet.jpg"));
|
|
23089
|
+
if (meta2?.caption) writeFileSync7(join9(dir, "caption.txt"), meta2.caption);
|
|
23090
|
+
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 };
|
|
23091
|
+
writeFileSync7(join9(dir, "meta.json"), JSON.stringify({ ...info, slides }, null, 2));
|
|
23092
|
+
const md = [
|
|
23093
|
+
"# Carousel breakdown",
|
|
23094
|
+
"",
|
|
23095
|
+
`Source: ${info.source}${info.owner ? ` (@${info.owner})` : ""}`,
|
|
23096
|
+
...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)}` : ""}`] : [],
|
|
23097
|
+
`${slides.length} slides. Every slide, numbered: sheet.jpg. Each one full size: slides/.`,
|
|
23098
|
+
"",
|
|
23099
|
+
"Study only. Rebuild the structure and look with your own words and images; never reuse theirs.",
|
|
23100
|
+
"",
|
|
23101
|
+
"## Measured colors per slide (most common first, share of the slide)",
|
|
23102
|
+
...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(", ")}`),
|
|
23103
|
+
"",
|
|
23104
|
+
"## Caption",
|
|
23105
|
+
meta2?.caption ?? "(none)",
|
|
23106
|
+
""
|
|
23107
|
+
].join("\n");
|
|
23108
|
+
writeFileSync7(join9(dir, "breakdown.md"), md);
|
|
23109
|
+
return { folder: dir, report: join9(dir, "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 };
|
|
23110
|
+
}
|
|
23111
|
+
function imageSize(file) {
|
|
23112
|
+
const r = spawnSync4("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file], { encoding: "utf8" });
|
|
23113
|
+
const [w, h] = String(r.stdout ?? "").trim().split(",").map(Number);
|
|
23114
|
+
return w && h ? { width: w, height: h } : null;
|
|
23115
|
+
}
|
|
23116
|
+
var BASE = {
|
|
23117
|
+
name: "base",
|
|
23118
|
+
size: [1080, 1350],
|
|
23119
|
+
colors: { bg: "#FFFFFF", text: "#111111", accent: "#2B59FF", muted: "#6B6B6B", card: "#F2F2F2", stroke: "#000000", box: "#FFFFFF", boxText: "#111111" },
|
|
23120
|
+
fonts: { headline: "Inter", body: "Inter", mono: "Roboto Mono" },
|
|
23121
|
+
headline: { case: "none", weight: 800, size: 0.085, lineHeight: 1.05, letterSpacing: -0.02, italicAccent: false },
|
|
23122
|
+
body: { size: 0.034, weight: 400, lineHeight: 1.35 },
|
|
23123
|
+
text: { treatment: "plain", align: "left", valign: "center", strokeWidth: 0.09, offset: 0, quoteMark: true },
|
|
23124
|
+
frame: { margin: 0.08, pageNumber: "top-right", handle: "", handlePos: "bottom-left", swipeCue: "arrow" },
|
|
23125
|
+
image: { radius: 0, grade: "none", position: "top" },
|
|
23126
|
+
texture: { grain: 0, vignette: 0 }
|
|
23127
|
+
};
|
|
23128
|
+
function mergeStyle(base, over) {
|
|
23129
|
+
if (!over) return base;
|
|
23130
|
+
const out = { ...base };
|
|
23131
|
+
for (const [k, v] of Object.entries(over)) {
|
|
23132
|
+
if (v && typeof v === "object" && !Array.isArray(v) && typeof base[k] === "object") out[k] = { ...base[k], ...v };
|
|
23133
|
+
else if (v !== void 0) out[k] = v;
|
|
23134
|
+
}
|
|
23135
|
+
return out;
|
|
23136
|
+
}
|
|
23137
|
+
var STYLE_PRESETS = {
|
|
23138
|
+
"editorial-serif": {
|
|
23139
|
+
about: "Warm paper ground, big serif headlines with an italic accent word, small sans body. 'N principles' and essay carousels.",
|
|
23140
|
+
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" } }
|
|
23141
|
+
},
|
|
23142
|
+
"tiktok-outline": {
|
|
23143
|
+
about: "TikTok slideshow: full-bleed photo, centered white sans text with a black outline.",
|
|
23144
|
+
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" } }
|
|
23145
|
+
},
|
|
23146
|
+
"tiktok-box": {
|
|
23147
|
+
about: "TikTok slideshow: full-bleed photo, centered text on white rounded label boxes.",
|
|
23148
|
+
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" } }
|
|
23149
|
+
},
|
|
23150
|
+
"bold-minimal": {
|
|
23151
|
+
about: "White ground, heavy tight sans, one bright accent. Hot takes and frameworks.",
|
|
23152
|
+
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 } }
|
|
23153
|
+
},
|
|
23154
|
+
"notes-app": {
|
|
23155
|
+
about: "Phone notes look: off-white ground, plain dark text, a yellow accent. Personal lists and confessions.",
|
|
23156
|
+
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" } }
|
|
23157
|
+
},
|
|
23158
|
+
cinematic: {
|
|
23159
|
+
internal: true,
|
|
23160
|
+
about: "Keepy Labs house style (internal only): dark grainy ground, cream text, one blue accent.",
|
|
23161
|
+
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 } }
|
|
23162
|
+
}
|
|
23163
|
+
};
|
|
23164
|
+
function applyBrand(style, brand) {
|
|
23165
|
+
if (!brand) return style;
|
|
23166
|
+
const c = style.colors;
|
|
23167
|
+
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 };
|
|
23168
|
+
if (style.text.treatment === "outline") colors.stroke = brand.bg ?? c.stroke;
|
|
23169
|
+
if (style.text.treatment === "highlight-box") {
|
|
23170
|
+
colors.box = brand.text ?? c.box;
|
|
23171
|
+
colors.boxText = brand.bg ?? c.boxText;
|
|
23172
|
+
}
|
|
23173
|
+
return {
|
|
23174
|
+
...style,
|
|
23175
|
+
name: `${style.name}+brand`,
|
|
23176
|
+
colors,
|
|
23177
|
+
fonts: { headline: brand.headline ?? style.fonts.headline, body: brand.body ?? style.fonts.body, mono: brand.mono ?? style.fonts.mono },
|
|
23178
|
+
texture: { grain: brand.grain ?? style.texture.grain, vignette: brand.vignette ?? style.texture.vignette }
|
|
23179
|
+
};
|
|
23180
|
+
}
|
|
23181
|
+
function brandFromJson(j) {
|
|
23182
|
+
if (!j) return null;
|
|
23183
|
+
const font = j.font === "serif" ? "Instrument Serif" : j.font === "mono" ? "Roboto Mono" : j.font === "sans" ? "Inter" : void 0;
|
|
23184
|
+
return { bg: j.background, text: j.text, accent: j.accent ?? j.highlight, headline: font, body: font === "Instrument Serif" ? "Inter" : font };
|
|
23185
|
+
}
|
|
23186
|
+
var LAYOUTS = ["text-hook", "image-hook", "image-caption", "list-item", "quote", "before-after", "screenshot-label", "cta"];
|
|
23187
|
+
function validateDeck(deck) {
|
|
23188
|
+
if (!deck || typeof deck !== "object") throw new Error("deck must be an object with style and slides.");
|
|
23189
|
+
if (!Array.isArray(deck.slides) || !deck.slides.length) throw new Error("deck.slides must be a non-empty list.");
|
|
23190
|
+
if (deck.slides.length > 20) throw new Error("At most 20 slides (Instagram's limit).");
|
|
23191
|
+
deck.slides.forEach((s, i) => {
|
|
23192
|
+
if (!LAYOUTS.includes(s?.layout)) throw new Error(`Slide ${i + 1}: layout must be one of ${LAYOUTS.join(", ")}.`);
|
|
23193
|
+
const hasText = ["headline", "sub", "body", "quote", "keyword", "number"].some((k) => typeof s[k] === "string" && s[k].trim()) || s.before || s.after;
|
|
23194
|
+
if (!hasText) throw new Error(`Slide ${i + 1}: needs some text (headline, body, quote...).`);
|
|
23195
|
+
});
|
|
23196
|
+
return deck;
|
|
23197
|
+
}
|
|
23198
|
+
function resolveStyle(spec, opts = {}) {
|
|
23199
|
+
const load = (ref) => {
|
|
23200
|
+
const preset = STYLE_PRESETS[ref];
|
|
23201
|
+
if (preset) {
|
|
23202
|
+
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.`);
|
|
23203
|
+
return mergeStyle({ ...BASE, name: ref }, preset.style);
|
|
23204
|
+
}
|
|
23205
|
+
const file = isAbsolute2(ref) ? ref : resolve4(opts.baseDir ?? ".", ref.replace(/^~/, homedir4()));
|
|
23206
|
+
if (!existsSync7(file)) throw new Error(`No style called ${ref} (presets: ${Object.keys(STYLE_PRESETS).join(", ")}; or a style.json path).`);
|
|
23207
|
+
const j = JSON.parse(readFileSync8(file, "utf8"));
|
|
23208
|
+
return j.preset || j.path ? resolveStyle(j, { ...opts, baseDir: dirname2(file) }) : mergeStyle({ ...BASE, name: basename3(file, ".json") }, j);
|
|
23209
|
+
};
|
|
23210
|
+
if (typeof spec === "string") return load(spec);
|
|
23211
|
+
const s = spec ?? {};
|
|
23212
|
+
if (s.preset || s.path) return mergeStyle(load(s.preset ?? s.path), s.overrides);
|
|
23213
|
+
return mergeStyle({ ...BASE, name: s.name ?? "custom" }, s);
|
|
23214
|
+
}
|
|
23215
|
+
var FONT_SPECS = {
|
|
23216
|
+
"Inter": "ital,wght@0,100..900;1,100..900",
|
|
23217
|
+
"Instrument Serif": "ital@0;1",
|
|
23218
|
+
"Playfair Display": "ital,wght@0,400..900;1,400..900",
|
|
23219
|
+
"DM Serif Display": "ital@0;1",
|
|
23220
|
+
"Cormorant Garamond": "ital,wght@0,300..700;1,300..700",
|
|
23221
|
+
"EB Garamond": "ital,wght@0,400..800;1,400..800",
|
|
23222
|
+
"Libre Baskerville": "ital,wght@0,400;0,700;1,400",
|
|
23223
|
+
"Fraunces": "ital,wght@0,100..900;1,100..900",
|
|
23224
|
+
"Lora": "ital,wght@0,400..700;1,400..700",
|
|
23225
|
+
"Montserrat": "ital,wght@0,100..900;1,100..900",
|
|
23226
|
+
"Poppins": "ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,700",
|
|
23227
|
+
"DM Sans": "ital,wght@0,100..1000;1,100..1000",
|
|
23228
|
+
"Manrope": "wght@200..800",
|
|
23229
|
+
"Plus Jakarta Sans": "ital,wght@0,200..800;1,200..800",
|
|
23230
|
+
"Space Grotesk": "wght@300..700",
|
|
23231
|
+
"Archivo": "ital,wght@0,100..900;1,100..900",
|
|
23232
|
+
"Oswald": "wght@200..700",
|
|
23233
|
+
"Bebas Neue": "",
|
|
23234
|
+
"Anton": "",
|
|
23235
|
+
"Archivo Black": "",
|
|
23236
|
+
"Roboto Mono": "ital,wght@0,100..700;1,100..700",
|
|
23237
|
+
"JetBrains Mono": "ital,wght@0,100..800;1,100..800",
|
|
23238
|
+
"Caveat": "wght@400..700",
|
|
23239
|
+
"Permanent Marker": "",
|
|
23240
|
+
"Kalam": "wght@300;400;700",
|
|
23241
|
+
"Noto Sans KR": "wght@100..900",
|
|
23242
|
+
"TikTok Sans": "wght@300..900",
|
|
23243
|
+
"Noto Serif KR": "wght@200..900"
|
|
23244
|
+
};
|
|
23245
|
+
var SERIF = /serif|garamond|baskerville|playfair|fraunces|lora|cormorant|merriweather/i;
|
|
23246
|
+
function fontLinks(families) {
|
|
23247
|
+
return [...new Set(families.filter(Boolean))].map((f) => {
|
|
23248
|
+
const spec = FONT_SPECS[f];
|
|
23249
|
+
const fam = f.trim().replace(/ /g, "+") + (spec ? `:${spec}` : "");
|
|
23250
|
+
return `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${fam}&display=block">`;
|
|
23251
|
+
}).join("\n");
|
|
23252
|
+
}
|
|
23253
|
+
var stack = (f) => `"${f}", ${SERIF.test(f) && !/sans/i.test(f) ? "Georgia, serif" : "Helvetica, Arial, sans-serif"}`;
|
|
23254
|
+
var esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
23255
|
+
function rich(s) {
|
|
23256
|
+
if (!s) return "";
|
|
23257
|
+
return esc2(s).replace(/\*([^*]+)\*/g, '<em class="hl">$1</em>').replace(/\n/g, "<br>");
|
|
23258
|
+
}
|
|
23259
|
+
var fileUrl = (p) => /^(https?:|data:|file:)/.test(p) ? p : `file://${encodeURI(p)}`;
|
|
23260
|
+
function imageEl(img, style, baseDir, cls = "img") {
|
|
23261
|
+
const src = /^(https?:|data:)/.test(img.src) ? img.src : resolve4(baseDir, img.src.replace(/^~/, homedir4()));
|
|
23262
|
+
const grade = img.grade ?? style.image.grade;
|
|
23263
|
+
const filter = grade === "mono" ? "grayscale(1) contrast(1.05)" : grade === "warm" ? "sepia(0.25) saturate(1.1)" : grade === "dim" ? "brightness(0.7)" : "none";
|
|
23264
|
+
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>`;
|
|
23265
|
+
}
|
|
23266
|
+
function artHtml(art) {
|
|
23267
|
+
if (!art) return "";
|
|
23268
|
+
switch (art.kind) {
|
|
23269
|
+
case "html":
|
|
23270
|
+
return `<div class="art art-html">${art.html}</div>`;
|
|
23271
|
+
case "stat":
|
|
23272
|
+
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>`;
|
|
23273
|
+
case "chart": {
|
|
23274
|
+
const max = Math.max(...art.bars.map((b) => b.value), 1);
|
|
23275
|
+
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>`;
|
|
23276
|
+
}
|
|
23277
|
+
case "chat":
|
|
23278
|
+
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>`;
|
|
23279
|
+
case "checklist":
|
|
23280
|
+
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>`;
|
|
23281
|
+
case "browser":
|
|
23282
|
+
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>`;
|
|
23283
|
+
}
|
|
23284
|
+
}
|
|
23285
|
+
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>` : "";
|
|
23286
|
+
function slideHtml(slide, style, n, total, opts) {
|
|
23287
|
+
const [W, H] = style.size;
|
|
23288
|
+
const m = Math.round(style.frame.margin * W);
|
|
23289
|
+
const c = { ...style.colors, bg: slide.bg ?? style.colors.bg, text: slide.color ?? style.colors.text, accent: slide.accent ?? style.colors.accent };
|
|
23290
|
+
const sc = slide.scale ?? 1;
|
|
23291
|
+
const hs = Math.round(style.headline.size * W * sc), bs = Math.round(style.body.size * W * sc);
|
|
23292
|
+
const align = slide.align ?? style.text.align, valign = slide.valign ?? style.text.valign;
|
|
23293
|
+
const treat = slide.treatment ?? style.text.treatment;
|
|
23294
|
+
const handle = opts.handle ?? style.frame.handle;
|
|
23295
|
+
const j = valign === "top" ? "flex-start" : valign === "bottom" ? "flex-end" : "center";
|
|
23296
|
+
const off = Math.round(style.text.offset * H);
|
|
23297
|
+
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>`;
|
|
23298
|
+
const kicker = slide.kicker ? `<div class="kicker" data-fit>${rich(slide.kicker)}</div>` : "";
|
|
23299
|
+
const head = slide.headline ? `<div class="headline" data-fit>${treat === "highlight-box" ? `<span class="tb">${rich(slide.headline)}</span>` : rich(slide.headline)}</div>` : "";
|
|
23300
|
+
const sub = slide.sub ? `<div class="sub" data-fit>${rich(slide.sub)}</div>` : "";
|
|
23301
|
+
const body = slide.body ? `<div class="body" data-fit>${treat === "highlight-box" ? `<span class="tb">${rich(slide.body)}</span>` : rich(slide.body)}</div>` : "";
|
|
23302
|
+
const fullBleed = (img) => (img?.position ?? style.image.position) === "full";
|
|
23303
|
+
let inner = "";
|
|
23304
|
+
switch (slide.layout) {
|
|
23305
|
+
case "text-hook":
|
|
23306
|
+
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>`;
|
|
23307
|
+
break;
|
|
23308
|
+
case "image-hook":
|
|
23309
|
+
case "image-caption": {
|
|
23310
|
+
const pos = slide.image?.position ?? style.image.position;
|
|
23311
|
+
if (fullBleed(slide.image) && slide.image) {
|
|
23312
|
+
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>`;
|
|
23313
|
+
} else {
|
|
23314
|
+
const media = `<div class="media ${pos === "framed" ? "framed" : ""}">${visual(slide, style, opts.baseDir)}</div>`;
|
|
23315
|
+
const text = `<div class="txt">${textBlock(kicker + head + sub + body)}</div>`;
|
|
23316
|
+
inner = `<div class="col pad split">${pos === "bottom" ? text + media : media + text}</div>`;
|
|
23317
|
+
}
|
|
23318
|
+
break;
|
|
23319
|
+
}
|
|
23320
|
+
case "list-item":
|
|
23321
|
+
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>`;
|
|
23322
|
+
break;
|
|
23323
|
+
case "quote":
|
|
23324
|
+
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>`;
|
|
23325
|
+
break;
|
|
23326
|
+
case "before-after": {
|
|
23327
|
+
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>`;
|
|
23328
|
+
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>`;
|
|
23329
|
+
break;
|
|
23330
|
+
}
|
|
23331
|
+
case "screenshot-label":
|
|
23332
|
+
{
|
|
23333
|
+
const doodle = slide.image && slide.art ? `<div class="doodle">${artHtml(slide.art)}</div>` : "";
|
|
23334
|
+
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>`;
|
|
23335
|
+
break;
|
|
23336
|
+
}
|
|
23337
|
+
break;
|
|
23338
|
+
case "cta":
|
|
23339
|
+
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>`;
|
|
23340
|
+
break;
|
|
23341
|
+
}
|
|
23342
|
+
const page2 = style.frame.pageNumber !== "none" && total > 1 ? `<div class="page page-${style.frame.pageNumber}">${n}/${total}</div>` : "";
|
|
23343
|
+
const hand = handle && style.frame.handlePos !== "none" ? `<div class="handle handle-${style.frame.handlePos}">${esc2(handle.startsWith("@") ? handle : `@${handle}`)}</div>` : "";
|
|
23344
|
+
const swipe = n < total && style.frame.swipeCue !== "none" && slide.layout !== "cta" ? `<div class="swipe">${style.frame.swipeCue === "text" ? "Swipe →" : "→"}</div>` : "";
|
|
23345
|
+
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>` : "";
|
|
23346
|
+
const vig = style.texture.vignette > 0 ? `<div class="vig"></div>` : "";
|
|
23347
|
+
const ff = style.fonts;
|
|
23348
|
+
const strokeW = Math.max(2, Math.round(hs * style.text.strokeWidth));
|
|
23349
|
+
const ring = (w) => Array.from({ length: 16 }, (_, i) => {
|
|
23350
|
+
const t = i / 16 * Math.PI * 2;
|
|
23351
|
+
return `${(Math.cos(t) * w).toFixed(1)}px ${(Math.sin(t) * w).toFixed(1)}px 0 ${c.stroke}`;
|
|
23352
|
+
}).join(",");
|
|
23353
|
+
const outline = treat === "outline" ? `text-shadow:${ring(strokeW)},${ring(strokeW / 2)};` : "";
|
|
23354
|
+
const shadow = treat === "shadow" || treat === "plain" && fullBleed(slide.image) && slide.image ? "text-shadow:0 2px 18px rgba(0,0,0,.55);" : "";
|
|
23355
|
+
const css = `
|
|
23356
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
23357
|
+
html,body{width:${W}px;height:${H}px;overflow:hidden;background:${c.bg}}
|
|
23358
|
+
body{position:relative;color:${c.text};font-family:${stack(ff.body)};-webkit-font-smoothing:antialiased}
|
|
23359
|
+
.pad{position:absolute;inset:0;padding:${m}px;display:flex;flex-direction:column;gap:${Math.round(m * 0.45)}px}
|
|
23360
|
+
.over{z-index:3}
|
|
23361
|
+
.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}
|
|
23362
|
+
.headline,.ptext,.kw{text-wrap:balance}.body,.attr,.msg{text-wrap:pretty}
|
|
23363
|
+
.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}}
|
|
23364
|
+
.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}}
|
|
23365
|
+
.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}}
|
|
23366
|
+
.hl{font-style:${style.headline.italicAccent ? "italic" : "normal"};color:${treat === "plain" ? c.accent : "inherit"}}
|
|
23367
|
+
${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}` : ""}
|
|
23368
|
+
.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}}
|
|
23369
|
+
.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}
|
|
23370
|
+
.tb .hl{color:${c.boxText}}
|
|
23371
|
+
.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}}
|
|
23372
|
+
.media{flex:0 0 auto;height:52%;position:relative;border-radius:${style.image.radius}px;overflow:hidden}
|
|
23373
|
+
.media-sm{height:38%}
|
|
23374
|
+
.framed{border-radius:${Math.max(style.image.radius, 18)}px;box-shadow:0 18px 50px rgba(0,0,0,.25)}
|
|
23375
|
+
.split .txt{flex:1 1 auto;min-height:0;display:flex}
|
|
23376
|
+
.img{position:absolute;inset:0;background-repeat:no-repeat}
|
|
23377
|
+
.bleed{position:absolute;inset:0;background-repeat:no-repeat;z-index:1}
|
|
23378
|
+
.scrim{position:absolute;inset:0;z-index:2;background:linear-gradient(180deg,rgba(0,0,0,0) 35%,rgba(0,0,0,.72) 100%)}
|
|
23379
|
+
.artwrap{display:flex;align-items:center;justify-content:center;background:${c.card};border-radius:${Math.max(style.image.radius, 14)}px}
|
|
23380
|
+
.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}
|
|
23381
|
+
.quote{font-style:${style.headline.italicAccent ? "italic" : "normal"}}
|
|
23382
|
+
.attr{font-size:${bs}px;color:${c.muted};${outline}${shadow}}
|
|
23383
|
+
.bahead{flex:0 0 auto}
|
|
23384
|
+
.ba{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:${Math.round(m * 0.35)}px}
|
|
23385
|
+
.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}
|
|
23386
|
+
.before{background:${c.card};color:${c.muted}}
|
|
23387
|
+
.after{background:${c.card};border:3px solid ${c.accent}}
|
|
23388
|
+
.plabel{font-family:${stack(ff.mono)};font-size:${Math.round(bs * 0.75)}px;letter-spacing:.1em;text-transform:uppercase}
|
|
23389
|
+
.after .plabel{color:${c.accent}}
|
|
23390
|
+
.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}}
|
|
23391
|
+
.before .ptext{color:${c.muted}}
|
|
23392
|
+
.pmedia{flex:1 1 auto;position:relative;min-height:0;border-radius:10px;overflow:hidden}
|
|
23393
|
+
.shot-sm{height:42% !important}
|
|
23394
|
+
.doodle{flex:0 0 auto;height:19%;color:${c.text}}.doodle .art{padding:0}.doodle svg,.doodle img{height:100%;width:auto}
|
|
23395
|
+
.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}}
|
|
23396
|
+
.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}
|
|
23397
|
+
.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}
|
|
23398
|
+
.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}
|
|
23399
|
+
.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}
|
|
23400
|
+
.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}
|
|
23401
|
+
.swipe{bottom:${Math.round(m * 0.45)}px;right:${m}px;color:${c.accent}}
|
|
23402
|
+
.grain{position:absolute;inset:0;z-index:5;opacity:${style.texture.grain};mix-blend-mode:overlay;pointer-events:none}
|
|
23403
|
+
.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%)}
|
|
23404
|
+
.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}
|
|
23405
|
+
.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}
|
|
23406
|
+
.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}}
|
|
23407
|
+
.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)}}
|
|
23408
|
+
.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}
|
|
23409
|
+
.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}}
|
|
23410
|
+
.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}}
|
|
23411
|
+
.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%}
|
|
23412
|
+
`;
|
|
23413
|
+
const fit = `
|
|
23414
|
+
<script>
|
|
23415
|
+
(async function(){
|
|
23416
|
+
try { await document.fonts.ready; } catch(e) {}
|
|
23417
|
+
const want = ${JSON.stringify([ff.headline, ff.body, ff.mono])};
|
|
23418
|
+
try { await Promise.all(want.map(f => document.fonts.load('40px "' + f + '"'))); } catch(e) {}
|
|
23419
|
+
const missing = want.filter(f => !document.fonts.check('40px "' + f + '"'));
|
|
23420
|
+
const boxes = [...document.querySelectorAll('.fitbox,.panel,.ba,.pad')];
|
|
23421
|
+
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);
|
|
23422
|
+
const els = [...document.querySelectorAll('[data-fit]')];
|
|
23423
|
+
const base = els.map(e => parseFloat(getComputedStyle(e).fontSize));
|
|
23424
|
+
let k = 1;
|
|
23425
|
+
while (over() && k > 0.55) { k *= 0.95; els.forEach((e, i) => e.style.fontSize = (base[i] * k) + 'px'); }
|
|
23426
|
+
document.body.setAttribute('data-qa', JSON.stringify({ shrunk: Math.round((1 - k) * 100), overflow: over(), missing_fonts: missing }));
|
|
23427
|
+
})();
|
|
23428
|
+
</script>`;
|
|
23429
|
+
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>`;
|
|
23430
|
+
}
|
|
23431
|
+
function findChrome(env = process.env) {
|
|
23432
|
+
const cands = [
|
|
23433
|
+
env.CHROME_PATH,
|
|
23434
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
23435
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
23436
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
23437
|
+
"/usr/bin/google-chrome",
|
|
23438
|
+
"/usr/bin/chromium",
|
|
23439
|
+
"/usr/bin/chromium-browser",
|
|
23440
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
23441
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
23442
|
+
].filter(Boolean);
|
|
23443
|
+
for (const c of cands) if (existsSync7(c)) return c;
|
|
23444
|
+
const pw = join9(homedir4(), "Library", "Caches", "ms-playwright");
|
|
23445
|
+
if (existsSync7(pw)) {
|
|
23446
|
+
for (const d of readdirSync3(pw).filter((n) => /^chromium-\d+$/.test(n)).sort().reverse()) {
|
|
23447
|
+
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"]) {
|
|
23448
|
+
if (existsSync7(join9(pw, d, sub))) return join9(pw, d, sub);
|
|
23449
|
+
}
|
|
23450
|
+
}
|
|
23451
|
+
}
|
|
23452
|
+
return null;
|
|
23453
|
+
}
|
|
23454
|
+
function chrome(bin, args, timeoutMs = 45e3) {
|
|
23455
|
+
return new Promise((done) => {
|
|
23456
|
+
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]);
|
|
23457
|
+
let out = "";
|
|
23458
|
+
p.stdout.on("data", (d) => out += d);
|
|
23459
|
+
const t = setTimeout(() => p.kill("SIGKILL"), timeoutMs);
|
|
23460
|
+
p.on("close", (code) => {
|
|
23461
|
+
clearTimeout(t);
|
|
23462
|
+
done({ code, out });
|
|
23463
|
+
});
|
|
23464
|
+
p.on("error", () => {
|
|
23465
|
+
clearTimeout(t);
|
|
23466
|
+
done({ code: -1, out });
|
|
23467
|
+
});
|
|
23468
|
+
});
|
|
23469
|
+
}
|
|
23470
|
+
function contrast(a, b) {
|
|
23471
|
+
const lum = (hex) => {
|
|
23472
|
+
const h = hex.replace("#", "");
|
|
23473
|
+
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);
|
|
23474
|
+
return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
|
|
23475
|
+
};
|
|
23476
|
+
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
|
|
23477
|
+
return Math.round((x + 0.05) / (y + 0.05) * 100) / 100;
|
|
23478
|
+
}
|
|
23479
|
+
async function renderDeck(folder, opts = {}) {
|
|
23480
|
+
const deckFile = join9(folder, "deck.json");
|
|
23481
|
+
if (!existsSync7(deckFile)) throw new Error(`No deck.json in ${folder}.`);
|
|
23482
|
+
const deck = validateDeck(JSON.parse(readFileSync8(deckFile, "utf8")));
|
|
23483
|
+
const bin = findChrome();
|
|
23484
|
+
if (!bin) throw new Error("Rendering slides needs Google Chrome (or Chromium) on this computer. Install Chrome, or set CHROME_PATH to it.");
|
|
23485
|
+
let style = resolveStyle(deck.style, { baseDir: folder, internal: opts.internal });
|
|
23486
|
+
const brand = typeof deck.brand === "string" ? opts.brand?.(deck.brand) ?? null : deck.brand ?? null;
|
|
23487
|
+
if (typeof deck.brand === "string" && !brand) throw new Error(`No brand kit called ${deck.brand}.`);
|
|
23488
|
+
style = applyBrand(style, brand);
|
|
23489
|
+
const total = deck.slides.length;
|
|
23490
|
+
const targets = (opts.only?.length ? opts.only : deck.slides.map((_, i) => i + 1)).filter((n) => n >= 1 && n <= total);
|
|
23491
|
+
const renders = join9(folder, "renders"), html = join9(folder, "html"), history = join9(renders, "history");
|
|
23492
|
+
for (const d of [renders, html]) mkdirSync7(d, { recursive: true });
|
|
23493
|
+
const queue = [...targets];
|
|
23494
|
+
const results = [];
|
|
23495
|
+
const worker = async () => {
|
|
23496
|
+
for (let n = queue.shift(); n !== void 0; n = queue.shift()) {
|
|
23497
|
+
const name = String(n).padStart(2, "0");
|
|
23498
|
+
const page2 = join9(html, `${name}.html`), png = join9(renders, `${name}.png`);
|
|
23499
|
+
writeFileSync7(page2, slideHtml(deck.slides[n - 1], style, n, total, { baseDir: folder, handle: deck.handle }));
|
|
23500
|
+
if (existsSync7(png)) {
|
|
23501
|
+
mkdirSync7(history, { recursive: true });
|
|
23502
|
+
let v = 1;
|
|
23503
|
+
while (existsSync7(join9(history, `${name}-v${v}.png`))) v++;
|
|
23504
|
+
copyFileSync3(png, join9(history, `${name}-v${v}.png`));
|
|
23505
|
+
}
|
|
23506
|
+
const [W, H] = style.size;
|
|
23507
|
+
const shot = await chrome(bin, [`--window-size=${W},${H}`, `--screenshot=${png}`, fileUrl(page2)]);
|
|
23508
|
+
if (!existsSync7(png) || shot.code !== 0) throw new Error(`Chrome could not render slide ${n}.`);
|
|
23509
|
+
const dom = await chrome(bin, [`--window-size=${W},${H}`, "--dump-dom", fileUrl(page2)]);
|
|
23510
|
+
let qa = {};
|
|
23511
|
+
try {
|
|
23512
|
+
qa = JSON.parse((dom.out.match(/data-qa="([^"]*)"/)?.[1] ?? "{}").replace(/"/g, '"'));
|
|
23513
|
+
} catch {
|
|
23514
|
+
}
|
|
23515
|
+
const s = deck.slides[n - 1];
|
|
23516
|
+
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);
|
|
23517
|
+
const flags = [
|
|
23518
|
+
...qa.overflow ? ["text still does not fit: shorten it"] : [],
|
|
23519
|
+
...qa.shrunk >= 25 ? [`text shrunk ${qa.shrunk}% to fit: consider fewer words`] : [],
|
|
23520
|
+
...qa.missing_fonts?.length ? [`font not loaded (fallback used): ${qa.missing_fonts.join(", ")}`] : [],
|
|
23521
|
+
...ratio !== void 0 && ratio < 4.5 ? [`low contrast ${ratio}:1 (text on background)`] : [],
|
|
23522
|
+
...s.image && !/^(https?:|data:)/.test(s.image.src) && !existsSync7(resolve4(folder, s.image.src.replace(/^~/, homedir4()))) ? [`image missing: ${s.image.src}`] : []
|
|
23523
|
+
];
|
|
23524
|
+
results.push({ n, png, shrunk: qa.shrunk, overflow: qa.overflow, missing_fonts: qa.missing_fonts, contrast: ratio, flags });
|
|
23525
|
+
}
|
|
23526
|
+
};
|
|
23527
|
+
await Promise.all(Array.from({ length: Math.min(opts.concurrency ?? 4, targets.length) }, worker));
|
|
23528
|
+
results.sort((a, b) => a.n - b.n);
|
|
23529
|
+
const all = deck.slides.map((_, i) => join9(renders, `${String(i + 1).padStart(2, "0")}.png`)).filter(existsSync7);
|
|
23530
|
+
const preview = tileSheet(all, join9(folder, "preview.jpg"), 360);
|
|
23531
|
+
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." };
|
|
23532
|
+
}
|
|
23533
|
+
function applyEdits(deck, edits) {
|
|
23534
|
+
const touched = [];
|
|
23535
|
+
for (const e of edits) {
|
|
23536
|
+
const s = deck.slides[e.n - 1];
|
|
23537
|
+
if (!s) throw new Error(`No slide ${e.n} (the deck has ${deck.slides.length}).`);
|
|
23538
|
+
for (const [k, v] of Object.entries(e.set)) {
|
|
23539
|
+
if (v === null) delete s[k];
|
|
23540
|
+
else if (v && typeof v === "object" && !Array.isArray(v) && s[k] && typeof s[k] === "object") s[k] = { ...s[k], ...v };
|
|
23541
|
+
else s[k] = v;
|
|
23542
|
+
}
|
|
23543
|
+
touched.push(e.n);
|
|
23544
|
+
}
|
|
23545
|
+
validateDeck(deck);
|
|
23546
|
+
return { deck, touched };
|
|
23547
|
+
}
|
|
23548
|
+
async function renderCarousel(input, opts = {}) {
|
|
23549
|
+
const folder = resolve4(input.folder.replace(/^~/, homedir4()));
|
|
23550
|
+
mkdirSync7(folder, { recursive: true });
|
|
23551
|
+
const file = join9(folder, "deck.json");
|
|
23552
|
+
let only = input.only;
|
|
23553
|
+
if (input.deck) {
|
|
23554
|
+
writeFileSync7(file, JSON.stringify(validateDeck(input.deck), null, 2));
|
|
23555
|
+
} else {
|
|
23556
|
+
if (!existsSync7(file)) throw new Error(`No deck.json in ${folder}: pass deck first.`);
|
|
23557
|
+
let deck = validateDeck(JSON.parse(readFileSync8(file, "utf8")));
|
|
23558
|
+
if (input.style !== void 0) deck.style = input.style;
|
|
23559
|
+
if (input.edits?.length) {
|
|
23560
|
+
const r = applyEdits(deck, input.edits);
|
|
23561
|
+
deck = r.deck;
|
|
23562
|
+
if (!only?.length && input.style === void 0) only = r.touched;
|
|
23563
|
+
}
|
|
23564
|
+
writeFileSync7(file, JSON.stringify(deck, null, 2));
|
|
23565
|
+
}
|
|
23566
|
+
return renderDeck(folder, { only, internal: opts.internal, brand: opts.brand });
|
|
23567
|
+
}
|
|
23568
|
+
|
|
23569
|
+
// src/community/local/carousel.ts
|
|
23570
|
+
var home = (p) => resolve5(p.replace(/^~/, homedir5()));
|
|
23571
|
+
async function breakdownCarousel(input) {
|
|
23572
|
+
ensureStudio();
|
|
23573
|
+
const src = input.source.trim();
|
|
23574
|
+
const base = join10(studio.references(), "carousels");
|
|
23575
|
+
const next = "Open sheet.jpg and every slide, then follow get_method('copy_carousel').";
|
|
23576
|
+
if (/^https?:\/\//i.test(src)) {
|
|
23577
|
+
const { meta: meta2 } = await call("carousel", { url: src }).catch((e) => {
|
|
23578
|
+
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.`);
|
|
23579
|
+
});
|
|
23580
|
+
const dir = join10(base, `${day()}-${slug(meta2.owner ?? meta2.platform)}-${meta2.code ?? "post"}`);
|
|
23581
|
+
return { ...await saveCarousel(meta2, dir), next };
|
|
23582
|
+
}
|
|
23583
|
+
const folder = home(src);
|
|
23584
|
+
if (!existsSync8(folder)) throw new Error(`No such folder: ${folder}`);
|
|
23585
|
+
return { ...await saveCarousel(null, join10(base, `${day()}-${slug(folder.split(/[\\/]/).pop() ?? "carousel")}`), { folder }), next };
|
|
23586
|
+
}
|
|
23587
|
+
function renderCarouselLocal(input) {
|
|
23588
|
+
ensureStudio();
|
|
23589
|
+
const root = join10(HOME(), "carousels");
|
|
23590
|
+
const folder = input.folder ? isAbsolute3(input.folder) || input.folder.startsWith("~") ? home(input.folder) : join10(root, input.folder) : join10(root, `${day()}-${slug(input.title ?? input.deck?.title ?? "carousel")}`);
|
|
23591
|
+
return renderCarousel({ folder, deck: input.deck, edits: input.edits, style: input.style, only: input.only }, { brand: (ref) => ref === "mine" ? brandFromJson(getBrand()) : null });
|
|
23592
|
+
}
|
|
23593
|
+
|
|
22495
23594
|
// src/community/local/server.ts
|
|
22496
|
-
process.env.PATH = (WIN ? [process.env.PATH,
|
|
23595
|
+
process.env.PATH = (WIN ? [process.env.PATH, join11(process.env.LOCALAPPDATA ?? join11(homedir6(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join11(homedir6(), ".local", "bin")]).filter(Boolean).join(delimiter2);
|
|
23596
|
+
useTools();
|
|
22497
23597
|
var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
22498
23598
|
var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
|
|
22499
23599
|
var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
|
|
23600
|
+
async function tools() {
|
|
23601
|
+
const s = await ensureTools();
|
|
23602
|
+
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.`);
|
|
23603
|
+
return s.note;
|
|
23604
|
+
}
|
|
23605
|
+
var local = (op) => Promise.resolve().then(async () => {
|
|
23606
|
+
const note = await tools();
|
|
23607
|
+
const out = result(await op());
|
|
23608
|
+
return note ? { content: [{ type: "text", text: note }, ...out.content] } : out;
|
|
23609
|
+
}).catch(failure2);
|
|
22500
23610
|
var INSTRUCTIONS = `Cutroom: short-form video help. Speak plainly; the user is not technical. Never show tool or effect codes; use plain names.
|
|
22501
23611
|
|
|
22502
|
-
Setup or any failure: cutroom_doctor, then a short checklist. If they aren't signed in it opens "Sign in with Whop" in their browser and waits: before calling it, tell them a Whop sign-in page will open and to sign in with the Whop account they joined with. Never ask for a key; signing in is the only way in. Wrong account or a membership problem: cutroom_sign_in to sign in again.
|
|
23612
|
+
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.
|
|
22503
23613
|
|
|
22504
23614
|
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.
|
|
22505
23615
|
|
|
22506
23616
|
Hooks, scripts, captions: get_method('hooks', 'script' or 'caption') and follow it.
|
|
23617
|
+
|
|
23618
|
+
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 ? `
|
|
23619
|
+
|
|
23620
|
+
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.` : ""}
|
|
23621
|
+
|
|
22507
23622
|
Break down a reel: breakdown_reel, then get_method('breakdown_writeup').
|
|
22508
23623
|
Tear down a creator: creator_teardown (compare_to = their handle), then get_method('teardown_writeup').
|
|
22509
23624
|
|
|
@@ -22515,7 +23630,7 @@ Their studio is the Cutroom folder in their home folder (cutroom_doctor returns
|
|
|
22515
23630
|
await serveStdio(() => {
|
|
22516
23631
|
const server = new McpServer({ name: "cutroom", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
22517
23632
|
server.registerTool("cutroom_doctor", {
|
|
22518
|
-
description: "Check Cutroom is ready: makes their studio folders,
|
|
23633
|
+
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.",
|
|
22519
23634
|
inputSchema: object({})
|
|
22520
23635
|
}, async () => safe(() => doctor()));
|
|
22521
23636
|
server.registerTool("cutroom_sign_in", {
|
|
@@ -22527,7 +23642,7 @@ await serveStdio(() => {
|
|
|
22527
23642
|
inputSchema: object({})
|
|
22528
23643
|
}, async () => safe(() => call("methods")));
|
|
22529
23644
|
server.registerTool("get_method", {
|
|
22530
|
-
description:
|
|
23645
|
+
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.`,
|
|
22531
23646
|
inputSchema: object({ name: string2().min(1) })
|
|
22532
23647
|
}, async ({ name }) => safe(async () => {
|
|
22533
23648
|
const m = await call("method", { name });
|
|
@@ -22546,11 +23661,28 @@ ${m.text}`;
|
|
|
22546
23661
|
server.registerTool("breakdown_reel", {
|
|
22547
23662
|
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.",
|
|
22548
23663
|
inputSchema: object({ source: string2().min(1).describe("Instagram reel link or a local video file path.") })
|
|
22549
|
-
}, async (input) =>
|
|
23664
|
+
}, async (input) => local(() => breakdownReel(input)));
|
|
23665
|
+
if (MEMBER_CAROUSEL) {
|
|
23666
|
+
server.registerTool("breakdown_carousel", {
|
|
23667
|
+
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.",
|
|
23668
|
+
inputSchema: object({ source: string2().min(1).describe("Instagram or TikTok carousel link, or a folder of slide images.") })
|
|
23669
|
+
}, async (input) => safe(() => breakdownCarousel(input)));
|
|
23670
|
+
server.registerTool("render_carousel", {
|
|
23671
|
+
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').`,
|
|
23672
|
+
inputSchema: object({
|
|
23673
|
+
folder: string2().optional().describe("The carousel's folder (a name in carousels/, or a full path). Default a new one from the title."),
|
|
23674
|
+
title: string2().optional(),
|
|
23675
|
+
deck: any().optional().describe("The whole deck: { title, style, brand?, handle?, slides: [...] }."),
|
|
23676
|
+
edits: array(object({ n: number2().int().min(1), set: record(string2(), any()) })).optional(),
|
|
23677
|
+
style: any().optional(),
|
|
23678
|
+
only: array(number2().int().min(1)).optional()
|
|
23679
|
+
})
|
|
23680
|
+
}, async (input) => safe(() => renderCarouselLocal(input)));
|
|
23681
|
+
}
|
|
22550
23682
|
server.registerTool("creator_teardown", {
|
|
22551
23683
|
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').",
|
|
22552
23684
|
inputSchema: object({ handle: string2().min(1), limit: number2().int().min(1).max(30).default(12), compare_to: string2().optional() })
|
|
22553
|
-
}, async (input) =>
|
|
23685
|
+
}, async (input) => local(() => creatorTeardown(input)));
|
|
22554
23686
|
server.registerTool("list_styles", {
|
|
22555
23687
|
description: "The Cutroom finishing menu: layouts, caption styles and effects (with the text each effect needs), plus timing rules. Read it before render_reel.",
|
|
22556
23688
|
inputSchema: object({})
|
|
@@ -22579,7 +23711,7 @@ ${m.text}`;
|
|
|
22579
23711
|
cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second."),
|
|
22580
23712
|
language: string2().optional().describe("Spoken language code if not English, e.g. ko, es, ja (uses a larger model, downloaded once).")
|
|
22581
23713
|
})
|
|
22582
|
-
}, async (input) =>
|
|
23714
|
+
}, async (input) => local(() => prepareFootage(input)));
|
|
22583
23715
|
server.registerTool("plan_reel", {
|
|
22584
23716
|
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.",
|
|
22585
23717
|
inputSchema: object({
|
|
@@ -22593,10 +23725,11 @@ ${m.text}`;
|
|
|
22593
23725
|
})
|
|
22594
23726
|
}, async (input) => {
|
|
22595
23727
|
try {
|
|
23728
|
+
const note = await tools();
|
|
22596
23729
|
const r = await planReel(input);
|
|
22597
|
-
return { content: [{ type: "text", text: `${r.text}
|
|
23730
|
+
return { content: [...note ? [{ type: "text", text: note }] : [], { type: "text", text: `${r.text}
|
|
22598
23731
|
|
|
22599
|
-
${r.next}` }, { type: "image", data:
|
|
23732
|
+
${r.next}` }, { type: "image", data: readFileSync9(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
|
|
22600
23733
|
} catch (e) {
|
|
22601
23734
|
return failure2(e);
|
|
22602
23735
|
}
|
|
@@ -22614,11 +23747,17 @@ ${r.next}` }, { type: "image", data: readFileSync6(r.storyboard).toString("base6
|
|
|
22614
23747
|
draft: boolean2().default(false),
|
|
22615
23748
|
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).")
|
|
22616
23749
|
})
|
|
22617
|
-
}, async (input) =>
|
|
23750
|
+
}, async (input) => local(() => renderReel(input)));
|
|
22618
23751
|
server.registerTool("qa_video", {
|
|
22619
23752
|
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.",
|
|
22620
23753
|
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.") })
|
|
22621
|
-
}, async ({ video, layout }) =>
|
|
23754
|
+
}, async ({ video, layout: layout2 }) => local(() => qaVideo({ video, layout: layout2 })));
|
|
23755
|
+
for (const p of PROMPTS) {
|
|
23756
|
+
const shape = Object.fromEntries(p.args.map((a) => [a.name, string2().optional().describe(a.description)]));
|
|
23757
|
+
server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema: object(shape) }, (args) => ({
|
|
23758
|
+
messages: [{ role: "user", content: { type: "text", text: p.ask(args ?? {}) } }]
|
|
23759
|
+
}));
|
|
23760
|
+
}
|
|
22622
23761
|
return server;
|
|
22623
23762
|
});
|
|
22624
23763
|
/*! Bundled license information:
|