@keepylabs/cutroom 0.8.0 → 0.10.0

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