@keepylabs/cutroom 0.7.1 → 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 (2) hide show
  1. package/cutroom.mjs +345 -174
  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 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.1";
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
 
@@ -22075,80 +22224,25 @@ Everything for my videos lives in this folder. Open Claude Code here so it can s
22075
22224
  ## My rules
22076
22225
  `;
22077
22226
  function ensureStudio() {
22078
- for (const d of Object.values(studio)) mkdirSync4(d(), { recursive: true });
22079
- const claude = join4(HOME(), "CLAUDE.md");
22080
- if (!existsSync3(claude)) writeFileSync3(claude, CLAUDE_MD);
22081
- const oldBrand = join4(HOME(), "brand.json"), newBrand = join4(studio.brand(), "brand.json");
22082
- 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);
22083
22230
  return HOME();
22084
22231
  }
22085
- var inside = (child, parent) => {
22086
- const r = relative(parent, child);
22087
- return !!r && !r.startsWith("..") && !isAbsolute(r);
22088
- };
22089
- function takeRaw(source, folder) {
22090
- const raw = join4(folder, "raw");
22091
- const file = resolve2(source);
22092
- if (inside(file, raw)) return { file, how: "already here" };
22093
- mkdirSync4(raw, { recursive: true });
22094
- let target = join4(raw, basename(file));
22095
- for (let n = 2; existsSync3(target); n++) target = join4(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
22096
- if (inside(file, studio.inbox())) {
22097
- try {
22098
- renameSync(file, target);
22099
- return { file: target, how: "moved from inbox" };
22100
- } catch {
22101
- }
22102
- }
22103
- copyFileSync(file, target);
22104
- return { file: target, how: "copied" };
22105
- }
22106
- function setStatus(folder, status, plan) {
22107
- const file = join4(folder, "plan.md");
22108
- const name = basename(folder);
22109
- const current = existsSync3(file) ? readFileSync4(file, "utf8") : `# ${name}
22110
-
22111
- status:
22112
- `;
22113
- const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
22114
- status: ${status}
22115
- `);
22116
- writeFileSync3(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
22117
-
22118
- ## Plan
22119
- ${plan}
22120
- `);
22121
- }
22122
- function startVideoNotes(folder, transcript) {
22123
- const script = join4(folder, "script.md"), facts = join4(folder, "facts.md"), notes = join4(folder, "references", "notes.md");
22124
- mkdirSync4(join4(folder, "references"), { recursive: true });
22125
- if (!existsSync3(notes)) writeFileSync3(notes, "# References for this video\n\nWhat this video copies and why it works, one line each.\n\n");
22126
- if (!existsSync3(script)) writeFileSync3(script, `# Script
22127
-
22128
- What I say, word for word (from the footage; fix any misheard words here).
22129
-
22130
- ${transcript}
22131
- `);
22132
- if (!existsSync3(facts)) writeFileSync3(facts, "# Facts\n\nEvery number or claim that shows on screen, with where it comes from.\n\n| on screen | source |\n| - | - |\n");
22133
- }
22134
- function addCheck(folder, entry) {
22135
- const file = join4(folder, "checks.md");
22136
- if (!existsSync3(file)) writeFileSync3(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
22137
- appendFileSync(file, `
22138
- ${entry.trim()}
22139
- `);
22140
- }
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;
22141
22235
 
22142
22236
  // src/community/local/render.ts
22143
22237
  var HF = "hyperframes@0.8.99";
22144
- var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
22145
- 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";
22146
22240
  var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
22147
22241
  var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
22148
- var projectFile = (folder) => join5(folder, ".cutroom", "project.json");
22242
+ var projectFile = (folder) => join6(folder, ".cutroom", "project.json");
22149
22243
  var resolveProject = (p) => {
22150
22244
  const folder = resolve3(p.replace(/^~/, homedir2()));
22151
- 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;
22152
22246
  if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
22153
22247
  return JSON.parse(readFileSync5(file, "utf8"));
22154
22248
  };
@@ -22166,19 +22260,19 @@ function looksLikeSpeech(words, duration3) {
22166
22260
  }
22167
22261
  async function prepareFootage(input) {
22168
22262
  const source = resolve3(input.video.replace(/^~/, homedir2()));
22169
- if (!existsSync4(source)) throw new Error(`No such file: ${source}`);
22263
+ if (!existsSync5(source)) throw new Error(`No such file: ${source}`);
22170
22264
  const p = probe(source);
22171
22265
  if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
22172
22266
  if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
22173
- const name = slug2(input.name ?? basename2(source, extname(source)));
22267
+ const name = slug3(input.name ?? basename2(source, extname(source)));
22174
22268
  ensureStudio();
22175
- const folder = join5(studio.videos(), `${day2()}-${name}`);
22176
- const work = join5(folder, ".cutroom");
22269
+ const folder = join6(studio.videos(), `${day3()}-${name}`);
22270
+ const work = join6(folder, ".cutroom");
22177
22271
  mkdirSync5(work, { recursive: true });
22178
- const clip = takeRaw(source, folder);
22272
+ const clip = takeRaw2(source, folder);
22179
22273
  const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
22180
22274
  if (!segments.length) throw new Error("Couldn't find any speech in that video.");
22181
- const footage = join5(work, "footage.mp4");
22275
+ const footage = join6(work, "footage.mp4");
22182
22276
  cutAndNormalize(clip.file, footage, segments);
22183
22277
  const duration3 = probe(footage).duration;
22184
22278
  const lang = (input.language ?? "en").toLowerCase().slice(0, 5);
@@ -22192,13 +22286,13 @@ async function prepareFootage(input) {
22192
22286
  })();
22193
22287
  if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
22194
22288
  ${meta2.error ?? tail(t.stderr)}`);
