@keepylabs/cutroom 0.9.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 (2) hide show
  1. package/cutroom.mjs +258 -182
  2. package/package.json +1 -1
package/cutroom.mjs CHANGED
@@ -8,7 +8,7 @@ var __export = (target, all) => {
8
8
 
9
9
  // src/community/local/server.ts
10
10
  import { homedir as homedir6 } from "node:os";
11
- import { delimiter as delimiter2, join as join11 } from "node:path";
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";
@@ -21550,13 +21550,13 @@ function toError(value) {
21550
21550
  }
21551
21551
 
21552
21552
  // src/community/local/server.ts
21553
- import { readFileSync as readFileSync9 } from "node:fs";
21553
+ import { readFileSync as readFileSync10 } from "node:fs";
21554
21554
 
21555
21555
  // src/community/local/render.ts
21556
21556
  import { spawnSync as spawnSync3 } from "node:child_process";
21557
- import { copyFileSync as copyFileSync2, existsSync as existsSync6, mkdirSync as mkdirSync6, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync7, renameSync as renameSync4, rmSync as rmSync5, statSync, writeFileSync as writeFileSync6 } from "node:fs";
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
21558
  import { homedir as homedir3, tmpdir } from "node:os";
21559
- import { basename as basename2, extname, join as join8, resolve as resolve3 } from "node:path";
21559
+ import { basename as basename2, extname, join as join9, resolve as resolve3 } from "node:path";
21560
21560
 
21561
21561
  // src/community/local/measure.ts
21562
21562
  import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
@@ -21592,13 +21592,13 @@ function measure(file) {
21592
21592
  lufs: p.has_audio ? parseLufs(ffmpegErr(["-i", file, "-vn", "-af", "ebur128", "-f", "null", "-"])) : null
21593
21593
  };
21594
21594
  }
21595
- function sampleFrames(file, dir, duration3, interval = 1, max = 40) {
21596
- mkdirSync(dir, { recursive: true });
21595
+ function sampleFrames(file, dir2, duration3, interval = 1, max = 40) {
21596
+ mkdirSync(dir2, { recursive: true });
21597
21597
  let step = interval;
21598
21598
  if (duration3 / step > max) step = Math.ceil(duration3 / max * 10) / 10;
21599
21599
  const out = [];
21600
21600
  for (let t = Math.min(0.05, duration3); t < duration3 - 0.05 && out.length < max; t = Math.round((t + step) * 100) / 100) {
21601
- 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`);
21602
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]);
21603
21603
  out.push({ t, jpg_base64: readFileSync(path).toString("base64"), path });
21604
21604
  }
@@ -22040,8 +22040,8 @@ function extractAudio(video, out) {
22040
22040
  ${(r.stderr ?? "").trim().split("\n").slice(-3).join("\n")}`);
22041
22041
  return out;
22042
22042
  }
22043
- async function cloudWords(video, dir, language, deps = {}) {
22044
- const audio = extractAudio(video, join5(dir, "audio.mp3"));
22043
+ async function cloudWords(video, dir2, language, deps = {}) {
22044
+ const audio = extractAudio(video, join5(dir2, "audio.mp3"));
22045
22045
  let id;
22046
22046
  try {
22047
22047
  ({ id } = await call("transcribe_start", { audio_base64: readFileSync5(audio).toString("base64"), ...language ? { language } : {} }));
@@ -22057,31 +22057,31 @@ async function cloudWords(video, dir, language, deps = {}) {
22057
22057
  }
22058
22058
  throw new Error("Transcription is taking too long. Try again in a minute.");
22059
22059
  }
22060
- function localWhisperWords(video, dir, language) {
22060
+ function localWhisperWords(video, dir2, language) {
22061
22061
  if (!has("whisper-cli")) return null;
22062
22062
  const lang = (language ?? "en").toLowerCase().slice(0, 5);
22063
- const r = run("npx", ["--yes", HF, "transcribe", video, "--json", "-d", dir, ...lang === "en" ? [] : ["--language", lang, "-m", "large-v3"]], { cwd: dir, encoding: "utf8", maxBuffer: 1 << 28 });
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
22064
  try {
22065
22065
  const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
22066
22066
  if (r.status !== 0 || !meta2.ok) return null;
22067
- const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(dir, "transcript.json"), "utf8"));
22067
+ const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(dir2, "transcript.json"), "utf8"));
22068
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
22069
  } catch {
22070
22070
  return null;
22071
22071
  }
22072
22072
  }
