@keepylabs/cutroom 0.9.0 → 0.10.1

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 +278 -192
  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 readdirSync3, readFileSync as readFileSync8, renameSync as renameSync4, rmSync as rmSync5, statSync as statSync2, 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.1";
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,80 @@ 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, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync, 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
+ var near = (a, b) => Math.abs(a - b) < 0.05;
22398
+ function savedFor(work, s, dur, layout2) {
22399
+ if (!existsSync6(dir(work))) return null;
22400
+ return readdirSync2(dir(work)).filter((f) => f.endsWith(".json")).map((f) => ({ f, at: statSync(join8(dir(work), f)).mtimeMs })).sort((a, b) => b.at - a.at).map(({ f }) => cardOf(work, f.replace(/\.json$/, ""))).find((c) => !!c && near(c.start, s.start) && near(c.dur, dur) && same(c.request, s.request) && c.layout === layout2 && existsSync6(join8(dir(work), `${c.id}.html`))) ?? null;
22401
+ }
22402
+ function previousAt(work, start) {
22403
+ if (!existsSync6(dir(work))) return null;
22404
+ return readdirSync2(dir(work)).filter((f) => f.endsWith(".json")).map((f) => ({ f, at: statSync(join8(dir(work), f)).mtimeMs })).sort((a, b) => b.at - a.at).map(({ f }) => cardOf(work, f.replace(/\.json$/, ""))).find((c) => !!c && near(c.start, start)) ?? null;
22405
+ }
22406
+ async function waitScene(id, wait = (ms) => new Promise((r) => setTimeout(r, ms)), deadline = Date.now() + 7 * 6e4) {
22407
+ while (Date.now() < deadline) {
22408
+ await wait(8e3);
22409
+ const r = await call("scene_status", { id });
22410
+ if (r.status === "done" && r.scene) return r.scene;
22411
+ if (r.status === "failed") throw new Error(r.error ?? "The scene writer couldn't make this scene.");
22412
+ }
22413
+ throw new Error("The scene writer took too long. Try plan_reel again in a minute.");
22414
+ }
22415
+ async function ensureCustom(work, slots, ctx) {
22416
+ mkdirSync6(dir(work), { recursive: true });
22417
+ let made = 0;
22418
+ const out = await Promise.all(slots.map(async (s) => {
22419
+ const dur = customDur(s);
22420
+ const given = s.scene ? cardOf(work, s.scene) : null;
22421
+ const have = given && existsSync6(join8(dir(work), `${given.id}.html`)) && near(given.start, s.start) && near(given.dur, dur) && same(given.request, s.request) && given.layout === ctx.layout ? given : s.redo ? null : savedFor(work, s, dur, ctx.layout);
22422
+ if (have && !s.redo) return { ...s, dur, scene: have.id, title: have.title, shows: have.shows, redo: void 0 };
22423
+ const before = given ?? previousAt(work, s.start);
22424
+ const { id } = await call("scene_start", {
22425
+ start: s.start,
22426
+ dur,
22427
+ words: ctx.words,
22428
+ layout: ctx.layout,
22429
+ brand: ctx.brand ?? void 0,
22430
+ about: ctx.about,
22431
+ request: s.request,
22432
+ job: s.job,
22433
+ ...before ? { previous: { shows: before.shows } } : {}
22434
+ });
22435
+ const scene = await waitScene(id, ctx.wait);
22436
+ writeFileSync6(join8(dir(work), `${id}.html`), scene.html);
22437
+ const card = { id, start: s.start, dur, request: s.request, job: scene.job, title: scene.title, shows: scene.shows, layout: ctx.layout };
22438
+ writeFileSync6(join8(dir(work), `${id}.json`), JSON.stringify(card, null, 2));
22439
+ made++;
22440
+ return { ...s, dur, scene: id, title: scene.title, shows: scene.shows, redo: void 0 };
22441
+ }));
22442
+ return { slots: out, made };
22443
+ }
22444
+
22383
22445
  // src/community/local/render.ts
22384
22446
  var HF2 = "hyperframes@0.8.99";
22385
22447
  var day3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
22386
22448
  var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
22387
22449
  var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
22388
22450
  var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
22389
- var projectFile = (folder) => join8(folder, ".cutroom", "project.json");
22451
+ var projectFile = (folder) => join9(folder, ".cutroom", "project.json");
22390
22452
  var resolveProject = (p) => {
22391
22453
  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;
22454
+ const file = [folder, join9(studio.videos(), p), join9(HOME(), "renders", p)].map(projectFile).find((f) => existsSync7(f)) ?? null;
22393
22455
  if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
22394
- return JSON.parse(readFileSync7(file, "utf8"));
22456
+ return JSON.parse(readFileSync8(file, "utf8"));
22395
22457
  };