22195
- 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"));
22196
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);
22197
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.`);
22198
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 };
22199
- writeFileSync4(projectFile(folder), JSON.stringify(project, null, 2));
22200
- startVideoNotes(folder, words.map((w) => w.w).join(" "));
22201
- 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");
22202
22296
  const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
22203
22297
  return {
22204
22298
  project: folder,
@@ -22211,15 +22305,15 @@ ${meta2.error ?? tail(t.stderr)}`);
22211
22305
  };
22212
22306
  }
22213
22307
  async function ensureFonts(work) {
22214
- const cache = join5(HOME(), ".kit", "fonts");
22308
+ const cache = join6(HOME(), ".kit", "fonts");
22215
22309
  const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
22216
- if (!need.every((f) => existsSync4(join5(cache, f)))) {
22310
+ if (!need.every((f) => existsSync5(join6(cache, f)))) {
22217
22311
  const { fonts } = await call("fonts");
22218
22312
  mkdirSync5(cache, { recursive: true });
22219
- 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"));
22220
22314
  }
22221
- mkdirSync5(join5(work, "fonts"), { recursive: true });
22222
- 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)));
22223
22317
  }
22224
22318
  function applyFixes(words, fixes = []) {
22225
22319
  return words.map((w) => {
@@ -22227,9 +22321,9 @@ function applyFixes(words, fixes = []) {
22227
22321
  return f ? { ...w, w: f.to } : w;
22228
22322
  }).filter((w) => w.w);
22229
22323
  }
22230
- var brandFile = () => join5(studio.brand(), "brand.json");
22324
+ var brandFile = () => join6(studio.brand(), "brand.json");
22231
22325
  function getBrand() {
22232
- for (const f of [brandFile(), join5(HOME(), "brand.json")]) {
22326
+ for (const f of [brandFile(), join6(HOME(), "brand.json")]) {
22233
22327
  try {
22234
22328
  return JSON.parse(readFileSync5(f, "utf8"));
22235
22329
  } catch {
@@ -22239,14 +22333,14 @@ function getBrand() {
22239
22333
  }
22240
22334
  async function setBrand(input) {
22241
22335
  if (input.reset) {
22242
- rmSync2(brandFile(), { force: true });
22336
+ rmSync3(brandFile(), { force: true });
22243
22337
  return { brand: null, saved: "Back to the default look." };
22244
22338
  }
22245
22339
  const { reset, ...fields } = input;
22246
22340
  const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
22247
22341
  await call("compose", { duration: 2, words: [], brand: next, check_only: true });
22248
22342
  ensureStudio();
22249
- writeFileSync4(brandFile(), JSON.stringify(next, null, 2));
22343
+ writeFileSync5(brandFile(), JSON.stringify(next, null, 2));
22250
22344
  return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
22251
22345
  }
22252
22346
  var PICTURE = /\.(jpe?g|png|webp)$/i;
@@ -22254,21 +22348,21 @@ function safeAssetName(file, i) {
22254
22348
  const m = basename2(file).match(PICTURE);
22255
22349
  if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
22256
22350
  const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
22257
- return `${i + 1}-${slug2(basename2(file, extname(file)))}.${ext}`;
22351
+ return `${i + 1}-${slug3(basename2(file, extname(file)))}.${ext}`;
22258
22352
  }
22259
22353
  function localImages(work, layoutImages, effects) {
22260
22354
  const names = /* @__PURE__ */ new Map();
22261
- const dir = join5(work, "assets");
22355
+ const dir = join6(work, "assets");
22262
22356
  const swap = (v) => {
22263
22357
  const raw = String(v ?? "").trim();
22264
- if (/^assets\/[\w.-]+$/.test(raw) && existsSync4(join5(work, raw))) return raw;
22358
+ if (/^assets\/[\w.-]+$/.test(raw) && existsSync5(join6(work, raw))) return raw;
22265
22359
  const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir2()));
22266
22360
  if (!names.has(file)) {
22267
- 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.`);
22268
22362
  if (names.size >= 8) throw new Error("8 pictures at most per reel.");
22269
22363
  const name = safeAssetName(file, names.size);
22270
22364
  mkdirSync5(dir, { recursive: true });
22271
- copyFileSync2(file, join5(dir, name));
22365
+ copyFileSync2(file, join6(dir, name));
22272
22366
  names.set(file, `assets/${name}`);
22273
22367
  }
22274
22368
  return names.get(file);
@@ -22281,10 +22375,10 @@ function localImages(work, layoutImages, effects) {
22281
22375
  }));
22282
22376
  return { images, effects: fx, count: names.size };
22283
22377
  }
22284
- async function wantsCutout(layout, effects) {
22378
+ async function wantsCutout(layout2, effects) {
22285
22379
  const styles = await call("styles");
22286
22380
  const slugs = new Set(effects.map((f) => String(f.slug)));
22287
- 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"));
22288
22382
  }
22289
22383
  function headTop(work, duration3) {
22290
22384
  const tops = [];
@@ -22305,7 +22399,7 @@ function headTop(work, duration3) {
22305
22399
  return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
22306
22400
  }
22307
22401
  function ensureCutout(work, duration3) {
22308
- 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");
22309
22403
  const st = statSync(footage);
22310
22404
  const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
22311
22405
  let cached2 = null;
@@ -22314,31 +22408,31 @@ function ensureCutout(work, duration3) {
22314
22408
  } catch {
22315
22409
  }
22316
22410
  let made_s = 0;
22317
- 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))) {
22318
22412
  const started = Date.now();
22319
22413
  const r = sh("npx", ["--yes", HF, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
22320
- 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.
22321
22415
  ${tail(r.stderr) || tail(r.stdout)}`);
22322
22416
  made_s = Math.round((Date.now() - started) / 100) / 10;
22323
22417
  cached2 = { ...key, head: headTop(work, duration3), made_s };
22324
- writeFileSync4(stamp, JSON.stringify(cached2));
22418
+ writeFileSync5(stamp, JSON.stringify(cached2));
22325
22419
  }
22326
- if (!existsSync4(still)) {
22327
- mkdirSync5(join5(work, "assets"), { recursive: true });
22420
+ if (!existsSync5(still)) {
22421
+ mkdirSync5(join6(work, "assets"), { recursive: true });
22328
22422
  sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
22329
22423
  }
22330
22424
  return { head: cached2.head, made_s };
22331
22425
  }
22332
- var planFile = (folder) => join5(folder, ".cutroom", "plan.json");
22426
+ var planFile = (folder) => join6(folder, ".cutroom", "plan.json");
22333
22427
  async function composition(input) {
22334
22428
  const pj = resolveProject(input.project);
22335
- const work = join5(pj.folder, ".cutroom");
22429
+ const work = join6(pj.folder, ".cutroom");
22336
22430
  const words = applyFixes(pj.words, input.fixes);
22337
22431
  const local = localImages(work, input.images, input.effects ?? []);
22338
22432
  const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
22339
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 } : {} });
22340
22434
  await ensureFonts(work);
22341
- 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 }));
22342
22436
  return { pj, work, html, cutout, words };