22073
- async function transcribeVideo(video, dir, language) {
22073
+ async function transcribeVideo(video, dir2, language) {
22074
22074
  try {
22075
- return await cloudWords(video, dir, language);
22075
+ return await cloudWords(video, dir2, language);
22076
22076
  } catch (e) {
22077
- const local2 = localWhisperWords(video, dir, language);
22077
+ const local2 = localWhisperWords(video, dir2, language);
22078
22078
  if (local2) return local2;
22079
22079
  throw e;
22080
22080
  }
22081
22081
  }
22082
22082
 
22083
22083
  // src/community/local/tools.ts
22084
- var VERSION = "0.9.0";
22084
+ var VERSION = "0.10.0";
22085
22085
  var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
22086
22086
  var HOME = () => process.env.CUTROOM_HOME || join6(homedir2(), "Cutroom");
22087
22087
  async function call(op, body = {}, fetchImpl = fetch) {
@@ -22161,9 +22161,9 @@ function beatBounds(duration3, cuts, words) {
22161
22161
  for (const x of all) if (x - out[out.length - 1] >= MIN_BEAT && duration3 - x >= MIN_BEAT / 2) out.push(x);
22162
22162
  return [...out, duration3];
22163
22163
  }
22164
- async function timedWords(file, dir) {
22164
+ async function timedWords(file, dir2) {
22165
22165
  try {
22166
- const words = await transcribeVideo(file, dir);
22166
+ const words = await transcribeVideo(file, dir2);
22167
22167
  return words.length ? words : null;
22168
22168
  } catch {
22169
22169
  return null;
@@ -22188,21 +22188,21 @@ async function breakdownReel(input) {
22188
22188
  });
22189
22189
  meta2 = r.meta;
22190
22190
  transcript = r.transcript;
22191
- const dir2 = join6(studio.references(), `${day2()}-${slug2(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
22192
- mkdirSync5(dir2, { recursive: true });
22193
- file = join6(dir2, "source.mp4");
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
22194
  if (!existsSync4(file)) await download(meta2.videoUrl, file);
22195
22195
  } else {
22196
22196
  file = resolve2(src.replace(/^~/, homedir2()));
22197
22197
  if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
22198
22198
  }
22199
- const dir = /^https?:/i.test(src) ? join6(file, "..") : join6(studio.references(), `${day2()}-${slug2(file.split(/[\\/]/).pop() ?? "video")}`);
22200
- mkdirSync5(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 });
22201
22201
  const m = measure(file);
22202
- const { frames, interval } = sampleFrames(file, join6(dir, "frames"), m.duration, 1, 40);
22202
+ const { frames, interval } = sampleFrames(file, join6(dir2, "frames"), m.duration, 1, 40);
22203
22203
  const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
22204
- const sheet = contactSheet(frames, join6(dir, "sheet.jpg"));
22205
- const timed = await timedWords(file, dir);
22204
+ const sheet = contactSheet(frames, join6(dir2, "sheet.jpg"));
22205
+ const timed = await timedWords(file, dir2);
22206
22206
  const words = timed ?? approxWords(transcript, m.duration);
22207
22207
  const bs = beats(m, words, match.frames);
22208
22208
  if (!transcript && timed) transcript = timed.map((w) => w.w).join(" ");
@@ -22241,9 +22241,9 @@ async function breakdownReel(input) {
22241
22241
  transcript ?? "(none)",
22242
22242
  ""
22243
22243
  ].join("\n");
22244
- writeFileSync5(join6(dir, "breakdown.md"), md);
22245
- const out = { folder: dir, report: join6(dir, "breakdown.md"), sheet, hook, measure: m, look: s, beats: bs, words_timing: timed ? "measured" : transcript ? "approximate" : "none", transcript, meta: meta2, next: "Get the 'breakdown_writeup' method with get_method and follow it." };
22246
- writeFileSync5(join6(dir, "breakdown.json"), JSON.stringify(out, null, 2));
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));
22247
22247
  return out;
22248
22248
  }
22249
22249
  function ctaKeyword(caption) {
@@ -22252,15 +22252,15 @@ function ctaKeyword(caption) {
22252
22252
  }
22253
22253
  async function teardownOne(handle, limit) {
22254
22254
  const { reels } = await call("reels", { handle, limit });
22255
- const dir = join6(studio.references(), `${day2()}-${slug2(handle)}-creator`);
22256
- mkdirSync5(join6(dir, "raw"), { recursive: true });
22255
+ const dir2 = join6(studio.references(), `${day2()}-${slug2(handle)}-creator`);
22256
+ mkdirSync5(join6(dir2, "raw"), { recursive: true });
22257
22257
  const rows = [];
22258
22258
  for (const [i, r] of reels.entries()) {
22259
22259
  try {
22260
- const file = join6(dir, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
22260
+ const file = join6(dir2, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
22261
22261
  if (!existsSync4(file)) await download(r.videoUrl, file);
22262
22262
  const m = measure(file);
22263
- const { frames, interval } = sampleFrames(file, join6(dir, "frames", r.shortCode), m.duration, 2, 20);
22263
+ const { frames, interval } = sampleFrames(file, join6(dir2, "frames", r.shortCode), m.duration, 2, 20);
22264
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 }))]);
22265
22265
  const top = (o) => Object.keys(o)[0] ?? null;
22266
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) });
@@ -22268,7 +22268,7 @@ async function teardownOne(handle, limit) {
22268
22268
  rows.push({ code: r.shortCode, url: r.url, posted: String(r.timestamp ?? "").slice(0, 10), plays: r.videoPlayCount ?? null, error: e.message, effects: [] });
22269
22269
  }
22270
22270
  }
22271
- return { dir, rows };
22271
+ return { dir: dir2, rows };
22272
22272
  }
22273
22273
  function rollup(rows) {
22274
22274
  const ok = rows.filter((r) => !r.error);
@@ -22380,18 +22380,70 @@ var takeRaw2 = (source, folder) => takeRaw(source, folder, studio.inbox());
22380
22380
  var startVideoNotes2 = (folder, transcript) => startVideoNotes(folder, transcript, "I say");
22381
22381
  var { setStatus: setStatus2, addCheck: addCheck2 } = studio_core_exports;
22382
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
+
22383
22435
  // src/community/local/render.ts
22384
22436
  var HF2 = "hyperframes@0.8.99";
22385
22437
  var day3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
22386
22438
  var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
22387
22439
  var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
22388
22440
  var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
22389
- var projectFile = (folder) => join8(folder, ".cutroom", "project.json");
22441
+ var projectFile = (folder) => join9(folder, ".cutroom", "project.json");
22390
22442
  var resolveProject = (p) => {
22391
22443
  const folder = resolve3(p.replace(/^~/, homedir3()));
22392
- const file = [folder, join8(studio.videos(), p), join8(HOME(), "renders", p)].map(projectFile).find((f) => existsSync6(f)) ?? null;
22444
+ const file = [folder, join9(studio.videos(), p), join9(HOME(), "renders", p)].map(projectFile).find((f) => existsSync7(f)) ?? null;
22393
22445
  if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
22394
- return JSON.parse(readFileSync7(file, "utf8"));
22446
+ return JSON.parse(readFileSync8(file, "utf8"));
22395
22447
  };
22396
22448
  function cutAndNormalize(src, out, segments) {
22397
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("");
@@ -22407,19 +22459,19 @@ function looksLikeSpeech(words, duration3) {
22407
22459
  }
22408
22460
  async function prepareFootage(input) {
22409
22461
  const source = resolve3(input.video.replace(/^~/, homedir3()));
22410
- if (!existsSync6(source)) throw new Error(`No such file: ${source}`);
22462
+ if (!existsSync7(source)) throw new Error(`No such file: ${source}`);
22411
22463
  const p = probe(source);
22412
22464
  if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
22413
22465
  if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
22414
22466
  const name = slug3(input.name ?? basename2(source, extname(source)));
22415
22467
  ensureStudio();
22416
- const folder = join8(studio.videos(), `${day3()}-${name}`);
22417
- const work = join8(folder, ".cutroom");
22418
- mkdirSync6(work, { recursive: true });
22468
+ const folder = join9(studio.videos(), `${day3()}-${name}`);
22469
+ const work = join9(folder, ".cutroom");
22470
+ mkdirSync7(work, { recursive: true });
22419
22471
  const clip = takeRaw2(source, folder);
22420
22472
  const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
22421
22473
  if (!segments.length) throw new Error("Couldn't find any speech in that video.");
22422
- const footage = join8(work, "footage.mp4");
22474
+ const footage = join9(work, "footage.mp4");
22423
22475
  cutAndNormalize(clip.file, footage, segments);
22424
22476
  const duration3 = probe(footage).duration;
22425
22477
  const lang = input.language ? input.language.toLowerCase().slice(0, 5) : void 0;
@@ -22432,7 +22484,7 @@ ${e.message}`);
22432
22484
  }
22433
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.`);
22434
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 };
22435
- writeFileSync6(projectFile(folder), JSON.stringify(project, null, 2));
22487
+ writeFileSync7(projectFile(folder), JSON.stringify(project, null, 2));
22436
22488
  startVideoNotes2(folder, words.map((w) => w.w).join(" "));
22437
22489
  setStatus2(folder, "footage ready");
22438
22490
  const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
@@ -22447,15 +22499,15 @@ ${e.message}`);
22447
22499
  };
22448
22500
  }