22396
22458
  function cutAndNormalize(src, out, segments) {
22397
22459
  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 +22469,19 @@ function looksLikeSpeech(words, duration3) {
22407
22469
  }
22408
22470
  async function prepareFootage(input) {
22409
22471
  const source = resolve3(input.video.replace(/^~/, homedir3()));
22410
- if (!existsSync6(source)) throw new Error(`No such file: ${source}`);
22472
+ if (!existsSync7(source)) throw new Error(`No such file: ${source}`);
22411
22473
  const p = probe(source);
22412
22474
  if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
22413
22475
  if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
22414
22476
  const name = slug3(input.name ?? basename2(source, extname(source)));
22415
22477
  ensureStudio();
22416
- const folder = join8(studio.videos(), `${day3()}-${name}`);
22417
- const work = join8(folder, ".cutroom");
22418
- mkdirSync6(work, { recursive: true });
22478
+ const folder = join9(studio.videos(), `${day3()}-${name}`);
22479
+ const work = join9(folder, ".cutroom");
22480
+ mkdirSync7(work, { recursive: true });
22419
22481
  const clip = takeRaw2(source, folder);
22420
22482
  const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
22421
22483
  if (!segments.length) throw new Error("Couldn't find any speech in that video.");
22422
- const footage = join8(work, "footage.mp4");
22484
+ const footage = join9(work, "footage.mp4");
22423
22485
  cutAndNormalize(clip.file, footage, segments);
22424
22486
  const duration3 = probe(footage).duration;
22425
22487
  const lang = input.language ? input.language.toLowerCase().slice(0, 5) : void 0;
@@ -22432,7 +22494,7 @@ ${e.message}`);
22432
22494
  }
22433
22495
  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
22496
  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));
22497
+ writeFileSync7(projectFile(folder), JSON.stringify(project, null, 2));
22436
22498
  startVideoNotes2(folder, words.map((w) => w.w).join(" "));
22437
22499
  setStatus2(folder, "footage ready");
22438
22500
  const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
@@ -22447,15 +22509,15 @@ ${e.message}`);
22447
22509
  };
22448
22510
  }
22449
22511
  async function ensureFonts(work) {
22450
- const cache = join8(HOME(), ".kit", "fonts");
22512
+ const cache = join9(HOME(), ".kit", "fonts");
22451
22513
  const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
22452
- if (!need.every((f) => existsSync6(join8(cache, f)))) {
22514
+ if (!need.every((f) => existsSync7(join9(cache, f)))) {
22453
22515
  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"));
22516
+ mkdirSync7(cache, { recursive: true });
22517
+ for (const [f, b64] of Object.entries(fonts)) writeFileSync7(join9(cache, basename2(f)), Buffer.from(b64, "base64"));
22456
22518
  }
22457
- mkdirSync6(join8(work, "fonts"), { recursive: true });
22458
- for (const f of need) writeFileSync6(join8(work, "fonts", f), readFileSync7(join8(cache, f)));
22519
+ mkdirSync7(join9(work, "fonts"), { recursive: true });
22520
+ for (const f of need) writeFileSync7(join9(work, "fonts", f), readFileSync8(join9(cache, f)));
22459
22521
  }
22460
22522
  function applyFixes(words, fixes = []) {
22461
22523
  return words.map((w) => {
@@ -22463,11 +22525,11 @@ function applyFixes(words, fixes = []) {
22463
22525
  return f ? { ...w, w: f.to } : w;
22464
22526
  }).filter((w) => w.w);
22465
22527
  }
22466
- var brandFile = () => join8(studio.brand(), "brand.json");
22528
+ var brandFile = () => join9(studio.brand(), "brand.json");
22467
22529
  function getBrand() {
22468
- for (const f of [brandFile(), join8(HOME(), "brand.json")]) {
22530
+ for (const f of [brandFile(), join9(HOME(), "brand.json")]) {
22469
22531
  try {
22470
- return JSON.parse(readFileSync7(f, "utf8"));
22532
+ return JSON.parse(readFileSync8(f, "utf8"));
22471
22533
  } catch {
22472
22534
  }
22473
22535
  }
@@ -22482,7 +22544,7 @@ async function setBrand(input) {
22482
22544
  const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
22483
22545
  await call("compose", { duration: 2, words: [], brand: next, check_only: true });
22484
22546
  ensureStudio();
22485
- writeFileSync6(brandFile(), JSON.stringify(next, null, 2));
22547
+ writeFileSync7(brandFile(), JSON.stringify(next, null, 2));
22486
22548
  return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
22487
22549
  }
22488
22550
  var PICTURE = /\.(jpe?g|png|webp)$/i;
@@ -22494,17 +22556,17 @@ function safeAssetName(file, i) {
22494
22556
  }
22495
22557
  function localImages(work, layoutImages, effects) {
22496
22558
  const names = /* @__PURE__ */ new Map();
22497
- const dir = join8(work, "assets");
22559
+ const dir2 = join9(work, "assets");
22498
22560
  const swap = (v) => {
22499
22561
  const raw = String(v ?? "").trim();
22500
- if (/^assets\/[\w.-]+$/.test(raw) && existsSync6(join8(work, raw))) return raw;
22562
+ if (/^assets\/[\w.-]+$/.test(raw) && existsSync7(join9(work, raw))) return raw;
22501
22563
  const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir3()));
22502
22564
  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.`);
22565
+ if (!existsSync7(file) || !statSync2(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
22566
  if (names.size >= 8) throw new Error("8 pictures at most per reel.");
22505
22567
  const name = safeAssetName(file, names.size);
22506
- mkdirSync6(dir, { recursive: true });
22507
- copyFileSync2(file, join8(dir, name));
22568
+ mkdirSync7(dir2, { recursive: true });
22569
+ copyFileSync2(file, join9(dir2, name));
22508
22570
  names.set(file, `assets/${name}`);
22509
22571
  }
22510
22572
  return names.get(file);
@@ -22541,51 +22603,60 @@ function headTop(work, duration3) {
22541
22603
  return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
22542
22604
  }
22543
22605
  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");
22545
- const st = statSync(footage);
22606
+ const footage = join9(work, "footage.mp4"), cut = join9(work, "cut.webm"), stamp = join9(work, "cut.json"), still = join9(work, "assets", "still.jpg");
22607
+ const st = statSync2(footage);
22546
22608
  const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
22547
22609
  let cached2 = null;
22548
22610
  try {
22549
- cached2 = JSON.parse(readFileSync7(stamp, "utf8"));
22611
+ cached2 = JSON.parse(readFileSync8(stamp, "utf8"));
22550
22612
  } catch {
22551
22613
  }
22552
22614
  let made_s = 0;
22553
- if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync6(cut))) {
22615
+ if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync7(cut))) {
22554
22616
  const started = Date.now();
22555
22617
  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.
22618
+ if (r.status !== 0 || !existsSync7(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22557
22619
  ${tail(r.stderr) || tail(r.stdout)}`);
22558
22620
  made_s = Math.round((Date.now() - started) / 100) / 10;
22559
22621
  cached2 = { ...key, head: headTop(work, duration3), made_s };
22560
- writeFileSync6(stamp, JSON.stringify(cached2));
22622
+ writeFileSync7(stamp, JSON.stringify(cached2));
22561
22623
  }
22562
- if (!existsSync6(still)) {
22563
- mkdirSync6(join8(work, "assets"), { recursive: true });
22624
+ if (!existsSync7(still)) {
22625
+ mkdirSync7(join9(work, "assets"), { recursive: true });
22564
22626
  sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
22565
22627
  }
22566
22628
  return { head: cached2.head, made_s };
22567
22629
  }
22568
- var planFile = (folder) => join8(folder, ".cutroom", "plan.json");
22630
+ var planFile = (folder) => join9(folder, ".cutroom", "plan.json");
22569
22631
  async function composition(input) {
22570
22632
  const pj = resolveProject(input.project);
22571
- const work = join8(pj.folder, ".cutroom");
22633
+ const work = join9(pj.folder, ".cutroom");
22572
22634
  const words = applyFixes(pj.words, input.fixes);
22573
- const local2 = localImages(work, input.images, input.effects ?? []);
22635
+ const all = input.effects ?? [];
22636
+ const local2 = localImages(work, input.images, all.filter((f) => !isCustom(f)));
22637
+ const brand = getBrand();
22638
+ 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 };
22639
+ const slots = all.filter(isCustom);
22640
+ 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 });
22641
+ const custom = slots.length ? await ensureCustom(work, slots, { words, layout: input.layout ?? "full-frame", brand, about: input.about }) : { slots: [], made: 0 };
22574
22642
  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 } : {} });