22343
22437
  }
22344
22438
  var fmtT = (t) => `${t.toFixed(1)}s`;
@@ -22354,7 +22448,7 @@ async function planReel(input) {
22354
22448
  };
22355
22449
  const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
22356
22450
  const styles = await call("styles");
22357
- 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*\(.*\)$/, "");
22358
22452
  const sentences = words.reduce((out, w, k) => {
22359
22453
  if (!out.length || /[.!?]["”']?$/.test(words[k - 1]?.w ?? "")) out.push({ t: w.t, text: [] });
22360
22454
  out[out.length - 1].text.push(w.w);
@@ -22388,31 +22482,31 @@ async function planReel(input) {
22388
22482
  return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
22389
22483
  })
22390
22484
  ];
22391
- const shots = join5(work, "storyboard");
22392
- rmSync2(shots, { recursive: true, force: true });
22393
- 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);
22394
22488
  let r;
22395
22489
  try {
22396
22490
  r = sh("npx", ["--yes", HF, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
22397
22491
  } finally {
22398
- rmSync2(join5(work, "index.html"), { force: true });
22492
+ rmSync3(join6(work, "index.html"), { force: true });
22399
22493
  }
22400
- 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() : [];
22401
22495
  if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
22402
22496
  ${tail(r.stderr) || tail(r.stdout)}`);
22403
- const board = join5(pj.folder, "storyboard.jpg");
22497
+ const board = join6(pj.folder, "storyboard.jpg");
22404
22498
  const cols = Math.min(4, moments.length);
22405
22499
  const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
22406
- 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)]);
22407
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(";");
22408
22502
  const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
22409
22503
  const stack = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
22410
22504
  const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
22411
22505
  if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
22412
22506
  ${tail(t.stderr)}`);
22413
- rmSync2(shots, { recursive: true, force: true });
22507
+ rmSync3(shots, { recursive: true, force: true });
22414
22508
  const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
22415
- writeFileSync4(planFile(pj.folder), JSON.stringify(plan, null, 2));
22509
+ writeFileSync5(planFile(pj.folder), JSON.stringify(plan, null, 2));
22416
22510
  const text = [
22417
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)." : ""}`,
22418
22512
  "",
@@ -22422,41 +22516,41 @@ ${tail(t.stderr)}`);
22422
22516
  "",
22423
22517
  `Storyboard: ${board}`
22424
22518
  ].join("\n");
22425
- 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"));
22426
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." };
22427
22521
  }
22428
22522
  async function renderReel(input) {
22429
22523
  if (input.plan) {
22430
22524
  const folder = resolveProject(input.project).folder;
22431
- 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.");
22432
22526
  input = { ...JSON.parse(readFileSync5(planFile(folder), "utf8")), project: input.project, draft: input.draft };
22433
22527
  }
22434
22528
  const { pj, work, html, cutout } = await composition(input);
22435
- mkdirSync5(join5(pj.folder, "renders"), { recursive: true });
22436
- const out = join5(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
22437
- const tmp = join5(work, "render.mp4");
22438
- 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);
22439
22533
  const started = Date.now();
22440
22534
  let r;
22441
22535
  try {
22442
22536
  r = sh("npx", ["--yes", HF, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
22443
22537
  } finally {
22444
- rmSync2(join5(work, "index.html"), { force: true });
22538
+ rmSync3(join6(work, "index.html"), { force: true });
22445
22539
  }
22446
- 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.
22447
22541
  ${tail(r.stderr) || tail(r.stdout)}`);
22448
- renameSync2(tmp, out);
22542
+ renameSync3(tmp, out);
22449
22543
  const render_s = Math.round((Date.now() - started) / 100) / 10;
22450
22544
  const { report: checks, ...qa } = await qaVideo({ video: out, layout: input.layout });
22451
- addCheck(pj.folder, checks);
22452
- setStatus(pj.folder, input.draft ? "preview" : "final");
22545
+ addCheck2(pj.folder, checks);
22546
+ setStatus2(pj.folder, input.draft ? "preview" : "final");
22453
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." };
22454
22548
  }