22449
22501
  async function ensureFonts(work) {
22450
- const cache = join8(HOME(), ".kit", "fonts");
22502
+ const cache = join9(HOME(), ".kit", "fonts");
22451
22503
  const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
22452
- if (!need.every((f) => existsSync6(join8(cache, f)))) {
22504
+ if (!need.every((f) => existsSync7(join9(cache, f)))) {
22453
22505
  const { fonts } = await call("fonts");
22454
- mkdirSync6(cache, { recursive: true });
22455
- for (const [f, b64] of Object.entries(fonts)) writeFileSync6(join8(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"));
22456
22508
  }
22457
- mkdirSync6(join8(work, "fonts"), { recursive: true });
22458
- for (const f of need) writeFileSync6(join8(work, "fonts", f), readFileSync7(join8(cache, f)));
22509
+ mkdirSync7(join9(work, "fonts"), { recursive: true });
22510
+ for (const f of need) writeFileSync7(join9(work, "fonts", f), readFileSync8(join9(cache, f)));
22459
22511
  }
22460
22512
  function applyFixes(words, fixes = []) {
22461
22513
  return words.map((w) => {
@@ -22463,11 +22515,11 @@ function applyFixes(words, fixes = []) {
22463
22515
  return f ? { ...w, w: f.to } : w;
22464
22516
  }).filter((w) => w.w);
22465
22517
  }
22466
- var brandFile = () => join8(studio.brand(), "brand.json");
22518
+ var brandFile = () => join9(studio.brand(), "brand.json");
22467
22519
  function getBrand() {
22468
- for (const f of [brandFile(), join8(HOME(), "brand.json")]) {
22520
+ for (const f of [brandFile(), join9(HOME(), "brand.json")]) {
22469
22521
  try {
22470
- return JSON.parse(readFileSync7(f, "utf8"));
22522
+ return JSON.parse(readFileSync8(f, "utf8"));
22471
22523
  } catch {
22472
22524
  }
22473
22525
  }
@@ -22482,7 +22534,7 @@ async function setBrand(input) {
22482
22534
  const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
22483
22535
  await call("compose", { duration: 2, words: [], brand: next, check_only: true });
22484
22536
  ensureStudio();
22485
- writeFileSync6(brandFile(), JSON.stringify(next, null, 2));
22537
+ writeFileSync7(brandFile(), JSON.stringify(next, null, 2));
22486
22538
  return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
22487
22539
  }
22488
22540
  var PICTURE = /\.(jpe?g|png|webp)$/i;
@@ -22494,17 +22546,17 @@ function safeAssetName(file, i) {
22494
22546
  }
22495
22547
  function localImages(work, layoutImages, effects) {
22496
22548
  const names = /* @__PURE__ */ new Map();
22497
- const dir = join8(work, "assets");
22549
+ const dir2 = join9(work, "assets");
22498
22550
  const swap = (v) => {
22499
22551
  const raw = String(v ?? "").trim();
22500
- if (/^assets\/[\w.-]+$/.test(raw) && existsSync6(join8(work, raw))) return raw;
22552
+ if (/^assets\/[\w.-]+$/.test(raw) && existsSync7(join9(work, raw))) return raw;
22501
22553
  const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir3()));
22502
22554
  if (!names.has(file)) {
22503
- if (!existsSync6(file) || !statSync(file).isFile()) throw new Error(`Can't find the picture ${raw}. Use the full path of a jpg, png or webp file on this computer.`);
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.`);
22504
22556
  if (names.size >= 8) throw new Error("8 pictures at most per reel.");
22505
22557
  const name = safeAssetName(file, names.size);
22506
- mkdirSync6(dir, { recursive: true });
22507
- copyFileSync2(file, join8(dir, name));
22558
+ mkdirSync7(dir2, { recursive: true });
22559
+ copyFileSync2(file, join9(dir2, name));
22508
22560
  names.set(file, `assets/${name}`);
22509
22561
  }
22510
22562
  return names.get(file);
@@ -22541,51 +22593,60 @@ function headTop(work, duration3) {
22541
22593
  return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
22542
22594
  }
22543
22595
  function ensureCutout(work, duration3) {
22544
- const footage = join8(work, "footage.mp4"), cut = join8(work, "cut.webm"), stamp = join8(work, "cut.json"), still = join8(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");
22545
22597
  const st = statSync(footage);
22546
22598
  const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
22547
22599
  let cached2 = null;
22548
22600
  try {
22549
- cached2 = JSON.parse(readFileSync7(stamp, "utf8"));
22601
+ cached2 = JSON.parse(readFileSync8(stamp, "utf8"));
22550
22602
  } catch {
22551
22603
  }
22552
22604
  let made_s = 0;
22553
- if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync6(cut))) {
22605
+ if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync7(cut))) {
22554
22606
  const started = Date.now();
22555
22607
  const r = sh("npx", ["--yes", HF2, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
22556
- if (r.status !== 0 || !existsSync6(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22608
+ if (r.status !== 0 || !existsSync7(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22557
22609
  ${tail(r.stderr) || tail(r.stdout)}`);
22558
22610
  made_s = Math.round((Date.now() - started) / 100) / 10;
22559
22611
  cached2 = { ...key, head: headTop(work, duration3), made_s };
22560
- writeFileSync6(stamp, JSON.stringify(cached2));
22612
+ writeFileSync7(stamp, JSON.stringify(cached2));
22561
22613
  }
22562
- if (!existsSync6(still)) {
22563
- mkdirSync6(join8(work, "assets"), { recursive: true });
22614
+ if (!existsSync7(still)) {
22615
+ mkdirSync7(join9(work, "assets"), { recursive: true });
22564
22616
  sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
22565
22617
  }
22566
22618
  return { head: cached2.head, made_s };
22567
22619
  }
22568
- var planFile = (folder) => join8(folder, ".cutroom", "plan.json");
22620
+ var planFile = (folder) => join9(folder, ".cutroom", "plan.json");
22569
22621
  async function composition(input) {
22570
22622
  const pj = resolveProject(input.project);
22571
- const work = join8(pj.folder, ".cutroom");
22623
+ const work = join9(pj.folder, ".cutroom");
22572
22624
  const words = applyFixes(pj.words, input.fixes);
22573
- const local2 = localImages(work, input.images, input.effects ?? []);
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 };
22574
22632
  const cutout = await wantsCutout(input.layout, local2.effects) ? ensureCutout(work, pj.duration) : null;
22575
- const { html } = await call("compose", { duration: pj.duration, words, layout: input.layout, caption: input.caption, effects: local2.effects, images: local2.images, emphasis: input.emphasis, brand: getBrand() ?? void 0, ...cutout ? { cutout: true, head: cutout.head } : {} });
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 } : {} });
22576
22634
  await ensureFonts(work);
22577
- writeFileSync6(join8(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22578
- 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 };
22579
22639
  }
22580
22640
  var fmtT = (t) => `${t.toFixed(1)}s`;
22581
22641
  var SHOWN = ["word", "text", "title", "number", "label", "eyebrow", "keyword", "items", "lines", "labels", "steps", "message", "before", "after", "note"];
22582
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 ");
22583
22643
  async function planReel(input) {
22584
- const { pj, work, html, cutout, words } = await composition(input);
22585
- 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);
22586
22647
  const peak = (f) => {
22587
- const d = Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5);
22588
- 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));
22589
22650
  return Math.min(pj.duration - 0.1, Number(f.start) + at);
