@keepylabs/cutroom 0.7.0 → 0.8.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 (3) hide show
  1. package/README.md +2 -1
  2. package/cutroom.mjs +349 -175
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,11 +15,12 @@ Everything lives in the Cutroom folder in your home folder. Open Claude Code the
15
15
  Cutroom/
16
16
  CLAUDE.md how your studio works: approvals, rules
17
17
  brand/ your colors, logo, frames you love
18
- references/ reel breakdowns and creator teardowns
18
+ references/ your library: reel breakdowns and creator teardowns
19
19
  inbox/ drop new raw videos here
20
20
  videos/
21
21
  2026-11-01-topic/
22
22
  raw/ this video's footage
23
+ references/ what this video copies, with a why-it-works note
23
24
  script.md what you say, word for word
24
25
  facts.md every number on screen, with its source
25
26
  plan.md the edit plan and its status
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 homedir3 } from "node:os";
11
- import { delimiter, join as join6 } from "node:path";
11
+ import { delimiter, join as join7 } from "node:path";
12
12
 
13
13
  // src/community/local/proc.ts
14
14
  import { spawnSync } from "node:child_process";
@@ -19201,8 +19201,8 @@ var require_formats = /* @__PURE__ */ __commonJSMin(((exports) => {
19201
19201
  if (!matches) return false;
19202
19202
  const year = +matches[1];
19203
19203
  const month = +matches[2];
19204
- const day3 = +matches[3];
19205
- return month >= 1 && month <= 12 && day3 >= 1 && day3 <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]);
19204
+ const day4 = +matches[3];
19205
+ return month >= 1 && month <= 12 && day4 >= 1 && day4 <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]);
19206
19206
  }