22455
22549
  async function qaVideo(input) {
22456
22550
  const file = resolve3(input.video.replace(/^~/, homedir2()));
22457
- if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
22551
+ if (!existsSync5(file)) throw new Error(`No such file: ${file}`);
22458
22552
  const tech = technicalChecks(file);
22459
- const dir = mkdtempSync(join5(tmpdir(), "cutroom-qa-"));
22553
+ const dir = mkdtempSync(join6(tmpdir(), "cutroom-qa-"));
22460
22554
  const { frames } = sampleFrames(file, dir, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
22461
22555
  let flags = [];
22462
22556
  let judged = 0, judge_error = null;
@@ -22467,7 +22561,7 @@ async function qaVideo(input) {
22467
22561
  } catch (e) {
22468
22562
  judge_error = e.message;
22469
22563
  }
22470
- rmSync2(dir, { recursive: true, force: true });
22564
+ rmSync3(dir, { recursive: true, force: true });
22471
22565
  const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
22472
22566
  const passed = !tech.issues.length && !flags.length && !judge_error;
22473
22567
  const report = {
@@ -22492,8 +22586,76 @@ async function qaVideo(input) {
22492
22586
  return { ...report, report: md };
22493
22587
  }
22494
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
+
22495
22657
  // src/community/local/server.ts
22496
- 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);
22497
22659
  var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
22498
22660
  var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
22499
22661
  var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
@@ -22504,6 +22666,9 @@ Setup or any failure: cutroom_doctor, then a short checklist. If they aren't sig
22504
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.
22505
22667
 
22506
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
+
22507
22672
  Break down a reel: breakdown_reel, then get_method('breakdown_writeup').
22508
22673
  Tear down a creator: creator_teardown (compare_to = their handle), then get_method('teardown_writeup').
22509
22674
 
@@ -22527,7 +22692,7 @@ await serveStdio(() => {
22527
22692
  inputSchema: object({})
22528
22693
  }, async () => safe(() => call("methods")));
22529
22694
  server.registerTool("get_method", {
22530
- 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.",
22531
22696
  inputSchema: object({ name: string2().min(1) })
22532
22697
  }, async ({ name }) => safe(async () => {
22533
22698
  const m = await call("method", { name });
@@ -22618,7 +22783,13 @@ ${r.next}` }, { type: "image", data: readFileSync6(r.storyboard).toString("base6
22618
22783
  server.registerTool("qa_video", {
22619
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.",
22620
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.") })
22621
- }, 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
+ }
22622
22793
  return server;
22623
22794
  });
22624
22795
  /*! Bundled license information:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keepylabs/cutroom",
3
- "version": "0.7.1",
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": {