22643
+ 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
22644
  await ensureFonts(work);
22577
- writeFileSync6(join8(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22578
- return { pj, work, html, cutout, words };
22645
+ writeFileSync7(join9(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22646
+ let k = 0;
22647
+ const effects = all.map((f) => isCustom(f) ? custom.slots[k++] : f);
22648
+ return { pj, work, html, cutout, words, effects, made: custom.made };
22579
22649
  }
22580
22650
  var fmtT = (t) => `${t.toFixed(1)}s`;
22581
22651
  var SHOWN = ["word", "text", "title", "number", "label", "eyebrow", "keyword", "items", "lines", "labels", "steps", "message", "before", "after", "note"];
22582
22652
  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
22653
  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));
22654
+ const { pj, work, html, cutout, words, effects: filled, made } = await composition(input);
22655
+ const effects = [...filled].sort((a, b) => Number(a.start) - Number(b.start));
22656
+ const durOf = (f) => isCustom(f) ? customDur(f) : Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5);
22586
22657
  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));
22658
+ const d = durOf(f);
22659
+ 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
22660
  return Math.min(pj.duration - 0.1, Number(f.start) + at);
22590
22661
  };
22591
22662
  const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
@@ -22616,39 +22687,42 @@ async function planReel(input) {
22616
22687
  const cur = sentences.filter((x) => x.t <= from).slice(-1);
22617
22688
  return cur.length ? `(\u2026${cur[0].text.join(" ")})` : "";
22618
22689
  };
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)));
22690
+ const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) + durOf(f)));
22620
22691
  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 }] : [],
22692
+ ...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
22693
  ...effects.map((f, k) => {
22694
+ 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
22695
  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) };
22696
+ return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f), custom: false };
22625
22697
  })
22626
22698
  ];
22627
- const shots = join8(work, "storyboard");
22699
+ const shots = join9(work, "storyboard");
22628
22700
  rmSync5(shots, { recursive: true, force: true });
22629
- writeFileSync6(join8(work, "index.html"), html);
22701
+ writeFileSync7(join9(work, "index.html"), html);
22630
22702
  let r;