19207
19207
  function compareDate(d1, d2) {
19208
19208
  if (!(d1 && d2)) return void 0;
@@ -21559,13 +21559,13 @@ import { readFileSync as readFileSync6 } from "node:fs";
21559
21559
 
21560
21560
  // src/community/local/render.ts
21561
21561
  import { spawnSync as spawnSync3 } from "node:child_process";
21562
- import { copyFileSync as copyFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync5, mkdtempSync, readdirSync, readFileSync as readFileSync5, renameSync as renameSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync4 } from "node:fs";
21562
+ import { copyFileSync as copyFileSync2, existsSync as existsSync5, mkdirSync as mkdirSync5, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync5, renameSync as renameSync3, rmSync as rmSync3, statSync, writeFileSync as writeFileSync5 } from "node:fs";
21563
21563
  import { homedir as homedir2, tmpdir } from "node:os";
21564
- import { basename as basename2, extname, join as join5, resolve as resolve3 } from "node:path";
21564
+ import { basename as basename2, extname, join as join6, resolve as resolve3 } from "node:path";
21565
21565
 
21566
21566
  // src/community/local/measure.ts
21567
21567
  import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
21568
- import { mkdirSync, readFileSync } from "node:fs";
21568
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21569
21569
  import { join } from "node:path";
21570
21570
  var ffmpegErr = (args) => spawnSync2("ffmpeg", ["-nostdin", "-hide_banner", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).stderr ?? "";
21571
21571
  function probe(file) {
@@ -21609,6 +21609,34 @@ function sampleFrames(file, dir, duration3, interval = 1, max = 40) {
21609
21609
  }
21610
21610
  return { frames: out, interval: step };
21611
21611
  }
21612
+ function sheetGrid(n) {
21613
+ const cols = Math.max(1, Math.min(8, n));
21614
+ return { cols, rows: Math.max(1, Math.ceil(n / cols)) };
21615
+ }
21616
+ function contactSheet(frames, out) {
21617
+ if (!frames.length) return null;
21618
+ const tmp = `${out}.parts`;
21619
+ mkdirSync(tmp, { recursive: true });
21620
+ try {
21621
+ const parts = frames.map((f, i) => {
21622
+ const part = join(tmp, `${String(i).padStart(3, "0")}.jpg`);
21623
+ const label = `${Math.floor(f.t / 60)}\\:${(f.t % 60).toFixed(1).padStart(4, "0")}`;
21624
+ const scale = "scale=200:-2";
21625
+ const labelled = spawnSync2("ffmpeg", ["-v", "error", "-y", "-i", f.path, "-vf", `${scale},drawtext=text='${label}':x=8:y=8:fontsize=22:fontcolor=white:box=1:boxcolor=black@0.7:boxborderw=6`, part]);
21626
+ if (labelled.status !== 0) execFileSync("ffmpeg", ["-v", "error", "-y", "-i", f.path, "-vf", scale, part]);
21627
+ return part;
21628
+ });
21629
+ const list = join(tmp, "list.txt");
21630
+ writeFileSync(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
21631
+ const { cols, rows } = sheetGrid(parts.length);
21632
+ execFileSync("ffmpeg", ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", list, "-vf", `tile=${cols}x${rows}:padding=4:color=black`, "-frames:v", "1", "-q:v", "4", out]);
21633
+ return out;
21634
+ } catch {
21635
+ return null;
21636
+ } finally {
21637
+ rmSync(tmp, { recursive: true, force: true });
21638
+ }
21639
+ }
21612
21640
  function parseSilences(log, duration3) {
21613
21641
  const out = [];
21614
21642
  let start = null;
@@ -21658,29 +21686,146 @@ function technicalChecks(file) {
21658
21686
  }
21659
21687
 
21660
21688
  // src/community/local/studio.ts
21661
- import { appendFileSync, copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync, writeFileSync as writeFileSync3 } from "node:fs";
21662
- import { basename, isAbsolute, join as join4, relative, resolve as resolve2 } from "node:path";
21689
+ import { existsSync as existsSync4, renameSync as renameSync2 } from "node:fs";
21690
+ import { join as join5 } from "node:path";
21691
+
21692
+ // src/studio-core.ts
21693
+ var studio_core_exports = {};
21694
+ __export(studio_core_exports, {
21695
+ STATUSES: () => STATUSES,
21696
+ addCheck: () => addCheck,
21697
+ day: () => day,
21698
+ ensureLayout: () => ensureLayout,
21699
+ keepRender: () => keepRender,
21700
+ layout: () => layout,
21701
+ nextVersion: () => nextVersion,
21702
+ setStatus: () => setStatus,
21703
+ slug: () => slug,
21704
+ startVideoNotes: () => startVideoNotes,
21705
+ statusOf: () => statusOf,
21706
+ takeRaw: () => takeRaw,
21707
+ videoFolderOf: () => videoFolderOf,
21708
+ videoPath: () => videoPath
21709
+ });
21710
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
21711
+ import { basename, dirname, isAbsolute, join as join2, relative, resolve } from "node:path";
21712
+ var STATUSES = ["footage ready", "plan ready", "preview", "final", "posted"];
21713
+ var day = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
21714
+ var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
21715
+ var layout = (home) => ({ home, brand: join2(home, "brand"), references: join2(home, "references"), inbox: join2(home, "inbox"), videos: join2(home, "videos") });
21716
+ function ensureLayout(home, claudeMd) {
21717
+ const s = layout(home);
21718
+ for (const d of [s.brand, s.references, s.inbox, s.videos]) mkdirSync2(d, { recursive: true });
21719
+ const claude = join2(home, "CLAUDE.md");
21720
+ if (!existsSync(claude)) writeFileSync2(claude, claudeMd);
21721
+ return s;
21722
+ }
21723
+ function videoPath(home, topic, date4 = day()) {
21724
+ const videos = layout(home).videos;
21725
+ const t = slug(topic) || "video";
21726
+ const existing = existsSync(videos) ? readdirSync(videos).filter((name) => /^\d{4}-\d{2}-\d{2}-/.test(name) && name.slice(11) === t).sort() : [];
21727
+ return join2(videos, existing.at(-1) ?? `${date4}-${t}`);
21728
+ }
21729
+ function videoFolderOf(path) {
21730
+ for (let p = resolve(path); p !== dirname(p); p = dirname(p)) {
21731
+ if (basename(dirname(p)) === "videos" && existsSync(join2(p, "plan.md"))) return p;
21732
+ }
21733
+ return null;
21734
+ }
21735
+ var inside = (child, parent) => {
21736
+ const r = relative(parent, child);
21737
+ return !!r && !r.startsWith("..") && !isAbsolute(r);
21738
+ };
21739
+ function takeRaw(source, folder, inbox) {
21740
+ const raw = join2(folder, "raw");
21741
+ const file = resolve(source);
21742
+ if (inside(file, raw)) return { file, how: "already here" };
21743
+ mkdirSync2(raw, { recursive: true });
21744
+ let target = join2(raw, basename(file));
21745
+ for (let n = 2; existsSync(target); n++) target = join2(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
21746
+ if (inbox && inside(file, inbox)) {
21747
+ try {
21748
+ renameSync(file, target);
21749
+ return { file: target, how: "moved from inbox" };
21750
+ } catch {
21751
+ }
21752
+ }
21753
+ copyFileSync(file, target);
21754
+ return { file: target, how: "copied" };
21755
+ }
21756
+ function setStatus(folder, status, plan) {
21757
+ const file = join2(folder, "plan.md");
21758
+ const current = existsSync(file) ? readFileSync2(file, "utf8") : `# ${basename(folder)}
21759
+
21760
+ status:
21761
+ `;
21762
+ const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
21763
+ status: ${status}
21764
+ `);
21765
+ writeFileSync2(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
21766
+
21767
+ ## Plan
21768
+ ${plan}
21769
+ `);
21770
+ }
21771
+ var statusOf = (folder) => {
21772
+ const file = join2(folder, "plan.md");
21773
+ return existsSync(file) ? readFileSync2(file, "utf8").match(/^status: *(.*)$/m)?.[1].trim() ?? null : null;
21774
+ };
21775
+ var NO_SCRIPT = "(no script yet)";
21776
+ function startVideoNotes(folder, script, who = "is said", source = "from the footage; fix any misheard words here") {
21777
+ const scriptFile = join2(folder, "script.md"), facts = join2(folder, "facts.md"), notes = join2(folder, "references", "notes.md");
21778
+ mkdirSync2(join2(folder, "references"), { recursive: true });
21779
+ if (!existsSync(notes)) writeFileSync2(notes, "# References for this video\n\nWhat this video copies and why it works, one line each.\n\n");
21780
+ if (!existsSync(facts)) writeFileSync2(facts, "# Facts\n\nEvery number or claim that shows on screen, with where it comes from.\n\n| on screen | source |\n| - | - |\n");
21781
+ const waiting = !existsSync(scriptFile) || script !== void 0 && readFileSync2(scriptFile, "utf8").includes(NO_SCRIPT);
21782
+ if (waiting) writeFileSync2(scriptFile, `# Script
21783
+
21784
+ What ${who}, word for word${script ? ` (${source})` : ""}.
21785
+
21786
+ ${script ?? NO_SCRIPT}
21787
+ `);
21788
+ }
21789
+ function addCheck(folder, entry) {
21790
+ const file = join2(folder, "checks.md");
21791
+ if (!existsSync(file)) writeFileSync2(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
21792
+ appendFileSync(file, `
21793
+ ${entry.trim()}
21794
+ `);
21795
+ }
21796
+ function nextVersion(base, ext, dirs) {
21797
+ let n = 1;
21798
+ while (dirs.some((d) => existsSync(join2(d, `${base}-v${n}${ext}`)))) n++;
21799
+ return `${base}-v${n}${ext}`;
21800
+ }
21801
+ function keepRender(folder, file, name = basename(file)) {
21802
+ mkdirSync2(join2(folder, "renders"), { recursive: true });
21803
+ let target = join2(folder, "renders", name);
21804
+ for (let n = 2; existsSync(target); n++) target = join2(folder, "renders", name.replace(/(\.[^.]*)?$/, `-${n}$1`));
21805
+ copyFileSync(file, target);
21806
+ return target;
21807
+ }
21663
21808
 
21664
21809
  // src/community/local/tools.ts
21665
- import { createWriteStream, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
21810
+ import { createWriteStream, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
21666
21811
  import { homedir } from "node:os";
21667
- import { join as join3, resolve } from "node:path";
21812
+ import { join as join4, resolve as resolve2 } from "node:path";
21668
21813
  import { Readable } from "node:stream";
21669
21814
  import { pipeline } from "node:stream/promises";
21670
21815
 
21671
21816
  // src/community/local/signin.ts
21672
21817
  import { spawn } from "node:child_process";
21673
21818
  import { createHash, randomBytes } from "node:crypto";
21674
- import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
21819
+ import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
21675
21820
  import { createServer } from "node:http";
21676
- import { join as join2 } from "node:path";
21821
+ import { join as join3 } from "node:path";
21677
21822
  var WHOP_APP = "app_bFhJFdBJn9jZ0v";
21678
21823
  var PORTS = [53171, 53172, 53173];
21679
21824
  var WAIT_MS = 5 * 6e4;
21680
- var keyFile = () => join2(HOME(), ".cutroom-key");
21825
+ var keyFile = () => join3(HOME(), ".cutroom-key");
21681
21826
  function currentKey() {
21682
21827
  try {
21683
- const k = readFileSync2(keyFile(), "utf8").trim();
21828
+ const k = readFileSync3(keyFile(), "utf8").trim();
21684
21829
  if (k) return k;
21685
21830
  } catch {
21686
21831
  }
@@ -21688,14 +21833,14 @@ function currentKey() {
21688
21833
  return env && !env.includes("${") ? env : null;
21689
21834
  }
21690
21835
  function saveKey(key) {
21691
- mkdirSync2(HOME(), { recursive: true });
21692
- writeFileSync(keyFile(), key, { mode: 384 });
21836
+ mkdirSync3(HOME(), { recursive: true });
21837
+ writeFileSync3(keyFile(), key, { mode: 384 });
21693
21838
  try {
21694
21839
  chmodSync(keyFile(), 384);
21695
21840
  } catch {
21696
21841
  }
21697
21842
  }
21698
- var forgetKey = () => rmSync(keyFile(), { force: true });
21843
+ var forgetKey = () => rmSync2(keyFile(), { force: true });
21699
21844
  function openBrowser(url2) {
21700
21845
  const [cmd, args] = process.platform === "darwin" ? ["open", [url2]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url2]] : ["xdg-open", [url2]];
21701
21846
  try {
@@ -21788,9 +21933,9 @@ ${authorize}
21788
21933
  }
21789
21934
 
21790
21935
  // src/community/local/tools.ts
21791
- var VERSION = "0.7.0";
21936
+ var VERSION = "0.8.0";
21792
21937
  var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
21793
- var HOME = () => process.env.CUTROOM_HOME || join3(homedir(), "Cutroom");
21938
+ var HOME = () => process.env.CUTROOM_HOME || join4(homedir(), "Cutroom");
21794
21939
  async function call(op, body = {}, fetchImpl = fetch) {
21795
21940
  const key = currentKey();
21796
21941
  if (!key) throw Object.assign(new Error("Not signed in to Cutroom yet. Run the setup check (cutroom_doctor): it opens the Whop sign-in."), { status: 401 });
@@ -21810,8 +21955,8 @@ async function call(op, body = {}, fetchImpl = fetch) {
21810
21955
  if (!res.ok) throw Object.assign(new Error(json?.error ?? `Cutroom error (HTTP ${res.status}).`), { status: res.status });
21811
21956
  return json;
21812
21957
  }
21813
- var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
21814
- var day = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
21958
+ var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
21959
+ var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
21815
21960
  var fmt = (t) => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, "0")}`;
21816
21961
  var pct = (n) => `${Math.round(n * 100)}%`;
21817
21962
  async function download(url2, target) {
@@ -21870,7 +22015,7 @@ function localWords(file, dir) {
21870
22015
  try {
21871
22016
  const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
21872
22017
  if (r.status !== 0 || !meta2.ok) return null;
21873
- const raw = JSON.parse(readFileSync3(meta2.transcriptPath ?? join3(dir, "transcript.json"), "utf8"));
22018
+ const raw = JSON.parse(readFileSync4(meta2.transcriptPath ?? join4(dir, "transcript.json"), "utf8"));
21874
22019
  const words = raw.map((w) => ({ t: Math.round(w.start * 100) / 100, e: Math.round(w.end * 100) / 100, w: w.text.trim() })).filter((w) => w.w);
21875
22020
  return words.length ? words : null;
21876
22021
  } catch {
@@ -21891,22 +22036,25 @@ async function breakdownReel(input) {
21891
22036
  let meta2 = null, transcript = null, file;
21892
22037
  const src = input.source.trim();
21893
22038
  if (/^https?:\/\//i.test(src)) {
21894
- const r = await call("reel", { url: src });
22039
+ const r = await call("reel", { url: src }).catch((e) => {
22040
+ throw new Error(`Couldn't fetch that Instagram link right now (${e instanceof Error ? e.message : e}). Save the reel to this computer and give me the file path instead: breakdown_reel works the same on a file.`);
22041
+ });
21895
22042
  meta2 = r.meta;
21896
22043
  transcript = r.transcript;
21897
- const dir2 = join3(studio.references(), `${day()}-${slug(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
21898
- mkdirSync3(dir2, { recursive: true });
21899
- file = join3(dir2, "source.mp4");
21900
- if (!existsSync2(file)) await download(meta2.videoUrl, file);
22044
+ const dir2 = join4(studio.references(), `${day2()}-${slug2(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
22045
+ mkdirSync4(dir2, { recursive: true });
22046
+ file = join4(dir2, "source.mp4");
22047
+ if (!existsSync3(file)) await download(meta2.videoUrl, file);
21901
22048
  } else {
21902
- file = resolve(src.replace(/^~/, homedir()));
21903
- if (!existsSync2(file)) throw new Error(`No such file: ${file}`);
22049
+ file = resolve2(src.replace(/^~/, homedir()));
22050
+ if (!existsSync3(file)) throw new Error(`No such file: ${file}`);
21904
22051
  }
21905
- const dir = /^https?:/i.test(src) ? join3(file, "..") : join3(studio.references(), `${day()}-${slug(file.split(/[\\/]/).pop() ?? "video")}`);
21906
- mkdirSync3(dir, { recursive: true });
22052
+ const dir = /^https?:/i.test(src) ? join4(file, "..") : join4(studio.references(), `${day2()}-${slug2(file.split(/[\\/]/).pop() ?? "video")}`);
22053
+ mkdirSync4(dir, { recursive: true });
21907
22054
  const m = measure(file);
21908
- const { frames, interval } = sampleFrames(file, join3(dir, "frames"), m.duration, 1, 40);
22055
+ const { frames, interval } = sampleFrames(file, join4(dir, "frames"), m.duration, 1, 40);
21909
22056
  const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
22057
+ const sheet = contactSheet(frames, join4(dir, "sheet.jpg"));
21910
22058
  const timed = localWords(file, dir);
21911
22059
  const words = timed ?? approxWords(transcript, m.duration);
21912
22060
  const bs = beats(m, words, match.frames);
@@ -21929,6 +22077,7 @@ async function breakdownReel(input) {
21929
22077
  hook ? `- first line: "${hook}"` : "- no transcript (silent video or local file)",
21930
22078
  "",
21931
22079
  "## The look",
22080
+ sheet ? "Every sampled frame, labelled with its time: sheet.jpg. The look below is the closest Cutroom parts; check it against the sheet." : "Sampled frames are in frames/. The look below is the closest Cutroom parts; check it against them.",
21932
22081
  "Layout, share of sampled frames:",
21933
22082
  ...Object.entries(s.layout_share).map(([k, v]) => `- ${k}: ${pct(v)}`),
21934
22083
  "Captions:",
@@ -21945,9 +22094,9 @@ async function breakdownReel(input) {
21945
22094
  transcript ?? "(none)",
21946
22095
  ""
21947
22096
  ].join("\n");
21948
- writeFileSync2(join3(dir, "breakdown.md"), md);
21949
- const out = { folder: dir, report: join3(dir, "breakdown.md"), 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." };
21950
- writeFileSync2(join3(dir, "breakdown.json"), JSON.stringify(out, null, 2));
22097
+ writeFileSync4(join4(dir, "breakdown.md"), md);
22098
+ const out = { folder: dir, report: join4(dir, "breakdown.md"), sheet, hook, measure: m, look: s, beats: bs, words_timing: timed ? "measured" : transcript ? "approximate" : "none", transcript, meta: meta2, next: "Get the 'breakdown_writeup' method with get_method and follow it." };
22099
+ writeFileSync4(join4(dir, "breakdown.json"), JSON.stringify(out, null, 2));
21951
22100
  return out;
21952
22101
  }
21953
22102
  function ctaKeyword(caption) {
@@ -21956,15 +22105,15 @@ function ctaKeyword(caption) {
21956
22105
  }
21957
22106
  async function teardownOne(handle, limit) {
21958
22107
  const { reels } = await call("reels", { handle, limit });
21959
- const dir = join3(studio.references(), `${day()}-${slug(handle)}-creator`);
21960
- mkdirSync3(join3(dir, "raw"), { recursive: true });
22108
+ const dir = join4(studio.references(), `${day2()}-${slug2(handle)}-creator`);
22109
+ mkdirSync4(join4(dir, "raw"), { recursive: true });
21961
22110
  const rows = [];
21962
22111
  for (const [i, r] of reels.entries()) {
21963
22112
  try {
21964
- const file = join3(dir, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
21965
- if (!existsSync2(file)) await download(r.videoUrl, file);
22113
+ const file = join4(dir, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
22114
+ if (!existsSync3(file)) await download(r.videoUrl, file);
21966
22115
  const m = measure(file);
21967
- const { frames, interval } = sampleFrames(file, join3(dir, "frames", r.shortCode), m.duration, 2, 20);
22116
+ const { frames, interval } = sampleFrames(file, join4(dir, "frames", r.shortCode), m.duration, 2, 20);
21968
22117
  const [match, tr] = await Promise.all([call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval }), call("transcript", { url: r.url }).catch(() => ({ transcript: null }))]);
21969
22118
  const top = (o) => Object.keys(o)[0] ?? null;
21970
22119
  rows.push({ code: r.shortCode, url: r.url, posted: String(r.timestamp ?? "").slice(0, 10), plays: r.videoPlayCount ?? null, duration_s: Math.round(m.duration * 10) / 10, cuts_per_min: m.cuts_per_min, layout: top(match.summary.layout_share), caption: top(match.summary.caption_share), effects: match.summary.effects.filter((e) => e.share >= 0.1 || e.frames >= 2).map((e) => e.title), off_menu: match.summary.effects.filter((e) => e.in_menu === false).map((e) => e.title), hook: tr.transcript ? String(tr.transcript).split(/(?<=[.!?])\s/)[0] : null, cta: ctaKeyword(r.caption) });
@@ -22036,18 +22185,18 @@ async function creatorTeardown(input) {
22036
22185
  ...a.rows.map((x) => x.error ? `| ${x.posted} | ${x.plays ?? ""} | | | failed: ${String(x.error).slice(0, 60)} | | |` : `| [${x.posted}](${x.url}) | ${x.plays ?? ""} | ${x.duration_s} | ${x.cuts_per_min} | ${x.layout ?? ""} | ${x.effects.join(", ")} | ${(x.hook ?? "").replace(/\|/g, "/").slice(0, 90)} |`),
22037
22186
  ""
22038
22187
  ].join("\n");
22039
- writeFileSync2(join3(a.dir, "teardown.md"), md);
22040
- const out = { folder: a.dir, report: join3(a.dir, "teardown.md"), rollup: r, compare: cmp, rows: a.rows, next: "Get the 'teardown_writeup' method with get_method and follow it." };
22041
- writeFileSync2(join3(a.dir, "teardown.json"), JSON.stringify(out, null, 2));
22188
+ writeFileSync4(join4(a.dir, "teardown.md"), md);
22189
+ const out = { folder: a.dir, report: join4(a.dir, "teardown.md"), rollup: r, compare: cmp, rows: a.rows, next: "Get the 'teardown_writeup' method with get_method and follow it." };
22190
+ writeFileSync4(join4(a.dir, "teardown.json"), JSON.stringify(out, null, 2));
22042
22191
  return out;
22043
22192
  }
22044
22193
 
22045
22194
  // src/community/local/studio.ts
22046
22195
  var studio = {
22047
- brand: () => join4(HOME(), "brand"),
22048
- references: () => join4(HOME(), "references"),
22049
- inbox: () => join4(HOME(), "inbox"),
22050
- videos: () => join4(HOME(), "videos")
22196
+ brand: () => layout(HOME()).brand,
22197
+ references: () => layout(HOME()).references,
22198
+ inbox: () => layout(HOME()).inbox,
22199
+ videos: () => layout(HOME()).videos
22051
22200
  };
22052
22201
  var CLAUDE_MD = `# My Cutroom studio
22053
22202
 
@@ -22057,13 +22206,14 @@ Everything for my videos lives in this folder. Open Claude Code here so it can s
22057
22206
  - inbox/: drop new raw videos here. When a video starts, its clip moves into that video's raw/ folder.
22058
22207
  - videos/<date>-<topic>/: one folder per video, with everything for it:
22059
22208
  - raw/: the footage
22209
+ - references/: only what this video copies (Pinterest images, the reel it matches), each with a one-line "why it works" in references/notes.md
22060
22210
  - script.md: what I say, word for word
22061
22211
  - facts.md: every number or claim on screen, with its source
22062
22212
  - plan.md: the edit plan and a status line (footage ready, plan ready, preview, final, posted)
22063
22213
  - renders/: previews and the final export
22064
22214
  - checks.md: what QA found and what got fixed
22065
22215
  - brand/: my colors (brand.json), logo and a few frames I love.
22066
- - references/: reels and creators I study, each with a breakdown of why it works.
22216
+ - references/: my library of reels and creators I study across videos, each with a breakdown of why it works.
22067
22217
 
22068
22218
  ## Rules
22069
22219
  - Three stops: I approve the script, then the plan, then the preview. Nothing final renders before that.
@@ -22074,78 +22224,25 @@ Everything for my videos lives in this folder. Open Claude Code here so it can s
22074
22224
  ## My rules
22075
22225
  `;
22076
22226
  function ensureStudio() {
22077
- for (const d of Object.values(studio)) mkdirSync4(d(), { recursive: true });
22078
- const claude = join4(HOME(), "CLAUDE.md");
22079
- if (!existsSync3(claude)) writeFileSync3(claude, CLAUDE_MD);
22080
- const oldBrand = join4(HOME(), "brand.json"), newBrand = join4(studio.brand(), "brand.json");
22081
- if (existsSync3(oldBrand) && !existsSync3(newBrand)) renameSync(oldBrand, newBrand);
22227
+ ensureLayout(HOME(), CLAUDE_MD);
22228
+ const oldBrand = join5(HOME(), "brand.json"), newBrand = join5(studio.brand(), "brand.json");
22229
+ if (existsSync4(oldBrand) && !existsSync4(newBrand)) renameSync2(oldBrand, newBrand);
22082
22230
  return HOME();
22083
22231
  }
22084
- var inside = (child, parent) => {
22085
- const r = relative(parent, child);
22086
- return !!r && !r.startsWith("..") && !isAbsolute(r);
22087
- };
22088
- function takeRaw(source, folder) {
22089
- const raw = join4(folder, "raw");
22090
- const file = resolve2(source);
22091
- if (inside(file, raw)) return { file, how: "already here" };
22092
- mkdirSync4(raw, { recursive: true });
22093
- let target = join4(raw, basename(file));
22094
- for (let n = 2; existsSync3(target); n++) target = join4(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
22095
- if (inside(file, studio.inbox())) {
22096
- try {
22097
- renameSync(file, target);
22098
- return { file: target, how: "moved from inbox" };
22099
- } catch {
22100
- }
22101
- }
22102
- copyFileSync(file, target);
22103
- return { file: target, how: "copied" };
22104
- }
22105
- function setStatus(folder, status, plan) {
22106
- const file = join4(folder, "plan.md");
22107
- const name = basename(folder);
22108
- const current = existsSync3(file) ? readFileSync4(file, "utf8") : `# ${name}
22109
-
22110
- status:
22111
- `;
22112
- const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
22113
- status: ${status}
22114
- `);
22115
- writeFileSync3(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
22116
-
22117
- ## Plan
22118
- ${plan}
22119
- `);
22120
- }
22121
- function startVideoNotes(folder, transcript) {
22122
- const script = join4(folder, "script.md"), facts = join4(folder, "facts.md");
22123
- if (!existsSync3(script)) writeFileSync3(script, `# Script
22124
-
22125
- What I say, word for word (from the footage; fix any misheard words here).
22126
-
22127
- ${transcript}
22128
- `);
22129
- if (!existsSync3(facts)) writeFileSync3(facts, "# Facts\n\nEvery number or claim that shows on screen, with where it comes from.\n\n| on screen | source |\n| - | - |\n");
22130
- }
22131
- function addCheck(folder, entry) {
22132
- const file = join4(folder, "checks.md");
22133
- if (!existsSync3(file)) writeFileSync3(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
22134
- appendFileSync(file, `
22135
- ${entry.trim()}
22136
- `);
22137
- }
22232
+ var takeRaw2 = (source, folder) => takeRaw(source, folder, studio.inbox());
22233
+ var startVideoNotes2 = (folder, transcript) => startVideoNotes(folder, transcript, "I say");
22234
+ var { setStatus: setStatus2, addCheck: addCheck2 } = studio_core_exports;
22138
22235
 
22139
22236
  // src/community/local/render.ts
22140
22237
  var HF = "hyperframes@0.8.99";
22141
- var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
22142
- var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
22238
+ var day3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
22239
+ var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
22143
22240
  var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
22144
22241
  var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
22145
- var projectFile = (folder) => join5(folder, ".cutroom", "project.json");
22242
+ var projectFile = (folder) => join6(folder, ".cutroom", "project.json");
22146
22243
  var resolveProject = (p) => {
22147
22244
  const folder = resolve3(p.replace(/^~/, homedir2()));
22148
- const file = [folder, join5(studio.videos(), p), join5(HOME(), "renders", p)].map(projectFile).find((f) => existsSync4(f)) ?? null;
22245
+ const file = [folder, join6(studio.videos(), p), join6(HOME(), "renders", p)].map(projectFile).find((f) => existsSync5(f)) ?? null;
22149
22246
  if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
22150
22247
  return JSON.parse(readFileSync5(file, "utf8"));
22151
22248
  };
@@ -22163,19 +22260,19 @@ function looksLikeSpeech(words, duration3) {
22163
22260
  }
22164
22261
  async function prepareFootage(input) {
22165
22262
  const source = resolve3(input.video.replace(/^~/, homedir2()));
22166
- if (!existsSync4(source)) throw new Error(`No such file: ${source}`);
22263
+ if (!existsSync5(source)) throw new Error(`No such file: ${source}`);
22167
22264
  const p = probe(source);
22168
22265
  if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
22169
22266
  if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
22170
- const name = slug2(input.name ?? basename2(source, extname(source)));
22267
+ const name = slug3(input.name ?? basename2(source, extname(source)));
22171
22268
  ensureStudio();
22172
- const folder = join5(studio.videos(), `${day2()}-${name}`);
22173
- const work = join5(folder, ".cutroom");
22269
+ const folder = join6(studio.videos(), `${day3()}-${name}`);
22270
+ const work = join6(folder, ".cutroom");
22174
22271
  mkdirSync5(work, { recursive: true });
22175
- const clip = takeRaw(source, folder);
22272
+ const clip = takeRaw2(source, folder);
22176
22273
  const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
22177
22274
  if (!segments.length) throw new Error("Couldn't find any speech in that video.");
22178
- const footage = join5(work, "footage.mp4");
22275
+ const footage = join6(work, "footage.mp4");
22179
22276
  cutAndNormalize(clip.file, footage, segments);
22180
22277
  const duration3 = probe(footage).duration;
22181
22278
  const lang = (input.language ?? "en").toLowerCase().slice(0, 5);
@@ -22189,13 +22286,13 @@ async function prepareFootage(input) {
22189
22286
  })();
22190
22287
  if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
22191
22288
  ${meta2.error ?? tail(t.stderr)}`);
22192
- const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(work, "transcript.json"), "utf8"));
22289
+ const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join6(work, "transcript.json"), "utf8"));
22193
22290
  const words = raw.map((w) => ({ t: Math.round(w.start * 100) / 100, e: Math.round(w.end * 100) / 100, w: w.text.trim() })).filter((w) => w.w);
22194
22291
  if (!looksLikeSpeech(words, duration3)) throw new Error(lang === "en" ? "I couldn't hear anyone talking in this video (it sounds like music or silence). Cutroom finishing needs you talking to camera. If you speak another language, tell me which one and I'll try again." : `I couldn't make out the speech in this video, even in that language. Check the sound is you talking to camera.`);
22195
22292
  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 };
22196
- writeFileSync4(projectFile(folder), JSON.stringify(project, null, 2));
22197
- startVideoNotes(folder, words.map((w) => w.w).join(" "));
22198
- setStatus(folder, "footage ready");
22293
+ writeFileSync5(projectFile(folder), JSON.stringify(project, null, 2));
22294
+ startVideoNotes2(folder, words.map((w) => w.w).join(" "));
22295
+ setStatus2(folder, "footage ready");
22199
22296
  const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
22200
22297
  return {
22201
22298
  project: folder,
@@ -22204,19 +22301,19 @@ ${meta2.error ?? tail(t.stderr)}`);
22204
22301
  pauses_removed_s: project.removed_s,
22205
22302
  ...landscape ? { warning: "This video is landscape (wide). Cutroom crops the middle of it to make it vertical, so anything off-center gets cut, and an already-edited video (split screen, titles, captions burned in) will look wrong. Tell the user plainly and ask for the raw vertical take of them talking; continue only if they say this one is fine." } : {},
22206
22303
  transcript: words.map((w) => `${w.t.toFixed(2)} ${w.w}`).join("\n"),
22207
- next: "Check the transcript for misheard words (pass them as fixes). Then plan: list_styles, get_method('edit_plan') and follow it, then plan_reel to show the storyboard. Nothing renders until the user says go."
22304
+ next: "Check the transcript for misheard words (pass them as fixes). References for this video go in its references/ folder. Then plan: list_styles, get_method('edit_plan') and follow it, then plan_reel to show the storyboard. Nothing renders until the user says go."
22208
22305
  };
22209
22306
  }
22210
22307
  async function ensureFonts(work) {
22211
- const cache = join5(HOME(), ".kit", "fonts");
22308
+ const cache = join6(HOME(), ".kit", "fonts");
22212
22309
  const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
22213
- if (!need.every((f) => existsSync4(join5(cache, f)))) {
22310
+ if (!need.every((f) => existsSync5(join6(cache, f)))) {
22214
22311
  const { fonts } = await call("fonts");
22215
22312
  mkdirSync5(cache, { recursive: true });
22216
- for (const [f, b64] of Object.entries(fonts)) writeFileSync4(join5(cache, basename2(f)), Buffer.from(b64, "base64"));
22313
+ for (const [f, b64] of Object.entries(fonts)) writeFileSync5(join6(cache, basename2(f)), Buffer.from(b64, "base64"));
22217
22314
  }
22218
- mkdirSync5(join5(work, "fonts"), { recursive: true });
22219
- for (const f of need) writeFileSync4(join5(work, "fonts", f), readFileSync5(join5(cache, f)));
22315
+ mkdirSync5(join6(work, "fonts"), { recursive: true });
22316
+ for (const f of need) writeFileSync5(join6(work, "fonts", f), readFileSync5(join6(cache, f)));
22220
22317
  }
22221
22318
  function applyFixes(words, fixes = []) {
22222
22319
  return words.map((w) => {
@@ -22224,9 +22321,9 @@ function applyFixes(words, fixes = []) {
22224
22321
  return f ? { ...w, w: f.to } : w;
22225
22322
  }).filter((w) => w.w);
22226
22323
  }
22227
- var brandFile = () => join5(studio.brand(), "brand.json");
22324
+ var brandFile = () => join6(studio.brand(), "brand.json");
22228
22325
  function getBrand() {
22229
- for (const f of [brandFile(), join5(HOME(), "brand.json")]) {
22326
+ for (const f of [brandFile(), join6(HOME(), "brand.json")]) {
22230
22327
  try {
22231
22328
  return JSON.parse(readFileSync5(f, "utf8"));
22232
22329
  } catch {
@@ -22236,14 +22333,14 @@ function getBrand() {
22236
22333
  }
22237
22334
  async function setBrand(input) {
22238
22335
  if (input.reset) {
22239
- rmSync2(brandFile(), { force: true });
22336
+ rmSync3(brandFile(), { force: true });
22240
22337
  return { brand: null, saved: "Back to the default look." };
22241
22338
  }
22242
22339
  const { reset, ...fields } = input;
22243
22340
  const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
22244
22341
  await call("compose", { duration: 2, words: [], brand: next, check_only: true });
22245
22342
  ensureStudio();
22246
- writeFileSync4(brandFile(), JSON.stringify(next, null, 2));
22343
+ writeFileSync5(brandFile(), JSON.stringify(next, null, 2));
22247
22344
  return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
22248
22345
  }
22249
22346
  var PICTURE = /\.(jpe?g|png|webp)$/i;
@@ -22251,21 +22348,21 @@ function safeAssetName(file, i) {
22251
22348
  const m = basename2(file).match(PICTURE);
22252
22349
  if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
22253
22350
  const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
22254
- return `${i + 1}-${slug2(basename2(file, extname(file)))}.${ext}`;
22351
+ return `${i + 1}-${slug3(basename2(file, extname(file)))}.${ext}`;
22255
22352
  }
22256
22353
  function localImages(work, layoutImages, effects) {
22257
22354
  const names = /* @__PURE__ */ new Map();
22258
- const dir = join5(work, "assets");
22355
+ const dir = join6(work, "assets");
22259
22356
  const swap = (v) => {
22260
22357
  const raw = String(v ?? "").trim();
22261
- if (/^assets\/[\w.-]+$/.test(raw) && existsSync4(join5(work, raw))) return raw;
22358
+ if (/^assets\/[\w.-]+$/.test(raw) && existsSync5(join6(work, raw))) return raw;
22262
22359
  const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir2()));
22263
22360
  if (!names.has(file)) {
22264
- if (!existsSync4(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.`);
22361
+ if (!existsSync5(file) || !statSync(file).isFile()) throw new Error(`Can't find the picture ${raw}. Use the full path of a jpg, png or webp file on this computer.`);
22265
22362
  if (names.size >= 8) throw new Error("8 pictures at most per reel.");
22266
22363
  const name = safeAssetName(file, names.size);
22267
22364
  mkdirSync5(dir, { recursive: true });
22268
- copyFileSync2(file, join5(dir, name));
22365
+ copyFileSync2(file, join6(dir, name));
22269
22366
  names.set(file, `assets/${name}`);
22270
22367
  }
22271
22368
  return names.get(file);
@@ -22278,10 +22375,10 @@ function localImages(work, layoutImages, effects) {
22278
22375
  }));
22279
22376
  return { images, effects: fx, count: names.size };
22280
22377
  }
22281
- async function wantsCutout(layout, effects) {
22378
+ async function wantsCutout(layout2, effects) {
22282
22379
  const styles = await call("styles");
22283
22380
  const slugs = new Set(effects.map((f) => String(f.slug)));
22284
- return [...styles.layouts.filter((l) => l.slug === (layout ?? "full-frame")), ...styles.effects.filter((e) => slugs.has(e.slug))].some((x) => x.needs?.includes("cutout"));
22381
+ return [...styles.layouts.filter((l) => l.slug === (layout2 ?? "full-frame")), ...styles.effects.filter((e) => slugs.has(e.slug))].some((x) => x.needs?.includes("cutout"));
22285
22382
  }
22286
22383
  function headTop(work, duration3) {
22287
22384
  const tops = [];
@@ -22302,7 +22399,7 @@ function headTop(work, duration3) {
22302
22399
  return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
22303
22400
  }
22304
22401
  function ensureCutout(work, duration3) {
22305
- const footage = join5(work, "footage.mp4"), cut = join5(work, "cut.webm"), stamp = join5(work, "cut.json"), still = join5(work, "assets", "still.jpg");
22402
+ const footage = join6(work, "footage.mp4"), cut = join6(work, "cut.webm"), stamp = join6(work, "cut.json"), still = join6(work, "assets", "still.jpg");
22306
22403
  const st = statSync(footage);
22307
22404
  const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
22308
22405
  let cached2 = null;
@@ -22311,31 +22408,31 @@ function ensureCutout(work, duration3) {
22311
22408
  } catch {
22312
22409
  }
22313
22410
  let made_s = 0;
22314
- if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync4(cut))) {
22411
+ if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync5(cut))) {
22315
22412
  const started = Date.now();
22316
22413
  const r = sh("npx", ["--yes", HF, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
22317
- if (r.status !== 0 || !existsSync4(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22414
+ if (r.status !== 0 || !existsSync5(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
22318
22415
  ${tail(r.stderr) || tail(r.stdout)}`);
22319
22416
  made_s = Math.round((Date.now() - started) / 100) / 10;
22320
22417
  cached2 = { ...key, head: headTop(work, duration3), made_s };
22321
- writeFileSync4(stamp, JSON.stringify(cached2));
22418
+ writeFileSync5(stamp, JSON.stringify(cached2));
22322
22419
  }
22323
- if (!existsSync4(still)) {
22324
- mkdirSync5(join5(work, "assets"), { recursive: true });
22420
+ if (!existsSync5(still)) {
22421
+ mkdirSync5(join6(work, "assets"), { recursive: true });
22325
22422
  sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
22326
22423
  }
22327
22424
  return { head: cached2.head, made_s };
22328
22425
  }
22329
- var planFile = (folder) => join5(folder, ".cutroom", "plan.json");
22426
+ var planFile = (folder) => join6(folder, ".cutroom", "plan.json");
22330
22427
  async function composition(input) {
22331
22428
  const pj = resolveProject(input.project);
22332
- const work = join5(pj.folder, ".cutroom");
22429
+ const work = join6(pj.folder, ".cutroom");
22333
22430
  const words = applyFixes(pj.words, input.fixes);
22334
22431
  const local = localImages(work, input.images, input.effects ?? []);
22335
22432
  const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
22336
22433
  const { html } = await call("compose", { duration: pj.duration, words, layout: input.layout, caption: input.caption, effects: local.effects, images: local.images, emphasis: input.emphasis, brand: getBrand() ?? void 0, ...cutout ? { cutout: true, head: cutout.head } : {} });
22337
22434
  await ensureFonts(work);
22338
- writeFileSync4(join5(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22435
+ writeFileSync5(join6(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
22339
22436
  return { pj, work, html, cutout, words };
22340
22437
  }
22341
22438
  var fmtT = (t) => `${t.toFixed(1)}s`;
@@ -22351,7 +22448,7 @@ async function planReel(input) {
22351
22448
  };
22352
22449
  const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
22353
22450
  const styles = await call("styles");
22354
- const title = (list, slug3) => (list.find((x) => x.slug === slug3)?.title ?? String(slug3)).replace(/\s*\(.*\)$/, "");
22451
+ const title = (list, slug4) => (list.find((x) => x.slug === slug4)?.title ?? String(slug4)).replace(/\s*\(.*\)$/, "");
22355
22452
  const sentences = words.reduce((out, w, k) => {
22356
22453
  if (!out.length || /[.!?]["”']?$/.test(words[k - 1]?.w ?? "")) out.push({ t: w.t, text: [] });
22357
22454
  out[out.length - 1].text.push(w.w);
@@ -22385,31 +22482,31 @@ async function planReel(input) {
22385
22482
  return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
22386
22483
  })
22387
22484
  ];
22388
- const shots = join5(work, "storyboard");
22389
- rmSync2(shots, { recursive: true, force: true });
22390
- writeFileSync4(join5(work, "index.html"), html);
22485
+ const shots = join6(work, "storyboard");
22486
+ rmSync3(shots, { recursive: true, force: true });
22487
+ writeFileSync5(join6(work, "index.html"), html);
22391
22488
  let r;
22392
22489
  try {
22393
22490
  r = sh("npx", ["--yes", HF, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
22394
22491
  } finally {
22395
- rmSync2(join5(work, "index.html"), { force: true });
22492
+ rmSync3(join6(work, "index.html"), { force: true });
22396
22493
  }
22397
- const pngs = existsSync4(shots) ? readdirSync(shots).filter((f) => f.endsWith(".png")).sort() : [];
22494
+ const pngs = existsSync5(shots) ? readdirSync2(shots).filter((f) => f.endsWith(".png")).sort() : [];
22398
22495
  if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
22399
22496
  ${tail(r.stderr) || tail(r.stdout)}`);
22400
- const board = join5(pj.folder, "storyboard.jpg");
22497
+ const board = join6(pj.folder, "storyboard.jpg");
22401
22498
  const cols = Math.min(4, moments.length);
22402
22499
  const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
22403
- const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join5(shots, f)]);
22500
+ const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join6(shots, f)]);
22404
22501
  const cells = moments.map((m, k) => `[${k}:v]scale=270:480,pad=270:540:0:0:#0A0A0A,drawtext=text='${label(`${fmtT(m.t)} ${m.tag}`)}':x=10:y=496:fontsize=18:fontcolor=#F7F3E8[c${k}]`).join(";");
22405
22502
  const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
22406
22503
  const stack = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
22407
22504
  const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
22408
22505
  if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
22409
22506
  ${tail(t.stderr)}`);
22410
- rmSync2(shots, { recursive: true, force: true });
22507
+ rmSync3(shots, { recursive: true, force: true });
22411
22508
  const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
22412
- writeFileSync4(planFile(pj.folder), JSON.stringify(plan, null, 2));
22509
+ writeFileSync5(planFile(pj.folder), JSON.stringify(plan, null, 2));
22413
22510
  const text = [
22414
22511
  `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)." : ""}`,
22415
22512
  "",
@@ -22419,41 +22516,41 @@ ${tail(t.stderr)}`);
22419
22516
  "",
22420
22517
  `Storyboard: ${board}`
22421
22518
  ].join("\n");
22422
- setStatus(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
22519
+ setStatus2(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
22423
22520
  return { text, storyboard: board, plan, words_used: words.length, next: "Show this table as it is (plain words, no effect codes) and the storyboard. On changes, plan_reel again with the new choice. On a go, render_reel with plan:true and draft:true." };
22424
22521
  }
22425
22522
  async function renderReel(input) {
22426
22523
  if (input.plan) {
22427
22524
  const folder = resolveProject(input.project).folder;
22428
- if (!existsSync4(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
22525
+ if (!existsSync5(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
22429
22526
  input = { ...JSON.parse(readFileSync5(planFile(folder), "utf8")), project: input.project, draft: input.draft };
22430
22527
  }
22431
22528
  const { pj, work, html, cutout } = await composition(input);
22432
- mkdirSync5(join5(pj.folder, "renders"), { recursive: true });
22433
- const out = join5(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22434
- const tmp = join5(work, "render.mp4");
22435
- writeFileSync4(join5(work, "index.html"), html);
22529
+ mkdirSync5(join6(pj.folder, "renders"), { recursive: true });
22530
+ const out = join6(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22531
+ const tmp = join6(work, "render.mp4");
22532
+ writeFileSync5(join6(work, "index.html"), html);
22436
22533
  const started = Date.now();
22437
22534
  let r;
22438
22535
  try {
22439
22536
  r = sh("npx", ["--yes", HF, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
22440
22537
  } finally {
22441
- rmSync2(join5(work, "index.html"), { force: true });
22538
+ rmSync3(join6(work, "index.html"), { force: true });
22442
22539
  }
22443
- if (r.status !== 0 || !existsSync4(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22540
+ if (r.status !== 0 || !existsSync5(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
22444
22541
  ${tail(r.stderr) || tail(r.stdout)}`);
22445
- renameSync2(tmp, out);
22542
+ renameSync3(tmp, out);
22446
22543
  const render_s = Math.round((Date.now() - started) / 100) / 10;
22447
22544
  const { report: checks, ...qa } = await qaVideo({ video: out, layout: input.layout });
22448
- addCheck(pj.folder, checks);
22449
- setStatus(pj.folder, input.draft ? "preview" : "final");
22545
+ addCheck2(pj.folder, checks);
22546
+ setStatus2(pj.folder, input.draft ? "preview" : "final");
22450
22547
  return { video: out, render_s, ...cutout?.made_s ? { cutout_s: cutout.made_s } : {}, qa, next: qa.passed ? "Watch it once, then it's ready to post." : qa.technical?.length ? "Fix the technical issues listed, then render again." : "Look at each flagged spot yourself (grab the frame) and tell the user which are real problems, with the fix, and which are the design." };
22451
22548
  }
22452
22549
  async function qaVideo(input) {
22453
22550
  const file = resolve3(input.video.replace(/^~/, homedir2()));
22454
- if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
22551
+ if (!existsSync5(file)) throw new Error(`No such file: ${file}`);
22455
22552
  const tech = technicalChecks(file);
22456
- const dir = mkdtempSync(join5(tmpdir(), "cutroom-qa-"));
22553
+ const dir = mkdtempSync(join6(tmpdir(), "cutroom-qa-"));
22457
22554
  const { frames } = sampleFrames(file, dir, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
22458
22555
  let flags = [];
22459
22556
  let judged = 0, judge_error = null;
@@ -22464,7 +22561,7 @@ async function qaVideo(input) {
22464
22561
  } catch (e) {
22465
22562
  judge_error = e.message;
22466
22563
  }
22467
- rmSync2(dir, { recursive: true, force: true });
22564
+ rmSync3(dir, { recursive: true, force: true });
22468
22565
  const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
22469
22566
  const passed = !tech.issues.length && !flags.length && !judge_error;
22470
22567
  const report = {
@@ -22489,8 +22586,76 @@ async function qaVideo(input) {
22489
22586
  return { ...report, report: md };
22490
22587
  }
22491
22588
 
22589
+ // src/community/local/prompts.ts
22590
+ var say = (label, value) => value?.trim() ? ` ${label}: ${value.trim()}.` : "";
22591
+ var PROMPTS = [
22592
+ {
22593
+ name: "copy-reel",
22594
+ title: "Copy a reel's format",
22595
+ description: "Your version of a reel: its structure, pacing and look, with your topic and words.",
22596
+ args: [{ name: "link", description: "The reel (Instagram link or a video file path)" }, { name: "topic", description: "Your topic (optional)" }],
22597
+ ask: (a) => `Copy a reel's format for me: get_method('copy_reel') and follow it step by step.${say("Reel", a.link)}${say("My topic", a.topic)}${a.link ? "" : " Ask me for the reel first."}`
22598
+ },
22599
+ {
22600
+ name: "finish-video",
22601
+ title: "Finish a raw video",
22602
+ description: "Turn a raw video of you talking into a finished reel: captions, effects, your brand.",
22603
+ args: [{ name: "video", description: "The raw video file (optional: otherwise the newest in your inbox)" }],
22604
+ ask: (a) => `Finish my raw video into a reel: get_method('finish_video') and follow it step by step.${say("Video", a.video)}${a.video ? "" : " Use the newest video in my studio's inbox/ and tell me which one you picked."}`
22605
+ },
22606
+ {
22607
+ name: "breakdown",
22608
+ title: "Break down a reel",
22609
+ description: "Why a reel works: hook, beats, pacing and the look, with how you'd make your own.",
22610
+ args: [{ name: "link", description: "The reel (Instagram link or a video file path)" }],
22611
+ ask: (a) => `Break down this reel: breakdown_reel, then get_method('breakdown_writeup') and follow it.${say("Reel", a.link)}${a.link ? "" : " Ask me for the reel first."}`
22612
+ },
22613
+ {
22614
+ name: "teardown",
22615
+ title: "Tear down a creator",
22616
+ description: "A creator's system from their latest reels, and the gap to yours.",
22617
+ args: [{ name: "handle", description: "Their Instagram handle" }, { name: "compare_to", description: "Your handle (optional)" }],
22618
+ ask: (a) => `Tear down this creator: creator_teardown, then get_method('teardown_writeup') and follow it.${say("Their handle", a.handle)}${say("Compare to my handle", a.compare_to)}${a.handle ? "" : " Ask me for their handle first."}`
22619
+ },
22620
+ {
22621
+ name: "hooks",
22622
+ title: "Write hooks",
22623
+ description: "Hooks for your next video, scored and ranked.",
22624
+ args: [{ name: "topic", description: "What the video is about" }],
22625
+ ask: (a) => `Write hooks for my next video: get_method('hooks') and follow it.${say("Topic", a.topic)}${a.topic ? "" : " Ask me for the topic first."}`
22626
+ },
22627
+ {
22628
+ name: "script",
22629
+ title: "Write a short script",
22630
+ description: "An 8-line short script for one idea, scored.",
22631
+ args: [{ name: "topic", description: "The idea" }],
22632
+ ask: (a) => `Write a short script: get_method('script') and follow it.${say("Idea", a.topic)}${a.topic ? "" : " Ask me for the idea first."}`
22633
+ },
22634
+ {
22635
+ name: "caption",
22636
+ title: "Write the post caption",
22637
+ description: "The caption and comment-keyword CTA for a finished video.",
22638
+ args: [{ name: "topic", description: "The video (optional)" }],
22639
+ ask: (a) => `Write the post caption: get_method('caption') and follow it.${say("Video", a.topic)}`
22640
+ },
22641
+ {
22642
+ name: "brand",
22643
+ title: "Set your brand",
22644
+ description: "See or change the colors and font every render uses.",
22645
+ args: [],
22646
+ ask: () => "Show my Cutroom brand kit with show_brand. If none is set, offer to set one with set_brand (from colors, a website or a logo), show me the colors, and save only on my yes."
22647
+ },
22648
+ {
22649
+ name: "doctor",
22650
+ title: "Check Cutroom setup",
22651
+ description: "Check everything Cutroom needs on this computer and fix what's missing.",
22652
+ args: [],
22653
+ ask: () => "Check my Cutroom setup with cutroom_doctor and give me a short checklist with the fix for anything missing."
22654
+ }
22655
+ ];
22656
+
22492
22657
  // src/community/local/server.ts
22493
- process.env.PATH = (WIN ? [process.env.PATH, join6(process.env.LOCALAPPDATA ?? join6(homedir3(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join6(homedir3(), ".local", "bin")]).filter(Boolean).join(delimiter);
22658
+ process.env.PATH = (WIN ? [process.env.PATH, join7(process.env.LOCALAPPDATA ?? join7(homedir3(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join7(homedir3(), ".local", "bin")]).filter(Boolean).join(delimiter);
22494
22659
  var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
22495
22660
  var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
22496
22661
  var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
@@ -22501,6 +22666,9 @@ Setup or any failure: cutroom_doctor, then a short checklist. If they aren't sig
22501
22666
  Finish a video (a raw video of them talking: edit it, captions, effects, make it a reel): get_method('finish_video') first and follow it step by step. Nothing renders until they approve the plan.
22502
22667
 
22503
22668
  Hooks, scripts, captions: get_method('hooks', 'script' or 'caption') and follow it.
22669
+
22670
+ Copy a reel (their own version of a reel's format, on their topic): get_method('copy_reel') and follow it step by step. Nothing gets filmed or rendered until they approve the script.
22671
+
22504
22672
  Break down a reel: breakdown_reel, then get_method('breakdown_writeup').
22505
22673
  Tear down a creator: creator_teardown (compare_to = their handle), then get_method('teardown_writeup').
22506
22674
 
@@ -22508,7 +22676,7 @@ Before a first render: show_brand; if none, offer set_brand (from colors, a webs
22508
22676
 
22509
22677
  QA flags are spots to check: look at each flagged frame yourself and say which are real problems (with the fix) and which are the design.
22510
22678
 
22511
- Their studio is the Cutroom folder in their home folder (cutroom_doctor returns the path): inbox/ for new raw videos (look there first when they say "my video" without a path), videos/<date>-<topic>/ with everything for one video (raw/, script.md, facts.md, plan.md with its status line, renders/, checks.md), brand/, references/ for breakdowns and teardowns, and CLAUDE.md with their rules. Put every number shown on screen in that video's facts.md with its source; when QA finds a real mistake, offer to add a rule to their CLAUDE.md. To list videos or what's unfinished, read the status lines in videos/*/plan.md. Other creators' reels are for study only: never reuse their words, footage or branding.`;
22679
+ Their studio is the Cutroom folder in their home folder (cutroom_doctor returns the path): inbox/ for new raw videos (look there first when they say "my video" without a path), videos/<date>-<topic>/ with everything for one video (raw/, references/, script.md, facts.md, plan.md with its status line, renders/, checks.md), brand/, references/ as the library for breakdowns and teardowns, and CLAUDE.md with their rules. When they give a reference for one video (an image, a link, a reel to match), save it in that video's references/ and add a line to its references/notes.md saying what it is and why it works; pictures for plan_reel and render_reel can come from there. breakdown_reel and creator_teardown still go to the top-level references/ library. Put every number shown on screen in that video's facts.md with its source; when QA finds a real mistake, offer to add a rule to their CLAUDE.md. To list videos or what's unfinished, read the status lines in videos/*/plan.md. Other creators' reels are for study only: never reuse their words, footage or branding.`;
22512
22680
  await serveStdio(() => {
22513
22681
  const server = new McpServer({ name: "cutroom", version: VERSION }, { instructions: INSTRUCTIONS });
22514
22682
  server.registerTool("cutroom_doctor", {
@@ -22524,7 +22692,7 @@ await serveStdio(() => {
22524
22692
  inputSchema: object({})
22525
22693
  }, async () => safe(() => call("methods")));
22526
22694
  server.registerTool("get_method", {
22527
- description: "Get a Cutroom method and follow it step by step: 'hooks' for video hooks, 'script' for an 8-line short script, 'caption' for the post caption and comment CTA, 'breakdown_writeup' and 'teardown_writeup' after those tools. Always fetch the method fresh; it is updated on the server.",
22695
+ description: "Get a Cutroom method and follow it step by step: 'hooks' for video hooks, 'script' for an 8-line short script, 'caption' for the post caption and comment CTA, 'copy_reel' to copy a reel's format onto their topic, 'breakdown_writeup' and 'teardown_writeup' after those tools. Always fetch the method fresh; it is updated on the server.",
22528
22696
  inputSchema: object({ name: string2().min(1) })
22529
22697
  }, async ({ name }) => safe(async () => {
22530
22698
  const m = await call("method", { name });
@@ -22615,7 +22783,13 @@ ${r.next}` }, { type: "image", data: readFileSync6(r.storyboard).toString("base6
22615
22783
  server.registerTool("qa_video", {
22616
22784
  description: "QA any finished vertical video: black or frozen picture, loudness, dead air, size, and every sampled frame judged for visual problems (cut-off text, covered face, empty frames). Returns passed plus the issues with timestamps.",
22617
22785
  inputSchema: object({ video: string2().min(1), layout: string2().optional().describe("The layout used, if it puts the face in a card (e.g. explainer-panel), so the card edge is not flagged.") })
22618
- }, async ({ video, layout }) => safe(() => qaVideo({ video, layout })));
22786
+ }, async ({ video, layout: layout2 }) => safe(() => qaVideo({ video, layout: layout2 })));
22787
+ for (const p of PROMPTS) {
22788
+ const shape = Object.fromEntries(p.args.map((a) => [a.name, string2().optional().describe(a.description)]));
22789
+ server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema: object(shape) }, (args) => ({
22790
+ messages: [{ role: "user", content: { type: "text", text: p.ask(args ?? {}) } }]
22791
+ }));
22792
+ }
22619
22793
  return server;
22620
22794
  });
22621
22795
  /*! Bundled license information:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepylabs/cutroom",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": {