@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.
- package/README.md +2 -1
- package/cutroom.mjs +349 -175
- 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
|
|
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
|
|
19205
|
-
return month >= 1 && month <= 12 &&
|
|
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
|
|
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
|
|
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 {
|
|
21662
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = () =>
|
|
21825
|
+
var keyFile = () => join3(HOME(), ".cutroom-key");
|
|
21681
21826
|
function currentKey() {
|
|
21682
21827
|
try {
|
|
21683
|
-
const k =
|
|
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
|
-
|
|
21692
|
-
|
|
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 = () =>
|
|
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.
|
|
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 ||
|
|
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
|
|
21814
|
-
var
|
|
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(
|
|
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 =
|
|
21898
|
-
|
|
21899
|
-
file =
|
|
21900
|
-
if (!
|
|
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 =
|
|
21903
|
-
if (!
|
|
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) ?
|
|
21906
|
-
|
|
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,
|
|
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
|
-
|
|
21949
|
-
const out = { folder: dir, report:
|
|
21950
|
-
|
|
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 =
|
|
21960
|
-
|
|
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 =
|
|
21965
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
22040
|
-
const out = { folder: a.dir, report:
|
|
22041
|
-
|
|
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: () =>
|
|
22048
|
-
references: () =>
|
|
22049
|
-
inbox: () =>
|
|
22050
|
-
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
|
-
|
|
22078
|
-
const
|
|
22079
|
-
if (!
|
|
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
|
|
22085
|
-
|
|
22086
|
-
|
|
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
|
|
22142
|
-
var
|
|
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) =>
|
|
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,
|
|
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 (!
|
|
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 =
|
|
22267
|
+
const name = slug3(input.name ?? basename2(source, extname(source)));
|
|
22171
22268
|
ensureStudio();
|
|
22172
|
-
const folder =
|
|
22173
|
-
const work =
|
|
22269
|
+
const folder = join6(studio.videos(), `${day3()}-${name}`);
|
|
22270
|
+
const work = join6(folder, ".cutroom");
|
|
22174
22271
|
mkdirSync5(work, { recursive: true });
|
|
22175
|
-
const clip =
|
|
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 =
|
|
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 ??
|
|
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
|
-
|
|
22197
|
-
|
|
22198
|
-
|
|
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 =
|
|
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) =>
|
|
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))
|
|
22313
|
+
for (const [f, b64] of Object.entries(fonts)) writeFileSync5(join6(cache, basename2(f)), Buffer.from(b64, "base64"));
|
|
22217
22314
|
}
|
|
22218
|
-
mkdirSync5(
|
|
22219
|
-
for (const f of need)
|
|
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 = () =>
|
|
22324
|
+
var brandFile = () => join6(studio.brand(), "brand.json");
|
|
22228
22325
|
function getBrand() {
|
|
22229
|
-
for (const f of [brandFile(),
|
|
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
|
-
|
|
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
|
-
|
|
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}-${
|
|
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 =
|
|
22355
|
+
const dir = join6(work, "assets");
|
|
22259
22356
|
const swap = (v) => {
|
|
22260
22357
|
const raw = String(v ?? "").trim();
|
|
22261
|
-
if (/^assets\/[\w.-]+$/.test(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 (!
|
|
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,
|
|
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(
|
|
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 === (
|
|
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 =
|
|
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 &&
|
|
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 || !
|
|
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
|
-
|
|
22418
|
+
writeFileSync5(stamp, JSON.stringify(cached2));
|
|
22322
22419
|
}
|
|
22323
|
-
if (!
|
|
22324
|
-
mkdirSync5(
|
|
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) =>
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
22389
|
-
|
|
22390
|
-
|
|
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
|
-
|
|
22492
|
+
rmSync3(join6(work, "index.html"), { force: true });
|
|
22396
22493
|
}
|
|
22397
|
-
const pngs =
|
|
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 =
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
22433
|
-
const out =
|
|
22434
|
-
const tmp =
|
|
22435
|
-
|
|
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
|
-
|
|
22538
|
+
rmSync3(join6(work, "index.html"), { force: true });
|
|
22442
22539
|
}
|
|
22443
|
-
if (r.status !== 0 || !
|
|
22540
|
+
if (r.status !== 0 || !existsSync5(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
|
|
22444
22541
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22445
|
-
|
|
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
|
-
|
|
22449
|
-
|
|
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 (!
|
|
22551
|
+
if (!existsSync5(file)) throw new Error(`No such file: ${file}`);
|
|
22455
22552
|
const tech = technicalChecks(file);
|
|
22456
|
-
const dir = mkdtempSync(
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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": {
|