22631
22703
  try {
22632
22704
  r = sh("npx", ["--yes", HF2, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
22633
22705
  } finally {
22634
- rmSync5(join8(work, "index.html"), { force: true });
22706
+ rmSync5(join9(work, "index.html"), { force: true });
22635
22707
  }
22636
- const pngs = existsSync6(shots) ? readdirSync2(shots).filter((f) => f.endsWith(".png")).sort() : [];
22708
+ const pngs = existsSync7(shots) ? readdirSync3(shots).filter((f) => f.endsWith(".png")).sort() : [];
22637
22709
  if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
22638
22710
  ${tail(r.stderr) || tail(r.stdout)}`);
22639
- const board = join8(pj.folder, "storyboard.jpg");
22711
+ const board = join9(pj.folder, "storyboard.jpg");
22640
22712
  const cols = Math.min(4, moments.length);
22641
22713
  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(";");
22714
+ const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join9(shots, f)]);
22715
+ const mark = getBrand()?.highlight?.replace(/^#?/, "#") ?? "#8B8EF5";
22716
+ 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`;
22717
+ 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
22718
  const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
22645
22719
  const stack2 = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
22646
22720
  const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack2}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
22647
22721
  if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
22648
22722
  ${tail(t.stderr)}`);
22649
22723
  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));
22724
+ 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 };
22725
+ writeFileSync7(planFile(pj.folder), JSON.stringify(plan, null, 2));
22652
22726
  const text = [
22653
22727
  `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
22728
  "",
@@ -22659,27 +22733,29 @@ ${tail(t.stderr)}`);
22659
22733
  `Storyboard: ${board}`
22660
22734
  ].join("\n");
22661
22735
  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." };
22736
+ const nCustom = effects.filter(isCustom).length;
22737
+ 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, give that slot a request (e.g. request:'a phone with the feed') or redo:true for a fresh take; to go back to a menu effect, put that effect in the slot instead.` : "";
22738
+ 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 whole choice; a custom slot with the same time, length and request reuses its scene (no new one is written), so only change the slots they asked to change. On a go, render_reel with plan:true and draft:true.` };
22663
22739
  }
22664
22740
  async function renderReel(input) {
22665
22741
  if (input.plan) {
22666
22742
  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 };
22743
+ if (!existsSync7(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
22744
+ input = { ...JSON.parse(readFileSync8(planFile(folder), "utf8")), project: input.project, draft: input.draft };
22669
22745
  }
22670
22746
  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);
22747
+ mkdirSync7(join9(pj.folder, "renders"), { recursive: true });
22748
+ const out = join9(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22749
+ const tmp = join9(work, "render.mp4");
22750
+ writeFileSync7(join9(work, "index.html"), html);
22675
22751
  const started = Date.now();
22676
22752
  let r;
22677
22753
  try {
22678
22754
  r = sh("npx", ["--yes", HF2, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
22679
22755
  } finally {
22680
- rmSync5(join8(work, "index.html"), { force: true });
22756
+ rmSync5(join9(work, "index.html"), { force: true });
22681
22757
  }
22682
- if (r.status !== 0 || !existsSync6(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22758
+ if (r.status !== 0 || !existsSync7(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22683
22759
  ${tail(r.stderr) || tail(r.stdout)}`);
22684
22760
  renameSync4(tmp, out);
22685
22761
  const render_s = Math.round((Date.now() - started) / 100) / 10;
@@ -22690,10 +22766,10 @@ ${tail(r.stderr) || tail(r.stdout)}`);
22690
22766
  }
22691
22767
  async function qaVideo(input) {
22692
22768
  const file = resolve3(input.video.replace(/^~/, homedir3()));
22693
- if (!existsSync6(file)) throw new Error(`No such file: ${file}`);
22769
+ if (!existsSync7(file)) throw new Error(`No such file: ${file}`);
22694
22770
  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);
22771
+ const dir2 = mkdtempSync(join9(tmpdir(), "cutroom-qa-"));
22772
+ const { frames } = sampleFrames(file, dir2, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
22697
22773
  let flags = [];
22698
22774
  let judged = 0, judge_error = null;
22699
22775
  try {
@@ -22703,7 +22779,7 @@ async function qaVideo(input) {
22703
22779
  } catch (e) {
22704
22780
  judge_error = e.message;
22705
22781
  }
22706
- rmSync5(dir, { recursive: true, force: true });
22782
+ rmSync5(dir2, { recursive: true, force: true });
22707
22783
  const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
22708
22784
  const passed = !tech.issues.length && !flags.length && !judge_error;
22709
22785
  const report = {
@@ -22733,18 +22809,24 @@ var MEMBER_CAROUSEL = false;
22733
22809
  var METHODS = {
22734
22810
  hooks: {
22735
22811
  title: "Video hooks",
22736
- text: `Write hooks for a short video (Reel, Short, TikTok) that will be spoken to camera.
22812
+ 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
22813
 
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.
22814
+ 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.
22815
+ 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.
22816
+ 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.
22817
+ 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).
22818
+ Angles: shocking number, contrarian belief, personal confession, result transformation, industry death, secret system, time compression, cost of inaction, insider leak, story in motion.
22819
+ 4. Rules:
22820
+ - Topic obvious in line one, under 15 words. Pause there: could a friend say what the video is about?
22821
+ - About the viewer (their problem, want or belief), not the user, their tool, team or routine. The user's story comes later, as proof.
22822
+ - Line two is proof: results for others beat their own results beat effort.
22823
+ - 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].
22824
+ - Definitive, one breath, contractions, no em dashes, no clich\xE9s, no view promises.
22825
+ 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.
22826
+ 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?
22827
+ 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
22828
 