22590
22651
  };
22591
22652
  const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
@@ -22616,39 +22677,42 @@ async function planReel(input) {
22616
22677
  const cur = sentences.filter((x) => x.t <= from).slice(-1);
22617
22678
  return cur.length ? `(\u2026${cur[0].text.join(" ")})` : "";
22618
22679
  };
22619
- 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)));
22620
22681
  const moments = [
22621
- ...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 }] : [],
22622
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 };
22623
22685
  const name = title(styles.effects, f.slug);
22624
- 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 };
22625
22687
  })
22626
22688
  ];
22627
- const shots = join8(work, "storyboard");
22689
+ const shots = join9(work, "storyboard");
22628
22690
  rmSync5(shots, { recursive: true, force: true });
22629
- writeFileSync6(join8(work, "index.html"), html);
22691
+ writeFileSync7(join9(work, "index.html"), html);
22630
22692
  let r;
22631
22693
  try {
22632
22694
  r = sh("npx", ["--yes", HF2, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
22633
22695
  } finally {
22634
- rmSync5(join8(work, "index.html"), { force: true });
22696
+ rmSync5(join9(work, "index.html"), { force: true });
22635
22697
  }
22636
- const pngs = existsSync6(shots) ? readdirSync2(shots).filter((f) => f.endsWith(".png")).sort() : [];
22698
+ const pngs = existsSync7(shots) ? readdirSync2(shots).filter((f) => f.endsWith(".png")).sort() : [];
22637
22699
  if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
22638
22700
  ${tail(r.stderr) || tail(r.stdout)}`);
22639
- const board = join8(pj.folder, "storyboard.jpg");
22701
+ const board = join9(pj.folder, "storyboard.jpg");
22640
22702
  const cols = Math.min(4, moments.length);
22641
22703
  const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
22642
- const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join8(shots, f)]);
22643
- const cells = moments.map((m, k) => `[${k}:v]scale=270:480,pad=270:540:0:0:#0A0A0A,drawtext=text='${label(`${fmtT(m.t)} ${m.tag}`)}':x=10:y=496:fontsize=18:fontcolor=#F7F3E8[c${k}]`).join(";");
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(";");
22644
22708
  const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
22645
22709
  const stack2 = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
22646
22710
  const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack2}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
22647
22711
  if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
22648
22712
  ${tail(t.stderr)}`);
22649
22713
  rmSync5(shots, { recursive: true, force: true });
22650
- const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
22651
- writeFileSync6(planFile(pj.folder), JSON.stringify(plan, null, 2));
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));
22652
22716
  const text = [
22653
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)." : ""}`,
22654
22718
  "",
@@ -22659,27 +22723,29 @@ ${tail(t.stderr)}`);
22659
22723
  `Storyboard: ${board}`
22660
22724
  ].join("\n");
22661
22725
  setStatus2(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
22662
- return { text, storyboard: board, plan, words_used: words.length, next: "Show this table as it is (plain words, no effect codes) and the storyboard. On changes, plan_reel again with the new choice. On a go, render_reel with plan:true and draft:true." };
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.` };
22663
22729
  }
