@keepylabs/cutroom 0.5.2 → 0.5.4
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 +43 -29
- package/package.json +1 -1
package/cutroom.mjs
CHANGED
|
@@ -21894,6 +21894,10 @@ function cutAndNormalize(src, out, segments) {
|
|
|
21894
21894
|
if (r.status !== 0) throw new Error(`Couldn't cut the footage:
|
|
21895
21895
|
${tail(r.stderr)}`);
|
|
21896
21896
|
}
|
|
21897
|
+
function looksLikeSpeech(words, duration3) {
|
|
21898
|
+
const readable = words.filter((w) => new RegExp("\\p{L}{2,}|\\d", "u").test(w.w) && !/\uFFFD/.test(w.w));
|
|
21899
|
+
return readable.length >= Math.max(3, duration3 * 0.6) && readable.length >= words.length * 0.8;
|
|
21900
|
+
}
|
|
21897
21901
|
async function prepareFootage(input) {
|
|
21898
21902
|
const source = resolve2(input.video.replace(/^~/, homedir2()));
|
|
21899
21903
|
if (!existsSync2(source)) throw new Error(`No such file: ${source}`);
|
|
@@ -21909,7 +21913,8 @@ async function prepareFootage(input) {
|
|
|
21909
21913
|
const footage = join3(work, "footage.mp4");
|
|
21910
21914
|
cutAndNormalize(source, footage, segments);
|
|
21911
21915
|
const duration3 = probe(footage).duration;
|
|
21912
|
-
const
|
|
21916
|
+
const lang = (input.language ?? "en").toLowerCase().slice(0, 5);
|
|
21917
|
+
const t = sh("npx", ["--yes", HF, "transcribe", "footage.mp4", "--json", "-d", ".", ...lang === "en" ? [] : ["--language", lang, "-m", "large-v3"]], work);
|
|
21913
21918
|
const meta2 = (() => {
|
|
21914
21919
|
try {
|
|
21915
21920
|
return JSON.parse(t.stdout.trim().split("\n").pop() ?? "{}");
|
|
@@ -21921,6 +21926,7 @@ async function prepareFootage(input) {
|
|
|
21921
21926
|
${meta2.error ?? tail(t.stderr)}`);
|
|
21922
21927
|
const raw = JSON.parse(readFileSync3(meta2.transcriptPath ?? join3(work, "transcript.json"), "utf8"));
|
|
21923
21928
|
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);
|
|
21929
|
+
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.`);
|
|
21924
21930
|
const project = { name, folder, source, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
|
|
21925
21931
|
writeFileSync2(projectFile(folder), JSON.stringify(project, null, 2));
|
|
21926
21932
|
return {
|
|
@@ -21928,7 +21934,7 @@ ${meta2.error ?? tail(t.stderr)}`);
|
|
|
21928
21934
|
duration: project.duration,
|
|
21929
21935
|
pauses_removed_s: project.removed_s,
|
|
21930
21936
|
transcript: words.map((w) => `${w.t.toFixed(2)} ${w.w}`).join("\n"),
|
|
21931
|
-
next: "Check the transcript for misheard words (pass them as fixes).
|
|
21937
|
+
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."
|
|
21932
21938
|
};
|
|
21933
21939
|
}
|
|
21934
21940
|
async function ensureFonts(work) {
|
|
@@ -22061,18 +22067,27 @@ async function composition(input) {
|
|
|
22061
22067
|
return { pj, work, html, cutout, words };
|
|
22062
22068
|
}
|
|
22063
22069
|
var fmtT = (t) => `${t.toFixed(1)}s`;
|
|
22064
|
-
var
|
|
22070
|
+
var SHOWN = ["word", "text", "title", "number", "label", "eyebrow", "keyword", "items", "lines", "labels", "steps", "message", "before", "after", "note"];
|
|
22071
|
+
var fxLine = (f) => SHOWN.filter((k) => f[k] !== void 0 && f[k] !== "").map((k) => k === "keyword" ? `comment "${f[k]}"` : Array.isArray(f[k]) ? f[k].join(" / ") : String(f[k])).join(" \xB7 ");
|
|
22065
22072
|
async function planReel(input) {
|
|
22066
22073
|
const { pj, work, html, cutout, words } = await composition(input);
|
|
22067
22074
|
const effects = [...input.effects ?? []].sort((a, b) => Number(a.start) - Number(b.start));
|
|
22068
22075
|
const peak = (f) => {
|
|
22069
22076
|
const d = Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5);
|
|
22070
|
-
|
|
22077
|
+
const at = f.slug === "frame-rip" ? d * 0.5 : Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : 0.55));
|
|
22078
|
+
return Math.min(pj.duration - 0.1, Number(f.start) + at);
|
|
22071
22079
|
};
|
|
22072
22080
|
const plainAt = effects.length && Number(effects[0].start) > 2 ? 1.2 : null;
|
|
22081
|
+
const styles = await call("styles");
|
|
22082
|
+
const title = (list, slug3) => (list.find((x) => x.slug === slug3)?.title ?? String(slug3)).replace(/\s*\(.*\)$/, "");
|
|
22083
|
+
const said = (from, to) => words.filter((w) => w.t >= from - 0.05 && w.t < to).map((w) => w.w).join(" ");
|
|
22084
|
+
const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) + Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5)));
|
|
22073
22085
|
const moments = [
|
|
22074
|
-
...plainAt !== null ? [{ t: plainAt,
|
|
22075
|
-
...effects.map((f
|
|
22086
|
+
...plainAt !== null ? [{ t: plainAt, end: Number(effects[0].start), what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
|
|
22087
|
+
...effects.map((f, k) => {
|
|
22088
|
+
const name = title(styles.effects, f.slug);
|
|
22089
|
+
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
|
|
22090
|
+
})
|
|
22076
22091
|
];
|
|
22077
22092
|
const shots = join3(work, "storyboard");
|
|
22078
22093
|
rmSync(shots, { recursive: true, force: true });
|
|
@@ -22100,11 +22115,15 @@ ${tail(t.stderr)}`);
|
|
|
22100
22115
|
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
|
|
22101
22116
|
writeFileSync2(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22102
22117
|
const text = [
|
|
22103
|
-
`
|
|
22104
|
-
|
|
22118
|
+
`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)." : ""}`,
|
|
22119
|
+
"",
|
|
22120
|
+
"| Time | What you say | What shows on screen |",
|
|
22121
|
+
"| - | - | - |",
|
|
22122
|
+
...moments.map((m) => `| ${m.t.toFixed(1)} to ${m.end.toFixed(1)}s | ${said(m.t, m.end).replace(/\|/g, "/") || "(pause)"} | ${m.what.replace(/\|/g, "/")} |`),
|
|
22123
|
+
"",
|
|
22105
22124
|
`Storyboard: ${board}`
|
|
22106
22125
|
].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." };
|
|
22126
|
+
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." };
|
|
22108
22127
|
}
|
|
22109
22128
|
async function renderReel(input) {
|
|
22110
22129
|
if (input.plan) {
|
|
@@ -22128,7 +22147,7 @@ ${tail(r.stderr) || tail(r.stdout)}`);
|
|
|
22128
22147
|
renameSync(tmp, out);
|
|
22129
22148
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22130
22149
|
const qa = await qaVideo({ video: out, write_report: true, layout: input.layout });
|
|
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
|
|
22150
|
+
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." };
|
|
22132
22151
|
}
|
|
22133
22152
|
async function qaVideo(input) {
|
|
22134
22153
|
const file = resolve2(input.video.replace(/^~/, homedir2()));
|
|
@@ -22179,29 +22198,23 @@ process.env.PATH = [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", joi
|
|
|
22179
22198
|
var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
22180
22199
|
var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
|
|
22181
22200
|
var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
|
|
22182
|
-
var INSTRUCTIONS = `Cutroom: short-form video help
|
|
22183
|
-
|
|
22184
|
-
Setup or any failure: call cutroom_doctor and show a short checklist. Missing ffmpeg: ask, then run "brew install ffmpeg". Missing Node.js: ask, then run "brew install node". Key problem: tell them to reconnect with the one install command from their welcome email; never ask them to paste the key into the chat.
|
|
22201
|
+
var INSTRUCTIONS = `Cutroom: short-form video help. Speak plainly; the user is not technical. Never show tool or effect codes; use plain names.
|
|
22185
22202
|
|
|
22186
|
-
|
|
22203
|
+
Setup or any failure: cutroom_doctor, then a short checklist. Missing ffmpeg or Node.js: ask, then run "brew install ffmpeg" or "brew install node". Key problem: reconnect Cutroom with their key from the Cutroom key space on Whop; never ask them to paste the key into the chat.
|
|
22187
22204
|
|
|
22188
|
-
|
|
22205
|
+
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.
|
|
22189
22206
|
|
|
22190
|
-
|
|
22207
|
+
Hooks, scripts, captions: get_method('hooks', 'script' or 'caption') and follow it.
|
|
22208
|
+
Break down a reel: breakdown_reel, then get_method('breakdown_writeup').
|
|
22209
|
+
Tear down a creator: creator_teardown (compare_to = their handle), then get_method('teardown_writeup').
|
|
22191
22210
|
|
|
22192
|
-
|
|
22193
|
-
1. prepare_footage with the file path.
|
|
22194
|
-
2. From the timed transcript, list words that look misheard (names, brands) and confirm spellings with the user.
|
|
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.
|
|
22211
|
+
Before a first render: show_brand; if none, offer set_brand (from colors, a website or a logo: pick background, accent, highlight, show them, save on a yes).
|
|
22199
22212
|
|
|
22200
|
-
|
|
22213
|
+
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.
|
|
22201
22214
|
|
|
22202
|
-
Results are saved in ~/Cutroom.
|
|
22215
|
+
Results are saved in ~/Cutroom. Other creators' reels are for study only: never reuse their words, footage or branding.`;
|
|
22203
22216
|
await serveStdio(() => {
|
|
22204
|
-
const server = new McpServer({ name: "cutroom", version: "0.5.
|
|
22217
|
+
const server = new McpServer({ name: "cutroom", version: "0.5.4" }, { instructions: INSTRUCTIONS });
|
|
22205
22218
|
server.registerTool("cutroom_doctor", {
|
|
22206
22219
|
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.",
|
|
22207
22220
|
inputSchema: object({})
|
|
@@ -22260,7 +22273,8 @@ ${m.text}`;
|
|
|
22260
22273
|
inputSchema: object({
|
|
22261
22274
|
video: string2().min(1).describe("Path to the raw video file on this computer."),
|
|
22262
22275
|
name: string2().optional().describe("Short name for the output, e.g. 'trial-loop'."),
|
|
22263
|
-
cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second.")
|
|
22276
|
+
cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second."),
|
|
22277
|
+
language: string2().optional().describe("Spoken language code if not English, e.g. ko, es, ja (uses a larger model, downloaded once).")
|
|
22264
22278
|
})
|
|
22265
22279
|
}, async (input) => safe(() => prepareFootage(input)));
|
|
22266
22280
|
server.registerTool("plan_reel", {
|
|
@@ -22269,7 +22283,7 @@ ${m.text}`;
|
|
|
22269
22283
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22270
22284
|
layout: string2().default("full-frame"),
|
|
22271
22285
|
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(
|
|
22286
|
+
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(5).optional() }).passthrough()).max(24).default([]).describe("Same as render_reel: e.g. [{slug:'number-pop', start:3.2, number:'24', label:'hours later'}]."),
|
|
22273
22287
|
images: array(string2()).max(8).optional().describe("Pictures for the layout, full paths on this computer."),
|
|
22274
22288
|
emphasis: array(string2()).optional(),
|
|
22275
22289
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word.")
|
|
@@ -22290,7 +22304,7 @@ ${r.next}` }, { type: "image", data: readFileSync4(r.storyboard).toString("base6
|
|
|
22290
22304
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22291
22305
|
layout: string2().default("full-frame"),
|
|
22292
22306
|
caption: string2().default("two-tier"),
|
|
22293
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(5).optional() }).passthrough()).max(
|
|
22307
|
+
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(5).optional() }).passthrough()).max(24).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
22308
|
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."),
|
|
22295
22309
|
emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
|
|
22296
22310
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word."),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepylabs/cutroom",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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": {
|