22747
- Output: top 3 with their angle, the winner, the second sentence, and any number the user must verify before filming.`
22829
+ 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
22830
  },
22749
22831
  script: {
22750
22832
  title: "Short script (8 lines)",
@@ -22753,15 +22835,15 @@ Output: top 3 with their angle, the winner, the second sentence, and any number
22753
22835
  1. Thesis: "This video will prove that ___." One video, one idea.
22754
22836
  2. Payoff first: "By the end, viewers understand ___." This is the ending; write it before any hook.
22755
22837
  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.
22838
+ 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.
22839
+ 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
22840
  6. Question test after every line: what question does it create? No question and no useful answer: delete it.
22759
22841
  7. Cut pass: remove repeats, long intros, generic claims, extra CTAs. Aloud pass: most sentences under 12 words, contractions, 70 to 100 words.
22760
22842
  8. Send the script to the score_script tool. Below its pass mark, revise the weakest two lines once and score again.
22761
22843
 
22762
- Rules: definitive statements, no em dashes, name the outcome not the tool, one CTA at most, every number provable and correctly attributed.
22844
+ 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
22845
 
22764
- Output: thesis, payoff, spine, chosen hook, the numbered script with word count, the score, and numbers to verify before filming.`
22846
+ 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
22847
  },
22766
22848
  caption: {
22767
22849
  title: "Post caption and comment CTA",
@@ -22780,15 +22862,16 @@ Output: the caption ready to paste, and the keyword with what gets sent.`
22780
22862
 
22781
22863
  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
22864
  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.
22865
+ 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.
22866
+ 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.
22867
+ 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.
22868
+ 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
22869
  - 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
22870
  - 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')).`
22871
+ 7. On a go: render_reel with plan:true and draft:true. Tell them where the file is.
22872
+ 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.
22873
+ 9. They approve the draft: render_reel with plan:true (no draft) for the final. After changes: plan_reel, then render again.
22874
+ 10. Offer the post caption next (get_method('caption')).`
22792
22875
  },
22793
22876
  edit_plan: {
22794
22877
  title: "Planning the edit scene by scene",
@@ -22812,9 +22895,10 @@ Output: the caption ready to paste, and the keyword with what gets sent.`
22812
22895
  - pointing at something on screen: Freeze and label or Doodle
22813
22896
  - the brand or style: Brand kit card
22814
22897
  - 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).
22898
+ 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
22899
  4. Do not repeat the same effect in two scenes in a row.
22817
22900
  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.
22901
+ 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
22902
  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
22903
  7. Show the plan as a table, plain words only, never effect codes:
22820
22904
  | Time | What you say | What shows on screen |
@@ -22986,15 +23070,15 @@ var PROMPTS = [
22986
23070
  ].filter((p) => MEMBER_CAROUSEL || p.name !== "copy-carousel");
22987
23071
 
22988
23072
  // src/community/local/carousel.ts
22989
- import { existsSync as existsSync8 } from "node:fs";
23073
+ import { existsSync as existsSync9 } from "node:fs";
22990
23074
  import { homedir as homedir5 } from "node:os";
22991
- import { isAbsolute as isAbsolute3, join as join10, resolve as resolve5 } from "node:path";
23075
+ import { isAbsolute as isAbsolute3, join as join11, resolve as resolve5 } from "node:path";
22992
23076
 
22993
23077
  // src/carousel.ts
22994
23078
  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";
23079
+ import { copyFileSync as copyFileSync3, existsSync as existsSync8, mkdirSync as mkdirSync8, readdirSync as readdirSync4, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
22996
23080
  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";
23081
+ import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join10, resolve as resolve4 } from "node:path";
22998
23082
 
22999
23083
  // src/scrapecreators.ts
23000
23084
  import { createWriteStream as createWriteStream2 } from "node:fs";
