@keepylabs/cutroom 0.5.0 → 0.5.2
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 +1 -1
- package/cutroom.mjs +216 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Write hooks and scripts with scoring. Break down any reel. Tear down a creator's
|
|
|
5
5
|
## Install (one command)
|
|
6
6
|
In your terminal, with your key from the welcome email:
|
|
7
7
|
|
|
8
|
-
claude mcp add cutroom -s user -e CUTROOM_KEY=ck_yourkey -- npx -y @keepylabs/cutroom
|
|
8
|
+
claude mcp add cutroom -s user -e CUTROOM_KEY=ck_yourkey -- npx -y @keepylabs/cutroom@latest
|
|
9
9
|
|
|
10
10
|
Restart Claude Code and say "check Cutroom is ready". It checks ffmpeg and Node and installs them with your OK.
|
|
11
11
|
|
package/cutroom.mjs
CHANGED
|
@@ -21531,9 +21531,12 @@ function toError(value) {
|
|
|
21531
21531
|
return value instanceof Error ? value : new Error(String(value));
|
|
21532
21532
|
}
|
|
21533
21533
|
|
|
21534
|
+
// src/community/local/server.ts
|
|
21535
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
21536
|
+
|
|
21534
21537
|
// src/community/local/render.ts
|
|
21535
21538
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
21536
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as
|
|
21539
|
+
import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, renameSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
21537
21540
|
import { homedir as homedir2 } from "node:os";
|
|
21538
21541
|
import { basename, extname, join as join3, resolve as resolve2 } from "node:path";
|
|
21539
21542
|
|
|
@@ -21631,7 +21634,7 @@ function technicalChecks(file) {
|
|
|
21631
21634
|
|
|
21632
21635
|
// src/community/local/tools.ts
|
|
21633
21636
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
21634
|
-
import { createWriteStream, existsSync, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
|
|
21637
|
+
import { createWriteStream, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
21635
21638
|
import { homedir } from "node:os";
|
|
21636
21639
|
import { join as join2, resolve } from "node:path";
|
|
21637
21640
|
import { Readable } from "node:stream";
|
|
@@ -21685,8 +21688,28 @@ function approxWords(text, duration3) {
|
|
|
21685
21688
|
const words = (text ?? "").split(/\s+/).filter(Boolean);
|
|
21686
21689
|
return words.map((w, i) => ({ t: i / Math.max(words.length, 1) * duration3, w }));
|
|
21687
21690
|
}
|
|
21691
|
+
var MIN_BEAT = 4;
|
|
21692
|
+
function beatBounds(duration3, cuts, words) {
|
|
21693
|
+
const ends = words.filter((w) => /[.!?]["”']?$/.test(w.w)).map((w) => w.e ?? w.t);
|
|
21694
|
+
const all = [...new Set([...cuts, ...ends].map((x) => Math.round(x * 10) / 10))].filter((x) => x > 0 && x < duration3).sort((a, b) => a - b);
|
|
21695
|
+
const out = [0];
|
|
21696
|
+
for (const x of all) if (x - out[out.length - 1] >= MIN_BEAT && duration3 - x >= MIN_BEAT / 2) out.push(x);
|
|
21697
|
+
return [...out, duration3];
|
|
21698
|
+
}
|
|
21699
|
+
function localWords(file, dir) {
|
|
21700
|
+
const r = spawnSync2("npx", ["--yes", "hyperframes@0.8.99", "transcribe", file, "--json", "-d", dir], { cwd: dir, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
21701
|
+
try {
|
|
21702
|
+
const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
|
|
21703
|
+
if (r.status !== 0 || !meta2.ok) return null;
|
|
21704
|
+
const raw = JSON.parse(readFileSync2(meta2.transcriptPath ?? join2(dir, "transcript.json"), "utf8"));
|
|
21705
|
+
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);
|
|
21706
|
+
return words.length ? words : null;
|
|
21707
|
+
} catch {
|
|
21708
|
+
return null;
|
|
21709
|
+
}
|
|
21710
|
+
}
|
|
21688
21711
|
function beats(m, words, frames) {
|
|
21689
|
-
const bounds =
|
|
21712
|
+
const bounds = beatBounds(m.duration, m.cuts, words);
|
|
21690
21713
|
return bounds.slice(1).map((end, i) => {
|
|
21691
21714
|
const start = bounds[i];
|
|
21692
21715
|
const inShot = frames.filter((f) => f.t >= start && f.t < end);
|
|
@@ -21715,8 +21738,10 @@ async function breakdownReel(input) {
|
|
|
21715
21738
|
const m = measure(file);
|
|
21716
21739
|
const { frames, interval } = sampleFrames(file, join2(dir, "frames"), m.duration, 1, 40);
|
|
21717
21740
|
const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
|
|
21718
|
-
const
|
|
21741
|
+
const timed = localWords(file, dir);
|
|
21742
|
+
const words = timed ?? approxWords(transcript, m.duration);
|
|
21719
21743
|
const bs = beats(m, words, match.frames);
|
|
21744
|
+
if (!transcript && timed) transcript = timed.map((w) => w.w).join(" ");
|
|
21720
21745
|
const hook = transcript ? transcript.split(/(?<=[.!?])\s/)[0] : null;
|
|
21721
21746
|
const s = match.summary;
|
|
21722
21747
|
const md = [
|
|
@@ -21739,10 +21764,10 @@ async function breakdownReel(input) {
|
|
|
21739
21764
|
...Object.entries(s.layout_share).map(([k, v]) => `- ${k}: ${pct(v)}`),
|
|
21740
21765
|
"Captions:",
|
|
21741
21766
|
...Object.entries(s.caption_share).map(([k, v]) => `- ${k}: ${pct(v)}`),
|
|
21742
|
-
|
|
21743
|
-
...s.effects.length ? s.effects.map((e) => `- ${e.title}: ${e.spans.map((x) => `${fmt(x.start)}-${fmt(x.end)}`).join(", ")}`) : ["- none from the Cutroom library"],
|
|
21767
|
+
'Effects seen ("in your menu" = render_reel can make it):',
|
|
21768
|
+
...s.effects.length ? s.effects.map((e) => `- ${e.title}${e.in_menu === false ? "" : " (in your menu)"}: ${e.spans.map((x) => `${fmt(x.start)}-${fmt(x.end)}`).join(", ")}`) : ["- none from the Cutroom library"],
|
|
21744
21769
|
"",
|
|
21745
|
-
`## Beats${transcript ? " (words are approximate: spread evenly over the video)" : ""}`,
|
|
21770
|
+
`## Beats${!timed && transcript ? " (words are approximate: spread evenly over the video)" : ""}`,
|
|
21746
21771
|
"| # | time | words | layout | effects |",
|
|
21747
21772
|
"| - | - | - | - | - |",
|
|
21748
21773
|
...bs.map((b) => `| ${b.n} | ${fmt(b.start)}-${fmt(b.end)} | ${b.words.replace(/\|/g, "/") || ""} | ${b.layout} | ${b.effects.join(", ")} |`),
|
|
@@ -21752,7 +21777,7 @@ async function breakdownReel(input) {
|
|
|
21752
21777
|
""
|
|
21753
21778
|
].join("\n");
|
|
21754
21779
|
writeFileSync(join2(dir, "breakdown.md"), md);
|
|
21755
|
-
const out = { folder: dir, report: join2(dir, "breakdown.md"), hook, measure: m, look: s, beats: bs, transcript, meta: meta2, next: "Get the 'breakdown_writeup' method with get_method and follow it." };
|
|
21780
|
+
const out = { folder: dir, report: join2(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." };
|
|
21756
21781
|
writeFileSync(join2(dir, "breakdown.json"), JSON.stringify(out, null, 2));
|
|
21757
21782
|
return out;
|
|
21758
21783
|
}
|
|
@@ -21773,7 +21798,7 @@ async function teardownOne(handle, limit) {
|
|
|
21773
21798
|
const { frames, interval } = sampleFrames(file, join2(dir, "frames", r.shortCode), m.duration, 2, 20);
|
|
21774
21799
|
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 }))]);
|
|
21775
21800
|
const top = (o) => Object.keys(o)[0] ?? null;
|
|
21776
|
-
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), hook: tr.transcript ? String(tr.transcript).split(/(?<=[.!?])\s/)[0] : null, cta: ctaKeyword(r.caption) });
|
|
21801
|
+
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) });
|
|
21777
21802
|
} catch (e) {
|
|
21778
21803
|
rows.push({ code: r.shortCode, url: r.url, posted: String(r.timestamp ?? "").slice(0, 10), plays: r.videoPlayCount ?? null, error: e.message, effects: [] });
|
|
21779
21804
|
}
|
|
@@ -21786,6 +21811,8 @@ function rollup(rows) {
|
|
|
21786
21811
|
const topSet = new Set(byPlays.slice(0, Math.max(1, Math.round(byPlays.length / 4))).map((r) => r.code));
|
|
21787
21812
|
const rest = ok.filter((r) => !topSet.has(r.code));
|
|
21788
21813
|
const names = [...new Set(ok.flatMap((r) => r.effects))];
|
|
21814
|
+
const off = new Set(ok.flatMap((r) => r.off_menu ?? []));
|
|
21815
|
+
const p2 = (n) => Math.round(n * 100) / 100;
|
|
21789
21816
|
const count = (k) => {
|
|
21790
21817
|
const c = {};
|
|
21791
21818
|
for (const r of ok) if (r[k]) c[r[k]] = (c[r[k]] ?? 0) + 1;
|
|
@@ -21801,7 +21828,7 @@ function rollup(rows) {
|
|
|
21801
21828
|
layouts: count("layout"),
|
|
21802
21829
|
captions: count("caption"),
|
|
21803
21830
|
ctas: count("cta"),
|
|
21804
|
-
effects: names.map((n) => ({ effect: n, share: ok.filter((r) => r.effects.includes(n)).length / Math.max(ok.length, 1), top_share: ok.filter((r) => topSet.has(r.code) && r.effects.includes(n)).length / Math.max(topSet.size, 1), rest_share: rest.filter((r) => r.effects.includes(n)).length / Math.max(rest.length, 1) })).sort((a, b) => b.share - a.share)
|
|
21831
|
+
effects: names.map((n) => ({ effect: n, in_menu: !off.has(n), share: p2(ok.filter((r) => r.effects.includes(n)).length / Math.max(ok.length, 1)), top_share: p2(ok.filter((r) => topSet.has(r.code) && r.effects.includes(n)).length / Math.max(topSet.size, 1)), rest_share: p2(rest.filter((r) => r.effects.includes(n)).length / Math.max(rest.length, 1)) })).sort((a, b) => b.share - a.share)
|
|
21805
21832
|
};
|
|
21806
21833
|
}
|
|
21807
21834
|
async function creatorTeardown(input) {
|
|
@@ -21815,7 +21842,7 @@ async function creatorTeardown(input) {
|
|
|
21815
21842
|
const rb = rollup(b.rows);
|
|
21816
21843
|
const mine = Object.fromEntries(rb.effects.map((e) => [e.effect, e.share]));
|
|
21817
21844
|
const regular = r.effects.filter((e) => e.share >= 0.25);
|
|
21818
|
-
cmp = { handle: input.compare_to.replace(/^@/, ""), rollup: rb, missing: regular.filter((e) => !(e.effect in mine)).map((e) => e.effect), underused: regular.filter((e) => e.effect in mine && mine[e.effect] < e.share / 2).map((e) => ({ effect: e.effect, theirs: e.share, yours: mine[e.effect] })) };
|
|
21845
|
+
cmp = { handle: input.compare_to.replace(/^@/, ""), rollup: rb, missing: regular.filter((e) => !(e.effect in mine)).map((e) => ({ effect: e.effect, in_menu: e.in_menu })), underused: regular.filter((e) => e.effect in mine && mine[e.effect] < e.share / 2).map((e) => ({ effect: e.effect, in_menu: e.in_menu, theirs: e.share, yours: mine[e.effect] })) };
|
|
21819
21846
|
}
|
|
21820
21847
|
const md = [
|
|
21821
21848
|
`# Creator teardown: @${handle}`,
|
|
@@ -21832,7 +21859,7 @@ async function creatorTeardown(input) {
|
|
|
21832
21859
|
"| effect | share of reels | top quarter by plays | the rest |",
|
|
21833
21860
|
"| - | - | - | - |",
|
|
21834
21861
|
...r.effects.map((e) => `| ${e.effect} | ${pct(e.share)} | ${pct(e.top_share)} | ${pct(e.rest_share)} |`),
|
|
21835
|
-
...cmp ? ["", `## Gap vs @${cmp.handle}`, ...cmp.missing.length ? [`Never used by @${cmp.handle}:`, ...cmp.missing.map((x) => `- ${x}`)] : ["Nothing they use regularly is entirely missing."], ...cmp.underused.length ? ["Used at under half their rate:", ...cmp.underused.map((u) => `- ${u.effect}: ${pct(u.theirs)} of their reels vs ${pct(u.yours)} of yours`)] : []] : [],
|
|
21862
|
+
...cmp ? ["", `## Gap vs @${cmp.handle}`, ...cmp.missing.length ? [`Never used by @${cmp.handle}:`, ...cmp.missing.map((x) => `- ${x.effect}${x.in_menu ? " (in your menu)" : ""}`)] : ["Nothing they use regularly is entirely missing."], ...cmp.underused.length ? ["Used at under half their rate:", ...cmp.underused.map((u) => `- ${u.effect}${u.in_menu ? " (in your menu)" : ""}: ${pct(u.theirs)} of their reels vs ${pct(u.yours)} of yours`)] : []] : [],
|
|
21836
21863
|
"",
|
|
21837
21864
|
"## Reels",
|
|
21838
21865
|
"| posted | plays | s | cuts/min | layout | effects | hook |",
|
|
@@ -21857,7 +21884,7 @@ var resolveProject = (p) => {
|
|
|
21857
21884
|
const folder = resolve2(p.replace(/^~/, homedir2()));
|
|
21858
21885
|
const file = existsSync2(projectFile(folder)) ? projectFile(folder) : existsSync2(join3(HOME(), "renders", p, ".cutroom", "project.json")) ? join3(HOME(), "renders", p, ".cutroom", "project.json") : null;
|
|
21859
21886
|
if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
|
|
21860
|
-
return JSON.parse(
|
|
21887
|
+
return JSON.parse(readFileSync3(file, "utf8"));
|
|
21861
21888
|
};
|
|
21862
21889
|
function cutAndNormalize(src, out, segments) {
|
|
21863
21890
|
const parts = segments.map(([s, e], i) => `[0:v]trim=${s}:${e},setpts=PTS-STARTPTS[v${i}];[0:a]atrim=${s}:${e},asetpts=PTS-STARTPTS[a${i}];`).join("");
|
|
@@ -21892,7 +21919,7 @@ async function prepareFootage(input) {
|
|
|
21892
21919
|
})();
|
|
21893
21920
|
if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
|
|
21894
21921
|
${meta2.error ?? tail(t.stderr)}`);
|
|
21895
|
-
const raw = JSON.parse(
|
|
21922
|
+
const raw = JSON.parse(readFileSync3(meta2.transcriptPath ?? join3(work, "transcript.json"), "utf8"));
|
|
21896
21923
|
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);
|
|
21897
21924
|
const project = { name, folder, source, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
|
|
21898
21925
|
writeFileSync2(projectFile(folder), JSON.stringify(project, null, 2));
|
|
@@ -21913,7 +21940,7 @@ async function ensureFonts(work) {
|
|
|
21913
21940
|
for (const [f, b64] of Object.entries(fonts)) writeFileSync2(join3(cache, basename(f)), Buffer.from(b64, "base64"));
|
|
21914
21941
|
}
|
|
21915
21942
|
mkdirSync3(join3(work, "fonts"), { recursive: true });
|
|
21916
|
-
for (const f of need) writeFileSync2(join3(work, "fonts", f),
|
|
21943
|
+
for (const f of need) writeFileSync2(join3(work, "fonts", f), readFileSync3(join3(cache, f)));
|
|
21917
21944
|
}
|
|
21918
21945
|
function applyFixes(words, fixes = []) {
|
|
21919
21946
|
return words.map((w) => {
|
|
@@ -21924,7 +21951,7 @@ function applyFixes(words, fixes = []) {
|
|
|
21924
21951
|
var brandFile = () => join3(HOME(), "brand.json");
|
|
21925
21952
|
function getBrand() {
|
|
21926
21953
|
try {
|
|
21927
|
-
return JSON.parse(
|
|
21954
|
+
return JSON.parse(readFileSync3(brandFile(), "utf8"));
|
|
21928
21955
|
} catch {
|
|
21929
21956
|
return null;
|
|
21930
21957
|
}
|
|
@@ -21941,16 +21968,154 @@ async function setBrand(input) {
|
|
|
21941
21968
|
writeFileSync2(brandFile(), JSON.stringify(next, null, 2));
|
|
21942
21969
|
return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
|
|
21943
21970
|
}
|
|
21944
|
-
|
|
21971
|
+
var PICTURE = /\.(jpe?g|png|webp)$/i;
|
|
21972
|
+
function safeAssetName(file, i) {
|
|
21973
|
+
const m = basename(file).match(PICTURE);
|
|
21974
|
+
if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
|
|
21975
|
+
const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
|
|
21976
|
+
return `${i + 1}-${slug2(basename(file, extname(file)))}.${ext}`;
|
|
21977
|
+
}
|
|
21978
|
+
function localImages(work, layoutImages, effects) {
|
|
21979
|
+
const names = /* @__PURE__ */ new Map();
|
|
21980
|
+
const dir = join3(work, "assets");
|
|
21981
|
+
const swap = (v) => {
|
|
21982
|
+
const raw = String(v ?? "").trim();
|
|
21983
|
+
if (/^assets\/[\w.-]+$/.test(raw) && existsSync2(join3(work, raw))) return raw;
|
|
21984
|
+
const file = resolve2(raw.replace(/^~(?=$|\/)/, homedir2()));
|
|
21985
|
+
if (!names.has(file)) {
|
|
21986
|
+
if (!existsSync2(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.`);
|
|
21987
|
+
if (names.size >= 8) throw new Error("8 pictures at most per reel.");
|
|
21988
|
+
const name = safeAssetName(file, names.size);
|
|
21989
|
+
mkdirSync3(dir, { recursive: true });
|
|
21990
|
+
copyFileSync(file, join3(dir, name));
|
|
21991
|
+
names.set(file, `assets/${name}`);
|
|
21992
|
+
}
|
|
21993
|
+
return names.get(file);
|
|
21994
|
+
};
|
|
21995
|
+
const images = layoutImages?.length ? layoutImages.map(swap) : void 0;
|
|
21996
|
+
const fx = effects.map((f) => ({
|
|
21997
|
+
...f,
|
|
21998
|
+
...f.image !== void 0 ? { image: swap(f.image) } : {},
|
|
21999
|
+
...Array.isArray(f.images) ? { images: f.images.map(swap) } : {}
|
|
22000
|
+
}));
|
|
22001
|
+
return { images, effects: fx, count: names.size };
|
|
22002
|
+
}
|
|
22003
|
+
async function wantsCutout(layout, effects) {
|
|
22004
|
+
const styles = await call("styles");
|
|
22005
|
+
const slugs = new Set(effects.map((f) => String(f.slug)));
|
|
22006
|
+
return [...styles.layouts.filter((l) => l.slug === (layout ?? "full-frame")), ...styles.effects.filter((e) => slugs.has(e.slug))].some((x) => x.needs?.includes("cutout"));
|
|
22007
|
+
}
|
|
22008
|
+
function headTop(work, duration3) {
|
|
22009
|
+
const tops = [];
|
|
22010
|
+
for (const at of [0.2, 0.5, 0.8].map((f) => f * duration3)) {
|
|
22011
|
+
const r = spawnSync3("ffmpeg", ["-nostdin", "-v", "error", "-c:v", "libvpx-vp9", "-ss", at.toFixed(2), "-i", "cut.webm", "-frames:v", "1", "-vf", "alphaextract,scale=108:192", "-f", "rawvideo", "-pix_fmt", "gray", "-"], { cwd: work, maxBuffer: 1 << 20 });
|
|
22012
|
+
const px = r.stdout;
|
|
22013
|
+
if (r.status !== 0 || !px || px.length < 108 * 192) continue;
|
|
22014
|
+
for (let y = 0; y < 192; y++) {
|
|
22015
|
+
let n = 0;
|
|
22016
|
+
for (let x = 32; x < 76; x++) if (px[y * 108 + x] > 128) n++;
|
|
22017
|
+
if (n >= 3) {
|
|
22018
|
+
tops.push(y * 10);
|
|
22019
|
+
break;
|
|
22020
|
+
}
|
|
22021
|
+
}
|
|
22022
|
+
}
|
|
22023
|
+
tops.sort((a, b) => a - b);
|
|
22024
|
+
return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
|
|
22025
|
+
}
|
|
22026
|
+
function ensureCutout(work, duration3) {
|
|
22027
|
+
const footage = join3(work, "footage.mp4"), cut = join3(work, "cut.webm"), stamp = join3(work, "cut.json"), still = join3(work, "assets", "still.jpg");
|
|
22028
|
+
const st = statSync(footage);
|
|
22029
|
+
const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
22030
|
+
let cached2 = null;
|
|
22031
|
+
try {
|
|
22032
|
+
cached2 = JSON.parse(readFileSync3(stamp, "utf8"));
|
|
22033
|
+
} catch {
|
|
22034
|
+
}
|
|
22035
|
+
let made_s = 0;
|
|
22036
|
+
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync2(cut))) {
|
|
22037
|
+
const started = Date.now();
|
|
22038
|
+
const r = sh("npx", ["--yes", HF, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
|
|
22039
|
+
if (r.status !== 0 || !existsSync2(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
|
|
22040
|
+
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22041
|
+
made_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22042
|
+
cached2 = { ...key, head: headTop(work, duration3), made_s };
|
|
22043
|
+
writeFileSync2(stamp, JSON.stringify(cached2));
|
|
22044
|
+
}
|
|
22045
|
+
if (!existsSync2(still)) {
|
|
22046
|
+
mkdirSync3(join3(work, "assets"), { recursive: true });
|
|
22047
|
+
sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
|
|
22048
|
+
}
|
|
22049
|
+
return { head: cached2.head, made_s };
|
|
22050
|
+
}
|
|
22051
|
+
var planFile = (folder) => join3(folder, ".cutroom", "plan.json");
|
|
22052
|
+
async function composition(input) {
|
|
21945
22053
|
const pj = resolveProject(input.project);
|
|
21946
22054
|
const work = join3(pj.folder, ".cutroom");
|
|
21947
22055
|
const words = applyFixes(pj.words, input.fixes);
|
|
21948
|
-
const
|
|
22056
|
+
const local = localImages(work, input.images, input.effects ?? []);
|
|
22057
|
+
const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22058
|
+
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 } : {} });
|
|
21949
22059
|
await ensureFonts(work);
|
|
22060
|
+
writeFileSync2(join3(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
22061
|
+
return { pj, work, html, cutout, words };
|
|
22062
|
+
}
|
|
22063
|
+
var fmtT = (t) => `${t.toFixed(1)}s`;
|
|
22064
|
+
var fxLine = (f) => Object.entries(f).filter(([k]) => !["slug", "start", "dur"].includes(k)).map(([, v]) => Array.isArray(v) ? v.join(" / ") : String(v)).join(" \xB7 ");
|
|
22065
|
+
async function planReel(input) {
|
|
22066
|
+
const { pj, work, html, cutout, words } = await composition(input);
|
|
22067
|
+
const effects = [...input.effects ?? []].sort((a, b) => Number(a.start) - Number(b.start));
|
|
22068
|
+
const peak = (f) => {
|
|
22069
|
+
const d = Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5);
|
|
22070
|
+
return Math.min(pj.duration - 0.1, Number(f.start) + Math.max(d * 0.5, d - 0.55));
|
|
22071
|
+
};
|
|
22072
|
+
const plainAt = effects.length && Number(effects[0].start) > 2 ? 1.2 : null;
|
|
22073
|
+
const moments = [
|
|
22074
|
+
...plainAt !== null ? [{ t: plainAt, what: `${input.layout ?? "full-frame"} + ${input.caption ?? "two-tier"} captions`, tag: "layout + captions", at: plainAt }] : [],
|
|
22075
|
+
...effects.map((f) => ({ t: Number(f.start), what: `${f.slug}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: String(f.slug), at: peak(f) }))
|
|
22076
|
+
];
|
|
22077
|
+
const shots = join3(work, "storyboard");
|
|
22078
|
+
rmSync(shots, { recursive: true, force: true });
|
|
22079
|
+
writeFileSync2(join3(work, "index.html"), html);
|
|
22080
|
+
let r;
|
|
22081
|
+
try {
|
|
22082
|
+
r = sh("npx", ["--yes", HF, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
|
|
22083
|
+
} finally {
|
|
22084
|
+
rmSync(join3(work, "index.html"), { force: true });
|
|
22085
|
+
}
|
|
22086
|
+
const pngs = existsSync2(shots) ? readdirSync(shots).filter((f) => f.endsWith(".png")).sort() : [];
|
|
22087
|
+
if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
|
|
22088
|
+
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22089
|
+
const board = join3(pj.folder, `${pj.name}-storyboard.jpg`);
|
|
22090
|
+
const cols = Math.min(4, moments.length);
|
|
22091
|
+
const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
|
|
22092
|
+
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join3(shots, f)]);
|
|
22093
|
+
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(";");
|
|
22094
|
+
const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
|
|
22095
|
+
const stack = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
|
|
22096
|
+
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
|
|
22097
|
+
if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
|
|
22098
|
+
${tail(t.stderr)}`);
|
|
22099
|
+
rmSync(shots, { recursive: true, force: true });
|
|
22100
|
+
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
|
|
22101
|
+
writeFileSync2(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22102
|
+
const text = [
|
|
22103
|
+
`PLAN ${pj.name} ${pj.duration.toFixed(1)}s ${effects.length} effects layout: ${plan.layout} captions: ${plan.caption}${cutout ? " (cut-out ready)" : ""}`,
|
|
22104
|
+
...moments.map((m) => `${fmtT(m.t).padStart(6)} ${m.what}`),
|
|
22105
|
+
`Storyboard: ${board}`
|
|
22106
|
+
].join("\n");
|
|
22107
|
+
return { text, storyboard: board, plan, words_used: words.length, next: "Show the storyboard. On changes, plan_reel again with the new choice. On a go, render_reel with plan:true and draft:true." };
|
|
22108
|
+
}
|
|
22109
|
+
async function renderReel(input) {
|
|
22110
|
+
if (input.plan) {
|
|
22111
|
+
const folder = resolveProject(input.project).folder;
|
|
22112
|
+
if (!existsSync2(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
|
|
22113
|
+
input = { ...JSON.parse(readFileSync3(planFile(folder), "utf8")), project: input.project, draft: input.draft };
|
|
22114
|
+
}
|
|
22115
|
+
const { pj, work, html, cutout } = await composition(input);
|
|
21950
22116
|
const out = join3(pj.folder, `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
|
|
21951
22117
|
const tmp = join3(work, "render.mp4");
|
|
21952
22118
|
writeFileSync2(join3(work, "index.html"), html);
|
|
21953
|
-
writeFileSync2(join3(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
21954
22119
|
const started = Date.now();
|
|
21955
22120
|
let r;
|
|
21956
22121
|
try {
|
|
@@ -21963,7 +22128,7 @@ ${tail(r.stderr) || tail(r.stdout)}`);
|
|
|
21963
22128
|
renameSync(tmp, out);
|
|
21964
22129
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
21965
22130
|
const qa = await qaVideo({ video: out, write_report: true, layout: input.layout });
|
|
21966
|
-
return { video: out, render_s, qa, next: qa.passed ? "Watch it once, then it's ready to post." : "Fix the issues listed (usually: move an effect, fix a word, or re-record a bit), then render_reel again." };
|
|
22131
|
+
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." : "Fix the issues listed (usually: move an effect, fix a word, or re-record a bit), then render_reel again." };
|
|
21967
22132
|
}
|
|
21968
22133
|
async function qaVideo(input) {
|
|
21969
22134
|
const file = resolve2(input.video.replace(/^~/, homedir2()));
|
|
@@ -22027,16 +22192,16 @@ Tear down a creator: call creator_teardown (compare_to = the user's own handle i
|
|
|
22027
22192
|
Finish a video (raw talking-head file: edit it, add captions or effects, make it a reel):
|
|
22028
22193
|
1. prepare_footage with the file path.
|
|
22029
22194
|
2. From the timed transcript, list words that look misheard (names, brands) and confirm spellings with the user.
|
|
22030
|
-
3. list_styles, then
|
|
22031
|
-
4. render_reel with draft:true; tell them where the file is and what QA found.
|
|
22032
|
-
5. After they approve
|
|
22033
|
-
6.
|
|
22195
|
+
3. Plan: list_styles (share its gallery link so the user can see each style moving), then choose one layout, one caption style and 2 to 4 effects tied to moments in the transcript (a number gets number-pop, steps get checklist, the CTA line gets comment-dm or follow-tap). Effect text uses only the user's own words and numbers. Call plan_reel with that choice and show the user the plan lines and the storyboard (one frame per moment). They change anything ("move the checklist to 30s", "swap orbit for card wall"): plan_reel again. Nothing renders until they say go. No B-roll unless they ask for it. Effects and layouts marked needs "cutout" (text behind you, orbit, card wall, cut-out layouts) first cut the speaker out of the background on this computer: about 6 seconds of work per second of video, once per video; say so before you start. Ones marked needs "images" take the user's own pictures as file paths; ask for them, or use the fallback the effect describes.
|
|
22196
|
+
4. On a go: render_reel with plan:true and draft:true; tell them where the file is and what QA found.
|
|
22197
|
+
5. After they approve the draft: render_reel with plan:true (no draft) for the final. After changes: plan_reel, then render again.
|
|
22198
|
+
6. QA "look" lines are spots to check, not failures: busy edits often get flagged on purpose (cards running off the edge, a card over a board). For each flagged time, grab that frame with ffmpeg and look at it yourself, then tell the user which ones are real problems (with the fix) and which are the design. Technical issues (frozen picture, loudness, black frames) are real; say if a freeze is a deliberate hold.
|
|
22034
22199
|
|
|
22035
22200
|
Brand: before a user's first render, call show_brand; if none is set, offer to set it with set_brand. If they give a website or logo instead of colors, pick 3 colors from it (background, accent, highlight), show them, and save on a yes.
|
|
22036
22201
|
|
|
22037
22202
|
Results are saved in ~/Cutroom. Study only for other creators' reels: never reuse their words, footage or branding.`;
|
|
22038
22203
|
await serveStdio(() => {
|
|
22039
|
-
const server = new McpServer({ name: "cutroom", version: "0.5.
|
|
22204
|
+
const server = new McpServer({ name: "cutroom", version: "0.5.2" }, { instructions: INSTRUCTIONS });
|
|
22040
22205
|
server.registerTool("cutroom_doctor", {
|
|
22041
22206
|
description: "Check Cutroom is ready: ffmpeg on this computer and an active membership key. Returns each check with the exact fix. Run it first, and whenever a tool fails.",
|
|
22042
22207
|
inputSchema: object({})
|
|
@@ -22098,16 +22263,39 @@ ${m.text}`;
|
|
|
22098
22263
|
cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second.")
|
|
22099
22264
|
})
|
|
22100
22265
|
}, async (input) => safe(() => prepareFootage(input)));
|
|
22266
|
+
server.registerTool("plan_reel", {
|
|
22267
|
+
description: "Step 2: the plan before anything renders. Takes the same choice as render_reel (layout, caption, effects with times, images) and returns the plan as lines plus a storyboard image: one real frame of the user's video per moment, with the effect on it, in their brand. About 10 to 20 seconds (plus the one-time cut-out if the choice needs it). Saves the plan so render_reel with plan:true renders exactly this. Show the storyboard and get a go before rendering.",
|
|
22268
|
+
inputSchema: object({
|
|
22269
|
+
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22270
|
+
layout: string2().default("full-frame"),
|
|
22271
|
+
caption: string2().default("two-tier"),
|
|
22272
|
+
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(5).optional() }).passthrough()).max(12).default([]).describe("Same as render_reel: e.g. [{slug:'number-pop', start:3.2, number:'24', label:'hours later'}]."),
|
|
22273
|
+
images: array(string2()).max(8).optional().describe("Pictures for the layout, full paths on this computer."),
|
|
22274
|
+
emphasis: array(string2()).optional(),
|
|
22275
|
+
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word.")
|
|
22276
|
+
})
|
|
22277
|
+
}, async (input) => {
|
|
22278
|
+
try {
|
|
22279
|
+
const r = await planReel(input);
|
|
22280
|
+
return { content: [{ type: "text", text: `${r.text}
|
|
22281
|
+
|
|
22282
|
+
${r.next}` }, { type: "image", data: readFileSync4(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
|
|
22283
|
+
} catch (e) {
|
|
22284
|
+
return failure2(e);
|
|
22285
|
+
}
|
|
22286
|
+
});
|
|
22101
22287
|
server.registerTool("render_reel", {
|
|
22102
|
-
description: "Step
|
|
22288
|
+
description: "Step 3: render the prepared footage in a Cutroom layout with a caption style and effects, on this computer, then QA it (technical checks plus every sampled frame judged). Saves <name>.mp4 and <name>.qa.md in the project folder. Use draft:true for a fast preview. Effect times are seconds in the prepared (trimmed) video. Layouts and effects that cut the speaker out of the background (see needs in list_styles) run a one-time cut-out first: about 5 to 6 seconds per second of footage, reused for later renders of the same project.",
|
|
22103
22289
|
inputSchema: object({
|
|
22104
22290
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22105
22291
|
layout: string2().default("full-frame"),
|
|
22106
22292
|
caption: string2().default("two-tier"),
|
|
22107
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(5).optional() }).passthrough()).max(12).default([]).describe("e.g. [{slug:'number-pop', start:3.2, number:'24', label:'hours later'}]. Params per effect are in list_styles."),
|
|
22293
|
+
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(5).optional() }).passthrough()).max(12).default([]).describe("e.g. [{slug:'number-pop', start:3.2, number:'24', label:'hours later'}]. Params per effect are in list_styles. Effect params image / images are picture files on this computer (full paths, jpg/png/webp); they are copied into the render folder, only the file name goes to Cutroom."),
|
|
22294
|
+
images: array(string2()).max(8).optional().describe("Pictures for the layout (image-cluster: 3 to 5, cutout-grid: 1 to 8, cutout-backdrop: optional): full paths of jpg, png or webp files on this computer. 8 pictures at most per reel, layout and effects together."),
|
|
22108
22295
|
emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
|
|
22109
22296
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word."),
|
|
22110
|
-
draft: boolean2().default(false)
|
|
22297
|
+
draft: boolean2().default(false),
|
|
22298
|
+
plan: boolean2().default(false).describe("Render the plan saved by the last plan_reel for this project (ignores layout, caption, effects and images given here).")
|
|
22111
22299
|
})
|
|
22112
22300
|
}, async (input) => safe(() => renderReel(input)));
|
|
22113
22301
|
server.registerTool("qa_video", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepylabs/cutroom",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Cutroom for Claude Code: hooks and scripts with scoring, reel breakdowns, creator teardowns, and finishing your own videos with QA. Needs a Keepy Labs membership key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|