22664
22730
  async function renderReel(input) {
22665
22731
  if (input.plan) {
22666
22732
  const folder = resolveProject(input.project).folder;
22667
- if (!existsSync6(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
22668
- input = { ...JSON.parse(readFileSync7(planFile(folder), "utf8")), project: input.project, draft: input.draft };
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 };
22669
22735
  }
22670
22736
  const { pj, work, html, cutout } = await composition(input);
22671
- mkdirSync6(join8(pj.folder, "renders"), { recursive: true });
22672
- const out = join8(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22673
- const tmp = join8(work, "render.mp4");
22674
- writeFileSync6(join8(work, "index.html"), html);
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);
22675
22741
  const started = Date.now();
22676
22742
  let r;
22677
22743
  try {
22678
22744
  r = sh("npx", ["--yes", HF2, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
22679
22745
  } finally {
22680
- rmSync5(join8(work, "index.html"), { force: true });
22746
+ rmSync5(join9(work, "index.html"), { force: true });
22681
22747
  }
22682
- if (r.status !== 0 || !existsSync6(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22748
+ if (r.status !== 0 || !existsSync7(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22683
22749
  ${tail(r.stderr) || tail(r.stdout)}`);
22684
22750
  renameSync4(tmp, out);
22685
22751
  const render_s = Math.round((Date.now() - started) / 100) / 10;
@@ -22690,10 +22756,10 @@ ${tail(r.stderr) || tail(r.stdout)}`);
22690
22756
  }
22691
22757
  async function qaVideo(input) {
22692
22758
  const file = resolve3(input.video.replace(/^~/, homedir3()));
22693
- if (!existsSync6(file)) throw new Error(`No such file: ${file}`);
22759
+ if (!existsSync7(file)) throw new Error(`No such file: ${file}`);
22694
22760
  const tech = technicalChecks(file);
22695
- const dir = mkdtempSync(join8(tmpdir(), "cutroom-qa-"));
22696
- 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);
22697
22763
  let flags = [];
22698
22764
  let judged = 0, judge_error = null;
22699
22765
  try {
@@ -22703,7 +22769,7 @@ async function qaVideo(input) {
22703
22769
  } catch (e) {
22704
22770
  judge_error = e.message;
22705
22771
  }
22706
- rmSync5(dir, { recursive: true, force: true });
22772
+ rmSync5(dir2, { recursive: true, force: true });
22707
22773
  const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
22708
22774
  const passed = !tech.issues.length && !flags.length && !judge_error;
22709
22775
  const report = {
@@ -22733,18 +22799,24 @@ var MEMBER_CAROUSEL = false;
22733
22799
  var METHODS = {
22734
22800
  hooks: {
22735
22801
  title: "Video hooks",
22736
- text: `Write hooks for a short video (Reel, Short, TikTok) that will be spoken to camera.
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).
22737
22803
 
22738
- 1. Problem in one line: the specific problem and who feels it. If the topic is broad, narrow it with the user first. One problem per run.
22739
- 2. Write 10 to 15 hooks across these angles (1 to 2 each, pick the 5 to 10 that fit):
22740
- shocking number, contrarian belief, personal confession, result transformation (before to after, timed), industry death (old way ending), secret system (tease the mechanism), time compression, cost of inaction, insider leak, story already in motion.
22741
- Every hook carries at least one of: a specific number, a contrarian claim, a timeframe, a personal admission, a curiosity gap.
22742
- 3. Rules: definitive statements (no maybe, might, could). One breath when spoken. Strongest word early. Contractions. No em dashes. No clich\xE9s (game-changer, unlock, level up). Any number must be one the user can prove and must belong to them, not a client, unless they say so.
22743
- 4. Send all hooks to the score_hooks tool. Keep the top 3 by its ranking.
22744
- 5. Read each of the top 3 aloud in your head; rewrite to natural speech.
22745
- 6. Pick the winner, one line on why, and write its second sentence: it deepens the question, moves toward the answer, no self-introduction, no repetition.
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.
22746
22818
 
22747
- Output: top 3 with their angle, the winner, the second sentence, and any number the user must verify before filming.`
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.`
22748
22820
  },
22749
22821
  script: {
22750
22822
  title: "Short script (8 lines)",
@@ -22753,15 +22825,15 @@ Output: top 3 with their angle, the winner, the second sentence, and any number
22753
22825
  1. Thesis: "This video will prove that ___." One video, one idea.
22754
22826
  2. Payoff first: "By the end, viewers understand ___." This is the ending; write it before any hook.
22755
22827
  3. Story spine in 5 sentences: before, problem, cost, discovery, after. Prefer a real story with a number the user can prove.
22756
- 4. Three hooks (problem, contradiction, curiosity). Choose the clearest, not the cleverest. If the user brings a hook, use it.
22757
- 5. The 8 lines: 1 hook (tension), 2 second hook (deepen the question), 3 setup (only needed context), 4 conflict (the cost), 5 turning point, 6 mechanism or proof (proof beats explanation), 7 rehook only for scripts over 20 seconds ("But that wasn't the real problem."), 8 payoff that closes the hook's question. Optional single CTA.
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.
22758
22830
  6. Question test after every line: what question does it create? No question and no useful answer: delete it.
22759
22831
  7. Cut pass: remove repeats, long intros, generic claims, extra CTAs. Aloud pass: most sentences under 12 words, contractions, 70 to 100 words.
22760
22832
  8. Send the script to the score_script tool. Below its pass mark, revise the weakest two lines once and score again.
22761
22833
 
22762
- Rules: definitive statements, no em dashes, name the outcome not the tool, one CTA at most, every number provable and correctly attributed.
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).
22763
22835
 
22764
- Output: thesis, payoff, spine, chosen hook, the numbered script with word count, the score, and numbers to verify before filming.`
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.`
22765
22837
  },
22766
22838
  caption: {
22767
22839
  title: "Post caption and comment CTA",
@@ -22780,15 +22852,16 @@ Output: the caption ready to paste, and the keyword with what gets sent.`
22780
22852
 
22781
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.
22782
22854
  2. From the timed transcript, list words that look misheard (names, brands, numbers) and confirm the spelling. Pass corrections as fixes later.
22783
- 3. Brand: show_brand. If none is set, offer to set one now (it changes every color in the edit); if they skip, use the default look.
22784
- 4. Plan: list_styles (share the gallery link so they can see each style moving), then get_method('edit_plan') and follow it. Show the plan as the plain table it asks for.
22785
- 5. plan_reel with that choice. Show its table and the storyboard image (one real frame per scene). They change anything ("move the checklist to 30s", "swap orbit for card wall", "serif captions"): plan_reel again. No B-roll unless they ask.
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.
22786
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.
22787
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.
22788
- 6. On a go: render_reel with plan:true and draft:true. Tell them where the file is.
22789
- 7. QA: technical issues (frozen picture, loudness, black frames, size) are real; fix them. Look flags are spots to check: grab each flagged frame (ffmpeg) and look at it yourself, then say which are real problems (with the fix) and which are the design.
22790
- 8. They approve the draft: render_reel with plan:true (no draft) for the final. After changes: plan_reel, then render again.
22791
- 9. Offer the post caption next (get_method('caption')).`
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')).`
22792
22865
  },
22793
22866
  edit_plan: {
22794
22867
  title: "Planning the edit scene by scene",
@@ -22812,9 +22885,10 @@ Output: the caption ready to paste, and the keyword with what gets sent.`
22812
22885
  - pointing at something on screen: Freeze and label or Doodle
22813
22886
  - the brand or style: Brand kit card
22814
22887
  - the call to action: Comment to DM (their keyword), Follow tap, or CTA card (their offer and button), as the last scene
22815
- The hook gets the strongest visual (Text behind you, Orbit, Card wall or Pull-back in Full-frame; Paper cut, Frame rip or Cover story in Explainer panel or Split reel).
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.
22816
22889
  4. Do not repeat the same effect in two scenes in a row.
22817
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).
22818
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.
22819
22893
  7. Show the plan as a table, plain words only, never effect codes:
22820
22894
  | Time | What you say | What shows on screen |
@@ -22986,15 +23060,15 @@ var PROMPTS = [
22986
23060
  ].filter((p) => MEMBER_CAROUSEL || p.name !== "copy-carousel");
22987
23061
 
22988
23062
  // src/community/local/carousel.ts
22989
- import { existsSync as existsSync8 } from "node:fs";
23063
+ import { existsSync as existsSync9 } from "node:fs";
22990
23064
  import { homedir as homedir5 } from "node:os";
22991
- import { isAbsolute as isAbsolute3, join as join10, resolve as resolve5 } from "node:path";
23065
+ import { isAbsolute as isAbsolute3, join as join11, resolve as resolve5 } from "node:path";
22992
23066
 
22993
23067
  // src/carousel.ts
22994
23068
  import { execFileSync as execFileSync2, spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
22995
- import { copyFileSync as copyFileSync3, existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync3, readFileSync as readFileSync8, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
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";
22996
23070
  import { homedir as homedir4 } from "node:os";
22997
- import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join9, resolve as resolve4 } from "node:path";
23071
+ import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join10, resolve as resolve4 } from "node:path";
22998
23072
 
22999
23073
  // src/scrapecreators.ts
23000
23074
  import { createWriteStream as createWriteStream2 } from "node:fs";
@@ -23043,18 +23117,18 @@ function paletteFromRgb(px, k = 6) {
23043
23117
  function tileSheet(files, out, cell = 324) {
23044
23118
  if (!files.length) return null;
23045
23119
  const tmp = `${out}.parts`;
23046
- mkdirSync7(tmp, { recursive: true });
23120
+ mkdirSync8(tmp, { recursive: true });
23047
23121
  try {
23048
23122
  const h = Math.round(cell * 1.25 / 2) * 2;
23049
23123
  const parts = files.map((f, i) => {
23050
- const part = join9(tmp, `${String(i).padStart(3, "0")}.jpg`);
23124
+ const part = join10(tmp, `${String(i).padStart(3, "0")}.jpg`);
23051
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`;
23052
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]);
23053
23127
  if (labelled.status !== 0) execFileSync2("ffmpeg", ["-v", "error", "-y", "-i", f, "-vf", fit, part]);
23054
23128
  return part;
23055
23129
  });
23056
- const list = join9(tmp, "list.txt");
23057
- writeFileSync7(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
23130
+ const list = join10(tmp, "list.txt");
23131
+ writeFileSync8(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
23058
23132
  const cols = Math.min(5, parts.length), rows = Math.ceil(parts.length / cols);
23059
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]);
23060
23134
  return out;
@@ -23065,30 +23139,30 @@ function tileSheet(files, out, cell = 324) {
23065
23139
  }
23066
23140
  }
23067
23141
  var IMAGE_EXT = /\.(jpe?g|png|webp|heic)$/i;
23068
- async function saveCarousel(meta2, dir, opts = {}) {
23069
- const slidesDir = join9(dir, "slides");
23070
- mkdirSync7(slidesDir, { recursive: true });
23142
+ async function saveCarousel(meta2, dir2, opts = {}) {
23143
+ const slidesDir = join10(dir2, "slides");
23144
+ mkdirSync8(slidesDir, { recursive: true });
23071
23145
  const files = [];
23072
23146
  if (meta2) {
23073
23147
  for (const [i, img] of meta2.images.entries()) {
23074
- const target = join9(slidesDir, `${String(i + 1).padStart(2, "0")}.jpg`);
23075
- if (!existsSync7(target)) await download2(img.url, target, opts.fetchImpl);
23148
+ const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}.jpg`);
23149
+ if (!existsSync8(target)) await download2(img.url, target, opts.fetchImpl);
23076
23150
  files.push(target);
23077
23151
  }
23078
23152
  } else if (opts.folder) {
23079
23153
  const names = readdirSync3(opts.folder).filter((n) => IMAGE_EXT.test(n)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
23080
23154
  if (!names.length) throw new Error(`No images in ${opts.folder} (jpg, png, webp).`);
23081
23155
  for (const [i, n] of names.entries()) {
23082
- const target = join9(slidesDir, `${String(i + 1).padStart(2, "0")}${extname2(n).toLowerCase()}`);
23083
- copyFileSync3(join9(opts.folder, n), target);
23156
+ const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}${extname2(n).toLowerCase()}`);
23157
+ copyFileSync3(join10(opts.folder, n), target);
23084
23158
  files.push(target);
23085
23159
  }
23086
23160
  }
23087
23161
  const slides = files.map((f, i) => ({ n: i + 1, file: f, size: imageSize(f), video_cover: !!meta2?.images[i]?.video, palette: palette(f) }));
23088
- const sheet = tileSheet(files, join9(dir, "sheet.jpg"));
23089
- if (meta2?.caption) writeFileSync7(join9(dir, "caption.txt"), meta2.caption);
23162
+ const sheet = tileSheet(files, join10(dir2, "sheet.jpg"));
23163
+ if (meta2?.caption) writeFileSync8(join10(dir2, "caption.txt"), meta2.caption);
23090
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 };
23091
- writeFileSync7(join9(dir, "meta.json"), JSON.stringify({ ...info, slides }, null, 2));
23165
+ writeFileSync8(join10(dir2, "meta.json"), JSON.stringify({ ...info, slides }, null, 2));
23092
23166
  const md = [
23093
23167
  "# Carousel breakdown",
23094
23168
  "",
@@ -23105,8 +23179,8 @@ async function saveCarousel(meta2, dir, opts = {}) {
23105
23179
  meta2?.caption ?? "(none)",
23106
23180
  ""
23107
23181
  ].join("\n");
23108
- writeFileSync7(join9(dir, "breakdown.md"), md);
23109
- return { folder: dir, report: join9(dir, "breakdown.md"), sheet, slides: slides.map(({ n, file, size, video_cover, palette: palette2 }) => ({ n, file, size, video_cover, palette: palette2 })), caption: meta2?.caption ?? null, meta: info };
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 };
23110
23184
  }
23111
23185
  function imageSize(file) {
23112
23186
  const r = spawnSync4("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file], { encoding: "utf8" });
@@ -23203,8 +23277,8 @@ function resolveStyle(spec, opts = {}) {
23203
23277
  return mergeStyle({ ...BASE, name: ref }, preset.style);
23204
23278
  }
23205
23279
  const file = isAbsolute2(ref) ? ref : resolve4(opts.baseDir ?? ".", ref.replace(/^~/, homedir4()));
23206
- if (!existsSync7(file)) throw new Error(`No style called ${ref} (presets: ${Object.keys(STYLE_PRESETS).join(", ")}; or a style.json path).`);
23207
- const j = JSON.parse(readFileSync8(file, "utf8"));
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"));
23208
23282
  return j.preset || j.path ? resolveStyle(j, { ...opts, baseDir: dirname2(file) }) : mergeStyle({ ...BASE, name: basename3(file, ".json") }, j);
23209
23283
  };
23210
23284
  if (typeof spec === "string") return load(spec);
@@ -23440,12 +23514,12 @@ function findChrome(env = process.env) {
23440
23514
  "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
23441
23515
  "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
23442
23516
  ].filter(Boolean);
23443
- for (const c of cands) if (existsSync7(c)) return c;
23444
- const pw = join9(homedir4(), "Library", "Caches", "ms-playwright");
23445
- if (existsSync7(pw)) {
23517
+ for (const c of cands) if (existsSync8(c)) return c;
23518
+ const pw = join10(homedir4(), "Library", "Caches", "ms-playwright");
23519
+ if (existsSync8(pw)) {
23446
23520
  for (const d of readdirSync3(pw).filter((n) => /^chromium-\d+$/.test(n)).sort().reverse()) {
23447
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"]) {
23448
- if (existsSync7(join9(pw, d, sub))) return join9(pw, d, sub);
23522
+ if (existsSync8(join10(pw, d, sub))) return join10(pw, d, sub);
23449
23523
  }
23450
23524
  }
23451
23525
  }
@@ -23477,9 +23551,9 @@ function contrast(a, b) {
23477
23551
  return Math.round((x + 0.05) / (y + 0.05) * 100) / 100;
23478
23552
  }
23479
23553
  async function renderDeck(folder, opts = {}) {
23480
- const deckFile = join9(folder, "deck.json");
23481
- if (!existsSync7(deckFile)) throw new Error(`No deck.json in ${folder}.`);
23482
- const deck = validateDeck(JSON.parse(readFileSync8(deckFile, "utf8")));
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")));
23483
23557
  const bin = findChrome();
23484
23558
  if (!bin) throw new Error("Rendering slides needs Google Chrome (or Chromium) on this computer. Install Chrome, or set CHROME_PATH to it.");
23485
23559
  let style = resolveStyle(deck.style, { baseDir: folder, internal: opts.internal });
@@ -23488,24 +23562,24 @@ async function renderDeck(folder, opts = {}) {
23488
23562
  style = applyBrand(style, brand);
23489
23563
  const total = deck.slides.length;
23490
23564
  const targets = (opts.only?.length ? opts.only : deck.slides.map((_, i) => i + 1)).filter((n) => n >= 1 && n <= total);
23491
- const renders = join9(folder, "renders"), html = join9(folder, "html"), history = join9(renders, "history");
23492
- for (const d of [renders, html]) mkdirSync7(d, { recursive: true });
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 });
23493
23567
  const queue = [...targets];
23494
23568
  const results = [];
23495
23569
  const worker = async () => {
23496
23570
  for (let n = queue.shift(); n !== void 0; n = queue.shift()) {
23497
23571
  const name = String(n).padStart(2, "0");
23498
- const page2 = join9(html, `${name}.html`), png = join9(renders, `${name}.png`);
23499
- writeFileSync7(page2, slideHtml(deck.slides[n - 1], style, n, total, { baseDir: folder, handle: deck.handle }));
23500
- if (existsSync7(png)) {
23501
- mkdirSync7(history, { recursive: true });
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 });
23502
23576
  let v = 1;
23503
- while (existsSync7(join9(history, `${name}-v${v}.png`))) v++;
23504
- copyFileSync3(png, join9(history, `${name}-v${v}.png`));
23577
+ while (existsSync8(join10(history, `${name}-v${v}.png`))) v++;
23578
+ copyFileSync3(png, join10(history, `${name}-v${v}.png`));
23505
23579
  }
23506
23580
  const [W, H] = style.size;
23507
23581
  const shot = await chrome(bin, [`--window-size=${W},${H}`, `--screenshot=${png}`, fileUrl(page2)]);
23508
- if (!existsSync7(png) || shot.code !== 0) throw new Error(`Chrome could not render slide ${n}.`);
23582
+ if (!existsSync8(png) || shot.code !== 0) throw new Error(`Chrome could not render slide ${n}.`);
23509
23583
  const dom = await chrome(bin, [`--window-size=${W},${H}`, "--dump-dom", fileUrl(page2)]);
23510
23584
  let qa = {};
23511
23585
  try {
@@ -23519,15 +23593,15 @@ async function renderDeck(folder, opts = {}) {
23519
23593
  ...qa.shrunk >= 25 ? [`text shrunk ${qa.shrunk}% to fit: consider fewer words`] : [],
23520
23594
  ...qa.missing_fonts?.length ? [`font not loaded (fallback used): ${qa.missing_fonts.join(", ")}`] : [],
23521
23595
  ...ratio !== void 0 && ratio < 4.5 ? [`low contrast ${ratio}:1 (text on background)`] : [],
23522
- ...s.image && !/^(https?:|data:)/.test(s.image.src) && !existsSync7(resolve4(folder, s.image.src.replace(/^~/, homedir4()))) ? [`image missing: ${s.image.src}`] : []
23596
+ ...s.image && !/^(https?:|data:)/.test(s.image.src) && !existsSync8(resolve4(folder, s.image.src.replace(/^~/, homedir4()))) ? [`image missing: ${s.image.src}`] : []
23523
23597
  ];
23524
23598
  results.push({ n, png, shrunk: qa.shrunk, overflow: qa.overflow, missing_fonts: qa.missing_fonts, contrast: ratio, flags });
23525
23599
  }
23526
23600
  };
23527
23601
  await Promise.all(Array.from({ length: Math.min(opts.concurrency ?? 4, targets.length) }, worker));
23528
23602
  results.sort((a, b) => a.n - b.n);
23529
- const all = deck.slides.map((_, i) => join9(renders, `${String(i + 1).padStart(2, "0")}.png`)).filter(existsSync7);
23530
- const preview = tileSheet(all, join9(folder, "preview.jpg"), 360);
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);
23531
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." };
23532
23606
  }
23533
23607
  function applyEdits(deck, edits) {
@@ -23547,21 +23621,21 @@ function applyEdits(deck, edits) {
23547
23621
  }
23548
23622
  async function renderCarousel(input, opts = {}) {
23549
23623
  const folder = resolve4(input.folder.replace(/^~/, homedir4()));
23550
- mkdirSync7(folder, { recursive: true });
23551
- const file = join9(folder, "deck.json");
23624
+ mkdirSync8(folder, { recursive: true });
23625
+ const file = join10(folder, "deck.json");
23552
23626
  let only = input.only;
23553
23627
  if (input.deck) {
23554
- writeFileSync7(file, JSON.stringify(validateDeck(input.deck), null, 2));
23628
+ writeFileSync8(file, JSON.stringify(validateDeck(input.deck), null, 2));
23555
23629
  } else {
23556
- if (!existsSync7(file)) throw new Error(`No deck.json in ${folder}: pass deck first.`);
23557
- let deck = validateDeck(JSON.parse(readFileSync8(file, "utf8")));
23630
+ if (!existsSync8(file)) throw new Error(`No deck.json in ${folder}: pass deck first.`);
23631
+ let deck = validateDeck(JSON.parse(readFileSync9(file, "utf8")));
23558
23632
  if (input.style !== void 0) deck.style = input.style;
23559
23633
  if (input.edits?.length) {
23560
23634
  const r = applyEdits(deck, input.edits);
23561
23635
  deck = r.deck;
23562
23636
  if (!only?.length && input.style === void 0) only = r.touched;
23563
23637
  }
23564
- writeFileSync7(file, JSON.stringify(deck, null, 2));
23638
+ writeFileSync8(file, JSON.stringify(deck, null, 2));
23565
23639
  }
23566
23640
  return renderDeck(folder, { only, internal: opts.internal, brand: opts.brand });
23567
23641
  }
@@ -23571,28 +23645,28 @@ var home = (p) => resolve5(p.replace(/^~/, homedir5()));
23571
23645
  async function breakdownCarousel(input) {
23572
23646
  ensureStudio();
23573
23647
  const src = input.source.trim();
23574
- const base = join10(studio.references(), "carousels");
23648
+ const base = join11(studio.references(), "carousels");
23575
23649
  const next = "Open sheet.jpg and every slide, then follow get_method('copy_carousel').";
23576
23650
  if (/^https?:\/\//i.test(src)) {
23577
23651
  const { meta: meta2 } = await call("carousel", { url: src }).catch((e) => {
23578
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.`);
23579
23653
  });
23580
- const dir = join10(base, `${day()}-${slug(meta2.owner ?? meta2.platform)}-${meta2.code ?? "post"}`);
23581
- return { ...await saveCarousel(meta2, dir), next };
23654
+ const dir2 = join11(base, `${day()}-${slug(meta2.owner ?? meta2.platform)}-${meta2.code ?? "post"}`);
23655
+ return { ...await saveCarousel(meta2, dir2), next };
23582
23656
  }
23583
23657
  const folder = home(src);
23584
- if (!existsSync8(folder)) throw new Error(`No such folder: ${folder}`);
23585
- return { ...await saveCarousel(null, join10(base, `${day()}-${slug(folder.split(/[\\/]/).pop() ?? "carousel")}`), { folder }), next };
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 };
23586
23660
  }
23587
23661
  function renderCarouselLocal(input) {
23588
23662
  ensureStudio();
23589
- const root = join10(HOME(), "carousels");
23590
- const folder = input.folder ? isAbsolute3(input.folder) || input.folder.startsWith("~") ? home(input.folder) : join10(root, input.folder) : join10(root, `${day()}-${slug(input.title ?? input.deck?.title ?? "carousel")}`);
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")}`);
23591
23665
  return renderCarousel({ folder, deck: input.deck, edits: input.edits, style: input.style, only: input.only }, { brand: (ref) => ref === "mine" ? brandFromJson(getBrand()) : null });
23592
23666
  }
23593
23667
 
23594
23668
  // src/community/local/server.ts
23595
- process.env.PATH = (WIN ? [process.env.PATH, join11(process.env.LOCALAPPDATA ?? join11(homedir6(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join11(homedir6(), ".local", "bin")]).filter(Boolean).join(delimiter2);
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);
23596
23670
  useTools();
23597
23671
  var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
23598
23672
  var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
@@ -23651,7 +23725,7 @@ await serveStdio(() => {
23651
23725
  ${m.text}`;
23652
23726
  }));
23653
23727
  server.registerTool("score_hooks", {
23654
- 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.",
23655
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'.") })
23656
23730
  }, async ({ hooks, audience }) => safe(() => call("score", { kind: "hook", texts: hooks, audience })));
23657
23731
  server.registerTool("score_script", {
@@ -23713,12 +23787,13 @@ ${m.text}`;
23713
23787
  })
23714
23788
  }, async (input) => local(() => prepareFootage(input)));
23715
23789
  server.registerTool("plan_reel", {
23716
- description: "Step 2: the plan before anything renders. Takes the same choice as render_reel (layout, caption, effects with times, images) and returns the plan as lines plus a storyboard image: one real frame of the user's video per moment, with the effect on it, in their brand. About 10 to 20 seconds (plus the one-time cut-out if the choice needs it). Saves the plan (plan.md in the video's folder, status plan ready) so render_reel with plan:true renders exactly this. Show the storyboard and get a go before rendering.",
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.",
23717
23791
  inputSchema: object({
23718
23792
  project: string2().min(1).describe("The project folder returned by prepare_footage."),
23719
23793
  layout: string2().default("full-frame"),
23720
23794
  caption: string2().default("two-tier"),
23721
- 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)."),
23722
23797
  images: array(string2()).max(8).optional().describe("Pictures for the layout, full paths on this computer."),
23723
23798
  emphasis: array(string2()).optional(),
23724
23799
  fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word.")
@@ -23729,7 +23804,7 @@ ${m.text}`;
23729
23804
  const r = await planReel(input);
23730
23805
  return { content: [...note ? [{ type: "text", text: note }] : [], { type: "text", text: `${r.text}
23731
23806
 
23732
- ${r.next}` }, { type: "image", data: readFileSync9(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
23807
+ ${r.next}` }, { type: "image", data: readFileSync10(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
23733
23808
  } catch (e) {
23734
23809
  return failure2(e);
23735
23810
  }
@@ -23740,10 +23815,11 @@ ${r.next}` }, { type: "image", data: readFileSync9(r.storyboard).toString("base6
23740
23815
  project: string2().min(1).describe("The project folder returned by prepare_footage."),
23741
23816
  layout: string2().default("full-frame"),
23742
23817
  caption: string2().default("two-tier"),
23743
- 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."),
23744
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."),
23745
23820
  emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
23746
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)."),
23747
23823
  draft: boolean2().default(false),
23748
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).")
23749
23825
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepylabs/cutroom",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Cutroom for Claude Code: hooks and scripts with scoring, reel breakdowns, creator teardowns, and finishing your own videos with QA. Sign in with your Keepy Labs membership on Whop.",
5
5
  "type": "module",
6
6
  "bin": {