@@ -23028,13 +23112,13 @@ function paletteFromRgb(px, k = 6) {
23028
23112
  const merged = [];
23029
23113
  for (const b of [...buckets.values()].sort((x, y) => y.n - x.n)) {
23030
23114
  const c = { n: b.n, r: b.r / b.n, g: b.g / b.n, b: b.b / b.n };
23031
- const near = merged.find((m) => Math.hypot(m.r - c.r, m.g - c.g, m.b - c.b) < 48);
23032
- if (near) {
23033
- const n = near.n + c.n;
23034
- near.r = (near.r * near.n + c.r * c.n) / n;
23035
- near.g = (near.g * near.n + c.g * c.n) / n;
23036
- near.b = (near.b * near.n + c.b * c.n) / n;
23037
- near.n = n;
23115
+ const near2 = merged.find((m) => Math.hypot(m.r - c.r, m.g - c.g, m.b - c.b) < 48);
23116
+ if (near2) {
23117
+ const n = near2.n + c.n;
23118
+ near2.r = (near2.r * near2.n + c.r * c.n) / n;
23119
+ near2.g = (near2.g * near2.n + c.g * c.n) / n;
23120
+ near2.b = (near2.b * near2.n + c.b * c.n) / n;
23121
+ near2.n = n;
23038
23122
  } else merged.push(c);
23039
23123
  }
23040
23124
  const hex = (v) => Math.round(v).toString(16).padStart(2, "0");
@@ -23043,18 +23127,18 @@ function paletteFromRgb(px, k = 6) {
23043
23127
  function tileSheet(files, out, cell = 324) {
23044
23128
  if (!files.length) return null;
23045
23129
  const tmp = `${out}.parts`;
23046
- mkdirSync7(tmp, { recursive: true });
23130
+ mkdirSync8(tmp, { recursive: true });
23047
23131
  try {
23048
23132
  const h = Math.round(cell * 1.25 / 2) * 2;
23049
23133
  const parts = files.map((f, i) => {
23050
- const part = join9(tmp, `${String(i).padStart(3, "0")}.jpg`);
23134
+ const part = join10(tmp, `${String(i).padStart(3, "0")}.jpg`);
23051
23135
  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
23136
  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
23137
  if (labelled.status !== 0) execFileSync2("ffmpeg", ["-v", "error", "-y", "-i", f, "-vf", fit, part]);
23054
23138
  return part;
23055
23139
  });
23056
- const list = join9(tmp, "list.txt");
23057
- writeFileSync7(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
23140
+ const list = join10(tmp, "list.txt");
23141
+ writeFileSync8(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
23058
23142
  const cols = Math.min(5, parts.length), rows = Math.ceil(parts.length / cols);
23059
23143
  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
23144
  return out;
@@ -23065,30 +23149,30 @@ function tileSheet(files, out, cell = 324) {
23065
23149
  }
23066
23150
  }
23067
23151
  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 });
23152
+ async function saveCarousel(meta2, dir2, opts = {}) {
23153
+ const slidesDir = join10(dir2, "slides");
23154
+ mkdirSync8(slidesDir, { recursive: true });
23071
23155
  const files = [];
23072
23156
  if (meta2) {
23073
23157
  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);
23158
+ const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}.jpg`);
23159
+ if (!existsSync8(target)) await download2(img.url, target, opts.fetchImpl);
23076
23160
  files.push(target);
23077
23161
  }
23078
23162
  } else if (opts.folder) {
23079
- const names = readdirSync3(opts.folder).filter((n) => IMAGE_EXT.test(n)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
23163
+ const names = readdirSync4(opts.folder).filter((n) => IMAGE_EXT.test(n)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
23080
23164
  if (!names.length) throw new Error(`No images in ${opts.folder} (jpg, png, webp).`);
23081
23165
  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);
23166
+ const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}${extname2(n).toLowerCase()}`);
23167
+ copyFileSync3(join10(opts.folder, n), target);
23084
23168
  files.push(target);
23085
23169
  }
23086
23170
  }
23087
23171
  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);
23172
+ const sheet = tileSheet(files, join10(dir2, "sheet.jpg"));
23173
+ if (meta2?.caption) writeFileSync8(join10(dir2, "caption.txt"), meta2.caption);
23090
23174
  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));
23175
+ writeFileSync8(join10(dir2, "meta.json"), JSON.stringify({ ...info, slides }, null, 2));
23092
23176
  const md = [
23093
23177
  "# Carousel breakdown",
23094
23178
  "",
@@ -23105,8 +23189,8 @@ async function saveCarousel(meta2, dir, opts = {}) {
23105
23189
  meta2?.caption ?? "(none)",
23106
23190
  ""
23107
23191
  ].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 };
23192
+ writeFileSync8(join10(dir2, "breakdown.md"), md);
23193
+ 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
23194
  }
