@keepylabs/cutroom 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cutroom.mjs +167 -22
- package/package.json +11 -4
package/cutroom.mjs
CHANGED
|
@@ -21533,7 +21533,7 @@ function toError(value) {
|
|
|
21533
21533
|
|
|
21534
21534
|
// src/community/local/render.ts
|
|
21535
21535
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
21536
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as
|
|
21536
|
+
import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
21537
21537
|
import { homedir as homedir2 } from "node:os";
|
|
21538
21538
|
import { basename, extname, join as join3, resolve as resolve2 } from "node:path";
|
|
21539
21539
|
|
|
@@ -21631,7 +21631,7 @@ function technicalChecks(file) {
|
|
|
21631
21631
|
|
|
21632
21632
|
// src/community/local/tools.ts
|
|
21633
21633
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
21634
|
-
import { createWriteStream, existsSync, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
|
|
21634
|
+
import { createWriteStream, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
21635
21635
|
import { homedir } from "node:os";
|
|
21636
21636
|
import { join as join2, resolve } from "node:path";
|
|
21637
21637
|
import { Readable } from "node:stream";
|
|
@@ -21685,8 +21685,28 @@ function approxWords(text, duration3) {
|
|
|
21685
21685
|
const words = (text ?? "").split(/\s+/).filter(Boolean);
|
|
21686
21686
|
return words.map((w, i) => ({ t: i / Math.max(words.length, 1) * duration3, w }));
|
|
21687
21687
|
}
|
|
21688
|
+
var MIN_BEAT = 4;
|
|
21689
|
+
function beatBounds(duration3, cuts, words) {
|
|
21690
|
+
const ends = words.filter((w) => /[.!?]["”']?$/.test(w.w)).map((w) => w.e ?? w.t);
|
|
21691
|
+
const all = [...new Set([...cuts, ...ends].map((x) => Math.round(x * 10) / 10))].filter((x) => x > 0 && x < duration3).sort((a, b) => a - b);
|
|
21692
|
+
const out = [0];
|
|
21693
|
+
for (const x of all) if (x - out[out.length - 1] >= MIN_BEAT && duration3 - x >= MIN_BEAT / 2) out.push(x);
|
|
21694
|
+
return [...out, duration3];
|
|
21695
|
+
}
|
|
21696
|
+
function localWords(file, dir) {
|
|
21697
|
+
const r = spawnSync2("npx", ["--yes", "hyperframes@0.8.99", "transcribe", file, "--json", "-d", dir], { cwd: dir, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
21698
|
+
try {
|
|
21699
|
+
const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
|
|
21700
|
+
if (r.status !== 0 || !meta2.ok) return null;
|
|
21701
|
+
const raw = JSON.parse(readFileSync2(meta2.transcriptPath ?? join2(dir, "transcript.json"), "utf8"));
|
|
21702
|
+
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);
|
|
21703
|
+
return words.length ? words : null;
|
|
21704
|
+
} catch {
|
|
21705
|
+
return null;
|
|
21706
|
+
}
|
|
21707
|
+
}
|
|
21688
21708
|
function beats(m, words, frames) {
|
|
21689
|
-
const bounds =
|
|
21709
|
+
const bounds = beatBounds(m.duration, m.cuts, words);
|
|
21690
21710
|
return bounds.slice(1).map((end, i) => {
|
|
21691
21711
|
const start = bounds[i];
|
|
21692
21712
|
const inShot = frames.filter((f) => f.t >= start && f.t < end);
|
|
@@ -21715,8 +21735,10 @@ async function breakdownReel(input) {
|
|
|
21715
21735
|
const m = measure(file);
|
|
21716
21736
|
const { frames, interval } = sampleFrames(file, join2(dir, "frames"), m.duration, 1, 40);
|
|
21717
21737
|
const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
|
|
21718
|
-
const
|
|
21738
|
+
const timed = localWords(file, dir);
|
|
21739
|
+
const words = timed ?? approxWords(transcript, m.duration);
|
|
21719
21740
|
const bs = beats(m, words, match.frames);
|
|
21741
|
+
if (!transcript && timed) transcript = timed.map((w) => w.w).join(" ");
|
|
21720
21742
|
const hook = transcript ? transcript.split(/(?<=[.!?])\s/)[0] : null;
|
|
21721
21743
|
const s = match.summary;
|
|
21722
21744
|
const md = [
|
|
@@ -21739,10 +21761,10 @@ async function breakdownReel(input) {
|
|
|
21739
21761
|
...Object.entries(s.layout_share).map(([k, v]) => `- ${k}: ${pct(v)}`),
|
|
21740
21762
|
"Captions:",
|
|
21741
21763
|
...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"],
|
|
21764
|
+
'Effects seen ("in your menu" = render_reel can make it):',
|
|
21765
|
+
...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
21766
|
"",
|
|
21745
|
-
`## Beats${transcript ? " (words are approximate: spread evenly over the video)" : ""}`,
|
|
21767
|
+
`## Beats${!timed && transcript ? " (words are approximate: spread evenly over the video)" : ""}`,
|
|
21746
21768
|
"| # | time | words | layout | effects |",
|
|
21747
21769
|
"| - | - | - | - | - |",
|
|
21748
21770
|
...bs.map((b) => `| ${b.n} | ${fmt(b.start)}-${fmt(b.end)} | ${b.words.replace(/\|/g, "/") || ""} | ${b.layout} | ${b.effects.join(", ")} |`),
|
|
@@ -21752,7 +21774,7 @@ async function breakdownReel(input) {
|
|
|
21752
21774
|
""
|
|
21753
21775
|
].join("\n");
|
|
21754
21776
|
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." };
|
|
21777
|
+
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
21778
|
writeFileSync(join2(dir, "breakdown.json"), JSON.stringify(out, null, 2));
|
|
21757
21779
|
return out;
|
|
21758
21780
|
}
|
|
@@ -21773,7 +21795,7 @@ async function teardownOne(handle, limit) {
|
|
|
21773
21795
|
const { frames, interval } = sampleFrames(file, join2(dir, "frames", r.shortCode), m.duration, 2, 20);
|
|
21774
21796
|
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
21797
|
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) });
|
|
21798
|
+
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
21799
|
} catch (e) {
|
|
21778
21800
|
rows.push({ code: r.shortCode, url: r.url, posted: String(r.timestamp ?? "").slice(0, 10), plays: r.videoPlayCount ?? null, error: e.message, effects: [] });
|
|
21779
21801
|
}
|
|
@@ -21786,6 +21808,8 @@ function rollup(rows) {
|
|
|
21786
21808
|
const topSet = new Set(byPlays.slice(0, Math.max(1, Math.round(byPlays.length / 4))).map((r) => r.code));
|
|
21787
21809
|
const rest = ok.filter((r) => !topSet.has(r.code));
|
|
21788
21810
|
const names = [...new Set(ok.flatMap((r) => r.effects))];
|
|
21811
|
+
const off = new Set(ok.flatMap((r) => r.off_menu ?? []));
|
|
21812
|
+
const p2 = (n) => Math.round(n * 100) / 100;
|
|
21789
21813
|
const count = (k) => {
|
|
21790
21814
|
const c = {};
|
|
21791
21815
|
for (const r of ok) if (r[k]) c[r[k]] = (c[r[k]] ?? 0) + 1;
|
|
@@ -21801,7 +21825,7 @@ function rollup(rows) {
|
|
|
21801
21825
|
layouts: count("layout"),
|
|
21802
21826
|
captions: count("caption"),
|
|
21803
21827
|
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)
|
|
21828
|
+
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
21829
|
};
|
|
21806
21830
|
}
|
|
21807
21831
|
async function creatorTeardown(input) {
|
|
@@ -21815,7 +21839,7 @@ async function creatorTeardown(input) {
|
|
|
21815
21839
|
const rb = rollup(b.rows);
|
|
21816
21840
|
const mine = Object.fromEntries(rb.effects.map((e) => [e.effect, e.share]));
|
|
21817
21841
|
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] })) };
|
|
21842
|
+
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
21843
|
}
|
|
21820
21844
|
const md = [
|
|
21821
21845
|
`# Creator teardown: @${handle}`,
|
|
@@ -21832,7 +21856,7 @@ async function creatorTeardown(input) {
|
|
|
21832
21856
|
"| effect | share of reels | top quarter by plays | the rest |",
|
|
21833
21857
|
"| - | - | - | - |",
|
|
21834
21858
|
...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`)] : []] : [],
|
|
21859
|
+
...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
21860
|
"",
|
|
21837
21861
|
"## Reels",
|
|
21838
21862
|
"| posted | plays | s | cuts/min | layout | effects | hook |",
|
|
@@ -21857,7 +21881,7 @@ var resolveProject = (p) => {
|
|
|
21857
21881
|
const folder = resolve2(p.replace(/^~/, homedir2()));
|
|
21858
21882
|
const file = existsSync2(projectFile(folder)) ? projectFile(folder) : existsSync2(join3(HOME(), "renders", p, ".cutroom", "project.json")) ? join3(HOME(), "renders", p, ".cutroom", "project.json") : null;
|
|
21859
21883
|
if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
|
|
21860
|
-
return JSON.parse(
|
|
21884
|
+
return JSON.parse(readFileSync3(file, "utf8"));
|
|
21861
21885
|
};
|
|
21862
21886
|
function cutAndNormalize(src, out, segments) {
|
|
21863
21887
|
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 +21916,7 @@ async function prepareFootage(input) {
|
|
|
21892
21916
|
})();
|
|
21893
21917
|
if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
|
|
21894
21918
|
${meta2.error ?? tail(t.stderr)}`);
|
|
21895
|
-
const raw = JSON.parse(
|
|
21919
|
+
const raw = JSON.parse(readFileSync3(meta2.transcriptPath ?? join3(work, "transcript.json"), "utf8"));
|
|
21896
21920
|
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
21921
|
const project = { name, folder, source, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
|
|
21898
21922
|
writeFileSync2(projectFile(folder), JSON.stringify(project, null, 2));
|
|
@@ -21913,7 +21937,7 @@ async function ensureFonts(work) {
|
|
|
21913
21937
|
for (const [f, b64] of Object.entries(fonts)) writeFileSync2(join3(cache, basename(f)), Buffer.from(b64, "base64"));
|
|
21914
21938
|
}
|
|
21915
21939
|
mkdirSync3(join3(work, "fonts"), { recursive: true });
|
|
21916
|
-
for (const f of need) writeFileSync2(join3(work, "fonts", f),
|
|
21940
|
+
for (const f of need) writeFileSync2(join3(work, "fonts", f), readFileSync3(join3(cache, f)));
|
|
21917
21941
|
}
|
|
21918
21942
|
function applyFixes(words, fixes = []) {
|
|
21919
21943
|
return words.map((w) => {
|
|
@@ -21921,11 +21945,113 @@ function applyFixes(words, fixes = []) {
|
|
|
21921
21945
|
return f ? { ...w, w: f.to } : w;
|
|
21922
21946
|
}).filter((w) => w.w);
|
|
21923
21947
|
}
|
|
21948
|
+
var brandFile = () => join3(HOME(), "brand.json");
|
|
21949
|
+
function getBrand() {
|
|
21950
|
+
try {
|
|
21951
|
+
return JSON.parse(readFileSync3(brandFile(), "utf8"));
|
|
21952
|
+
} catch {
|
|
21953
|
+
return null;
|
|
21954
|
+
}
|
|
21955
|
+
}
|
|
21956
|
+
async function setBrand(input) {
|
|
21957
|
+
if (input.reset) {
|
|
21958
|
+
rmSync(brandFile(), { force: true });
|
|
21959
|
+
return { brand: null, saved: "Back to the default look." };
|
|
21960
|
+
}
|
|
21961
|
+
const { reset, ...fields } = input;
|
|
21962
|
+
const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
|
|
21963
|
+
await call("compose", { duration: 2, words: [], brand: next, check_only: true });
|
|
21964
|
+
mkdirSync3(HOME(), { recursive: true });
|
|
21965
|
+
writeFileSync2(brandFile(), JSON.stringify(next, null, 2));
|
|
21966
|
+
return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
|
|
21967
|
+
}
|
|
21968
|
+
var PICTURE = /\.(jpe?g|png|webp)$/i;
|
|
21969
|
+
function safeAssetName(file, i) {
|
|
21970
|
+
const m = basename(file).match(PICTURE);
|
|
21971
|
+
if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
|
|
21972
|
+
const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
|
|
21973
|
+
return `${i + 1}-${slug2(basename(file, extname(file)))}.${ext}`;
|
|
21974
|
+
}
|
|
21975
|
+
function localImages(work, layoutImages, effects) {
|
|
21976
|
+
const names = /* @__PURE__ */ new Map();
|
|
21977
|
+
const dir = join3(work, "assets");
|
|
21978
|
+
const swap = (v) => {
|
|
21979
|
+
const raw = String(v ?? "").trim();
|
|
21980
|
+
if (/^assets\/[\w.-]+$/.test(raw) && existsSync2(join3(work, raw))) return raw;
|
|
21981
|
+
const file = resolve2(raw.replace(/^~(?=$|\/)/, homedir2()));
|
|
21982
|
+
if (!names.has(file)) {
|
|
21983
|
+
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.`);
|
|
21984
|
+
if (names.size >= 8) throw new Error("8 pictures at most per reel.");
|
|
21985
|
+
const name = safeAssetName(file, names.size);
|
|
21986
|
+
mkdirSync3(dir, { recursive: true });
|
|
21987
|
+
copyFileSync(file, join3(dir, name));
|
|
21988
|
+
names.set(file, `assets/${name}`);
|
|
21989
|
+
}
|
|
21990
|
+
return names.get(file);
|
|
21991
|
+
};
|
|
21992
|
+
const images = layoutImages?.length ? layoutImages.map(swap) : void 0;
|
|
21993
|
+
const fx = effects.map((f) => ({
|
|
21994
|
+
...f,
|
|
21995
|
+
...f.image !== void 0 ? { image: swap(f.image) } : {},
|
|
21996
|
+
...Array.isArray(f.images) ? { images: f.images.map(swap) } : {}
|
|
21997
|
+
}));
|
|
21998
|
+
return { images, effects: fx, count: names.size };
|
|
21999
|
+
}
|
|
22000
|
+
async function wantsCutout(layout, effects) {
|
|
22001
|
+
const styles = await call("styles");
|
|
22002
|
+
const slugs = new Set(effects.map((f) => String(f.slug)));
|
|
22003
|
+
return [...styles.layouts.filter((l) => l.slug === (layout ?? "full-frame")), ...styles.effects.filter((e) => slugs.has(e.slug))].some((x) => x.needs?.includes("cutout"));
|
|
22004
|
+
}
|
|
22005
|
+
function headTop(work, duration3) {
|
|
22006
|
+
const tops = [];
|
|
22007
|
+
for (const at of [0.2, 0.5, 0.8].map((f) => f * duration3)) {
|
|
22008
|
+
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 });
|
|
22009
|
+
const px = r.stdout;
|
|
22010
|
+
if (r.status !== 0 || !px || px.length < 108 * 192) continue;
|
|
22011
|
+
for (let y = 0; y < 192; y++) {
|
|
22012
|
+
let n = 0;
|
|
22013
|
+
for (let x = 32; x < 76; x++) if (px[y * 108 + x] > 128) n++;
|
|
22014
|
+
if (n >= 3) {
|
|
22015
|
+
tops.push(y * 10);
|
|
22016
|
+
break;
|
|
22017
|
+
}
|
|
22018
|
+
}
|
|
22019
|
+
}
|
|
22020
|
+
tops.sort((a, b) => a - b);
|
|
22021
|
+
return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
|
|
22022
|
+
}
|
|
22023
|
+
function ensureCutout(work, duration3) {
|
|
22024
|
+
const footage = join3(work, "footage.mp4"), cut = join3(work, "cut.webm"), stamp = join3(work, "cut.json"), still = join3(work, "assets", "still.jpg");
|
|
22025
|
+
const st = statSync(footage);
|
|
22026
|
+
const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
22027
|
+
let cached2 = null;
|
|
22028
|
+
try {
|
|
22029
|
+
cached2 = JSON.parse(readFileSync3(stamp, "utf8"));
|
|
22030
|
+
} catch {
|
|
22031
|
+
}
|
|
22032
|
+
let made_s = 0;
|
|
22033
|
+
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync2(cut))) {
|
|
22034
|
+
const started = Date.now();
|
|
22035
|
+
const r = sh("npx", ["--yes", HF, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
|
|
22036
|
+
if (r.status !== 0 || !existsSync2(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
|
|
22037
|
+
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22038
|
+
made_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22039
|
+
cached2 = { ...key, head: headTop(work, duration3), made_s };
|
|
22040
|
+
writeFileSync2(stamp, JSON.stringify(cached2));
|
|
22041
|
+
}
|
|
22042
|
+
if (!existsSync2(still)) {
|
|
22043
|
+
mkdirSync3(join3(work, "assets"), { recursive: true });
|
|
22044
|
+
sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
|
|
22045
|
+
}
|
|
22046
|
+
return { head: cached2.head, made_s };
|
|
22047
|
+
}
|
|
21924
22048
|
async function renderReel(input) {
|
|
21925
22049
|
const pj = resolveProject(input.project);
|
|
21926
22050
|
const work = join3(pj.folder, ".cutroom");
|
|
21927
22051
|
const words = applyFixes(pj.words, input.fixes);
|
|
21928
|
-
const
|
|
22052
|
+
const local = localImages(work, input.images, input.effects ?? []);
|
|
22053
|
+
const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22054
|
+
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 } : {} });
|
|
21929
22055
|
await ensureFonts(work);
|
|
21930
22056
|
const out = join3(pj.folder, `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
|
|
21931
22057
|
const tmp = join3(work, "render.mp4");
|
|
@@ -21943,7 +22069,7 @@ ${tail(r.stderr) || tail(r.stdout)}`);
|
|
|
21943
22069
|
renameSync(tmp, out);
|
|
21944
22070
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
21945
22071
|
const qa = await qaVideo({ video: out, write_report: true, layout: input.layout });
|
|
21946
|
-
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." };
|
|
22072
|
+
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." };
|
|
21947
22073
|
}
|
|
21948
22074
|
async function qaVideo(input) {
|
|
21949
22075
|
const file = resolve2(input.video.replace(/^~/, homedir2()));
|
|
@@ -22007,14 +22133,16 @@ Tear down a creator: call creator_teardown (compare_to = the user's own handle i
|
|
|
22007
22133
|
Finish a video (raw talking-head file: edit it, add captions or effects, make it a reel):
|
|
22008
22134
|
1. prepare_footage with the file path.
|
|
22009
22135
|
2. From the timed transcript, list words that look misheard (names, brands) and confirm spellings with the user.
|
|
22010
|
-
3. list_styles, then propose 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. Show the plan with times and get a yes.
|
|
22136
|
+
3. list_styles (share its gallery link so the user can see each style moving), then propose 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. Show the plan with times and get a yes. 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.
|
|
22011
22137
|
4. render_reel with draft:true; tell them where the file is and what QA found.
|
|
22012
22138
|
5. After they approve (or after changes), render_reel without draft for the final.
|
|
22013
|
-
6.
|
|
22139
|
+
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.
|
|
22140
|
+
|
|
22141
|
+
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.
|
|
22014
22142
|
|
|
22015
22143
|
Results are saved in ~/Cutroom. Study only for other creators' reels: never reuse their words, footage or branding.`;
|
|
22016
22144
|
await serveStdio(() => {
|
|
22017
|
-
const server = new McpServer({ name: "cutroom", version: "0.
|
|
22145
|
+
const server = new McpServer({ name: "cutroom", version: "0.5.1" }, { instructions: INSTRUCTIONS });
|
|
22018
22146
|
server.registerTool("cutroom_doctor", {
|
|
22019
22147
|
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.",
|
|
22020
22148
|
inputSchema: object({})
|
|
@@ -22052,6 +22180,22 @@ ${m.text}`;
|
|
|
22052
22180
|
description: "The Cutroom finishing menu: layouts, caption styles and effects (with the text each effect needs), plus timing rules. Read it before render_reel.",
|
|
22053
22181
|
inputSchema: object({})
|
|
22054
22182
|
}, async () => safe(() => call("styles")));
|
|
22183
|
+
server.registerTool("set_brand", {
|
|
22184
|
+
description: "Save the user's brand kit, used by every render from now on (kept on this computer only). Hex colors: background, text, accent (big caption words, chart), highlight (word highlight, DM bubble, ticks, follow button), number (number pop); font: sans, serif or mono. Only the fields given change. reset:true goes back to the default look.",
|
|
22185
|
+
inputSchema: object({
|
|
22186
|
+
background: string2().optional(),
|
|
22187
|
+
text: string2().optional(),
|
|
22188
|
+
accent: string2().optional(),
|
|
22189
|
+
highlight: string2().optional(),
|
|
22190
|
+
number: string2().optional(),
|
|
22191
|
+
font: _enum(["sans", "serif", "mono"]).optional(),
|
|
22192
|
+
reset: boolean2().optional()
|
|
22193
|
+
})
|
|
22194
|
+
}, async (input) => safe(() => setBrand(input)));
|
|
22195
|
+
server.registerTool("show_brand", {
|
|
22196
|
+
description: "Show the brand kit renders use right now (or that the default look is in use).",
|
|
22197
|
+
inputSchema: object({})
|
|
22198
|
+
}, async () => safe(() => getBrand() ?? "No brand kit set: renders use the default look. Set one with set_brand."));
|
|
22055
22199
|
server.registerTool("prepare_footage", {
|
|
22056
22200
|
description: "Step 1 of finishing a talking-head video on this computer: cuts long pauses, fits it to 1080x1920, and transcribes it with word timings. Returns the project folder and the timed transcript to plan captions and effects from. Takes about a minute.",
|
|
22057
22201
|
inputSchema: object({
|
|
@@ -22061,12 +22205,13 @@ ${m.text}`;
|
|
|
22061
22205
|
})
|
|
22062
22206
|
}, async (input) => safe(() => prepareFootage(input)));
|
|
22063
22207
|
server.registerTool("render_reel", {
|
|
22064
|
-
description: "Step 2: 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.",
|
|
22208
|
+
description: "Step 2: 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.",
|
|
22065
22209
|
inputSchema: object({
|
|
22066
22210
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22067
22211
|
layout: string2().default("full-frame"),
|
|
22068
22212
|
caption: string2().default("two-tier"),
|
|
22069
|
-
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."),
|
|
22213
|
+
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."),
|
|
22214
|
+
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."),
|
|
22070
22215
|
emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
|
|
22071
22216
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word."),
|
|
22072
22217
|
draft: boolean2().default(false)
|
package/package.json
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepylabs/cutroom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"cutroom": "cutroom.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cutroom.mjs",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
9
16
|
"license": "UNLICENSED",
|
|
10
17
|
"homepage": "https://keepylabs.com",
|
|
11
18
|
"author": "Keepy Labs"
|