23111
23195
  function imageSize(file) {
23112
23196
  const r = spawnSync4("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file], { encoding: "utf8" });
@@ -23203,8 +23287,8 @@ function resolveStyle(spec, opts = {}) {
23203
23287
  return mergeStyle({ ...BASE, name: ref }, preset.style);
23204
23288
  }
23205
23289
  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"));
23290
+ if (!existsSync8(file)) throw new Error(`No style called ${ref} (presets: ${Object.keys(STYLE_PRESETS).join(", ")}; or a style.json path).`);
23291
+ const j = JSON.parse(readFileSync9(file, "utf8"));
23208
23292
  return j.preset || j.path ? resolveStyle(j, { ...opts, baseDir: dirname2(file) }) : mergeStyle({ ...BASE, name: basename3(file, ".json") }, j);
23209
23293
  };
23210
23294
  if (typeof spec === "string") return load(spec);
@@ -23440,12 +23524,12 @@ function findChrome(env = process.env) {
23440
23524
  "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
23441
23525
  "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
23442
23526
  ].filter(Boolean);
23443
- for (const c of cands) if (existsSync7(c)) return c;
23444
- const pw = join9(homedir4(), "Library", "Caches", "ms-playwright");
23445
- if (existsSync7(pw)) {
23446
- for (const d of readdirSync3(pw).filter((n) => /^chromium-\d+$/.test(n)).sort().reverse()) {
23527
+ for (const c of cands) if (existsSync8(c)) return c;
23528
+ const pw = join10(homedir4(), "Library", "Caches", "ms-playwright");
23529
+ if (existsSync8(pw)) {
23530
+ for (const d of readdirSync4(pw).filter((n) => /^chromium-\d+$/.test(n)).sort().reverse()) {
23447
23531
  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);
23532
+ if (existsSync8(join10(pw, d, sub))) return join10(pw, d, sub);
23449
23533
  }
23450
23534
  }
23451
23535
  }
@@ -23477,9 +23561,9 @@ function contrast(a, b) {
23477
23561
  return Math.round((x + 0.05) / (y + 0.05) * 100) / 100;
23478
23562
  }
23479
23563
  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")));
23564
+ const deckFile = join10(folder, "deck.json");
23565
+ if (!existsSync8(deckFile)) throw new Error(`No deck.json in ${folder}.`);
23566
+ const deck = validateDeck(JSON.parse(readFileSync9(deckFile, "utf8")));
23483
23567
  const bin = findChrome();
23484
23568
  if (!bin) throw new Error("Rendering slides needs Google Chrome (or Chromium) on this computer. Install Chrome, or set CHROME_PATH to it.");
23485
23569
  let style = resolveStyle(deck.style, { baseDir: folder, internal: opts.internal });
@@ -23488,24 +23572,24 @@ async function renderDeck(folder, opts = {}) {
23488
23572
  style = applyBrand(style, brand);
23489
23573
  const total = deck.slides.length;
23490
23574
  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 });
23575
+ const renders = join10(folder, "renders"), html = join10(folder, "html"), history = join10(renders, "history");
23576
+ for (const d of [renders, html]) mkdirSync8(d, { recursive: true });
23493
23577
  const queue = [...targets];
23494
23578
  const results = [];
23495
23579
  const worker = async () => {
23496
23580
  for (let n = queue.shift(); n !== void 0; n = queue.shift()) {
23497
23581
  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 });
23582
+ const page2 = join10(html, `${name}.html`), png = join10(renders, `${name}.png`);
23583
+ writeFileSync8(page2, slideHtml(deck.slides[n - 1], style, n, total, { baseDir: folder, handle: deck.handle }));
23584
+ if (existsSync8(png)) {
23585
+ mkdirSync8(history, { recursive: true });
23502
23586
  let v = 1;
23503
- while (existsSync7(join9(history, `${name}-v${v}.png`))) v++;
23504
- copyFileSync3(png, join9(history, `${name}-v${v}.png`));
23587
+ while (existsSync8(join10(history, `${name}-v${v}.png`))) v++;
23588
+ copyFileSync3(png, join10(history, `${name}-v${v}.png`));
23505
23589
  }
23506
23590
  const [W, H] = style.size;
23507
23591
  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}.`);
23592
+ if (!existsSync8(png) || shot.code !== 0) throw new Error(`Chrome could not render slide ${n}.`);
23509
23593
  const dom = await chrome(bin, [`--window-size=${W},${H}`, "--dump-dom", fileUrl(page2)]);
23510
23594
  let qa = {};
23511
23595
  try {
@@ -23519,15 +23603,15 @@ async function renderDeck(folder, opts = {}) {
23519
23603
  ...qa.shrunk >= 25 ? [`text shrunk ${qa.shrunk}% to fit: consider fewer words`] : [],
23520
23604
  ...qa.missing_fonts?.length ? [`font not loaded (fallback used): ${qa.missing_fonts.join(", ")}`] : [],
23521
23605
  ...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}`] : []
23606
+ ...s.image && !/^(https?:|data:)/.test(s.image.src) && !existsSync8(resolve4(folder, s.image.src.replace(/^~/, homedir4()))) ? [`image missing: ${s.image.src}`] : []
23523
23607
  ];
23524
23608
  results.push({ n, png, shrunk: qa.shrunk, overflow: qa.overflow, missing_fonts: qa.missing_fonts, contrast: ratio, flags });
23525
23609
  }
23526
23610
  };
23527
23611
  await Promise.all(Array.from({ length: Math.min(opts.concurrency ?? 4, targets.length) }, worker));
23528
23612
  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);
23613
+ const all = deck.slides.map((_, i) => join10(renders, `${String(i + 1).padStart(2, "0")}.png`)).filter(existsSync8);
23614
+ const preview = tileSheet(all, join10(folder, "preview.jpg"), 360);
23531
23615
  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
23616
  }
23533
23617
  function applyEdits(deck, edits) {
@@ -23547,21 +23631,21 @@ function applyEdits(deck, edits) {
23547
23631
  }
23548
23632
  async function renderCarousel(input, opts = {}) {
23549
23633
  const folder = resolve4(input.folder.replace(/^~/, homedir4()));
23550
- mkdirSync7(folder, { recursive: true });
23551
- const file = join9(folder, "deck.json");
23634
+ mkdirSync8(folder, { recursive: true });
23635
+ const file = join10(folder, "deck.json");
23552
23636
  let only = input.only;
23553
23637
  if (input.deck) {
23554
- writeFileSync7(file, JSON.stringify(validateDeck(input.deck), null, 2));
23638
+ writeFileSync8(file, JSON.stringify(validateDeck(input.deck), null, 2));
23555
23639
  } 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")));
23640
+ if (!existsSync8(file)) throw new Error(`No deck.json in ${folder}: pass deck first.`);
23641
+ let deck = validateDeck(JSON.parse(readFileSync9(file, "utf8")));
23558
23642
  if (input.style !== void 0) deck.style = input.style;
23559
23643
  if (input.edits?.length) {
23560
23644
  const r = applyEdits(deck, input.edits);
23561
23645
  deck = r.deck;
23562
23646
  if (!only?.length && input.style === void 0) only = r.touched;
23563
23647
  }
23564
- writeFileSync7(file, JSON.stringify(deck, null, 2));
23648
+ writeFileSync8(file, JSON.stringify(deck, null, 2));
23565
23649
  }
23566
23650
  return renderDeck(folder, { only, internal: opts.internal, brand: opts.brand });
23567
23651
  }
@@ -23571,28 +23655,28 @@ var home = (p) => resolve5(p.replace(/^~/, homedir5()));
23571
23655
  async function breakdownCarousel(input) {
23572
23656
  ensureStudio();
23573
23657
  const src = input.source.trim();
23574
- const base = join10(studio.references(), "carousels");
23658
+ const base = join11(studio.references(), "carousels");
23575
23659
  const next = "Open sheet.jpg and every slide, then follow get_method('copy_carousel').";
23576
23660
  if (/^https?:\/\//i.test(src)) {
23577
23661
  const { meta: meta2 } = await call("carousel", { url: src }).catch((e) => {
23578
23662
  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
23663
  });
23580
- const dir = join10(base, `${day()}-${slug(meta2.owner ?? meta2.platform)}-${meta2.code ?? "post"}`);
23581
- return { ...await saveCarousel(meta2, dir), next };
23664
+ const dir2 = join11(base, `${day()}-${slug(meta2.owner ?? meta2.platform)}-${meta2.code ?? "post"}`);
23665
+ return { ...await saveCarousel(meta2, dir2), next };
23582
23666
  }
23583
23667
  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 };
23668
+ if (!existsSync9(folder)) throw new Error(`No such folder: ${folder}`);
23669
+ return { ...await saveCarousel(null, join11(base, `${day()}-${slug(folder.split(/[\\/]/).pop() ?? "carousel")}`), { folder }), next };
23586
23670
  }
23587
23671
  function renderCarouselLocal(input) {
23588
23672
  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")}`);
23673
+ const root = join11(HOME(), "carousels");
23674
+ 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
23675
  return renderCarousel({ folder, deck: input.deck, edits: input.edits, style: input.style, only: input.only }, { brand: (ref) => ref === "mine" ? brandFromJson(getBrand()) : null });
23592
23676
  }
23593
23677
 
23594
23678
  // 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);
23679
+ 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
23680
  useTools();
23597
23681
  var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
23598
23682
  var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
@@ -23651,7 +23735,7 @@ await serveStdio(() => {
23651
23735
  ${m.text}`;
23652
23736
  }));
23653
23737
  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.",
23738
+ 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
23739
  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
23740
  }, async ({ hooks, audience }) => safe(() => call("score", { kind: "hook", texts: hooks, audience })));
23657
23741
  server.registerTool("score_script", {
@@ -23713,12 +23797,13 @@ ${m.text}`;
23713
23797
  })
23714
23798
  }, async (input) => local(() => prepareFootage(input)));
23715
23799
  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.",
23800
+ 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
23801
  inputSchema: object({
23718
23802
  project: string2().min(1).describe("The project folder returned by prepare_footage."),
23719
23803
  layout: string2().default("full-frame"),
23720
23804
  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'}]."),
23805
+ 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)}. A slot with the same start, dur and request as before reuses its 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."),
23806
+ about: string2().optional().describe("What the video is about, in a sentence or two (for custom scenes)."),
23722
23807
  images: array(string2()).max(8).optional().describe("Pictures for the layout, full paths on this computer."),
23723
23808
  emphasis: array(string2()).optional(),
23724
23809
  fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word.")
@@ -23729,7 +23814,7 @@ ${m.text}`;
23729
23814
  const r = await planReel(input);
23730
23815
  return { content: [...note ? [{ type: "text", text: note }] : [], { type: "text", text: `${r.text}
23731
23816
 
23732
- ${r.next}` }, { type: "image", data: readFileSync9(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
23817
+ ${r.next}` }, { type: "image", data: readFileSync10(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
23733
23818
  } catch (e) {
23734
23819
  return failure2(e);
23735
23820
  }
@@ -23740,10 +23825,11 @@ ${r.next}` }, { type: "image", data: readFileSync9(r.storyboard).toString("base6
23740
23825
  project: string2().min(1).describe("The project folder returned by prepare_footage."),
23741
23826
  layout: string2().default("full-frame"),
23742
23827
  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."),
23828
+ 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
23829
  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
23830
  emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
23746
23831
  fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word."),
23832
+ about: string2().optional().describe("What the video is about (for custom scenes)."),
23747
23833
  draft: boolean2().default(false),
23748
23834
  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
23835
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepylabs/cutroom",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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": {