@keepylabs/cutroom 0.5.1 → 0.5.3
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 +104 -11
- 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 { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
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
|
|
|
@@ -22045,7 +22048,8 @@ ${tail(r.stderr) || tail(r.stdout)}`);
|
|
|
22045
22048
|
}
|
|
22046
22049
|
return { head: cached2.head, made_s };
|
|
22047
22050
|
}
|
|
22048
|
-
|
|
22051
|
+
var planFile = (folder) => join3(folder, ".cutroom", "plan.json");
|
|
22052
|
+
async function composition(input) {
|
|
22049
22053
|
const pj = resolveProject(input.project);
|
|
22050
22054
|
const work = join3(pj.folder, ".cutroom");
|
|
22051
22055
|
const words = applyFixes(pj.words, input.fixes);
|
|
@@ -22053,10 +22057,77 @@ async function renderReel(input) {
|
|
|
22053
22057
|
const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22054
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 } : {} });
|
|
22055
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 SHOWN = ["word", "text", "title", "number", "label", "eyebrow", "keyword", "items", "lines", "labels", "steps", "message", "before", "after", "note"];
|
|
22065
|
+
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 ");
|
|
22066
|
+
async function planReel(input) {
|
|
22067
|
+
const { pj, work, html, cutout, words } = await composition(input);
|
|
22068
|
+
const effects = [...input.effects ?? []].sort((a, b) => Number(a.start) - Number(b.start));
|
|
22069
|
+
const peak = (f) => {
|
|
22070
|
+
const d = Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5);
|
|
22071
|
+
return Math.min(pj.duration - 0.1, Number(f.start) + Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : 0.55)));
|
|
22072
|
+
};
|
|
22073
|
+
const plainAt = effects.length && Number(effects[0].start) > 2 ? 1.2 : null;
|
|
22074
|
+
const styles = await call("styles");
|
|
22075
|
+
const title = (list, slug3) => (list.find((x) => x.slug === slug3)?.title ?? String(slug3)).replace(/\s*\(.*\)$/, "");
|
|
22076
|
+
const said = (from, to) => words.filter((w) => w.t >= from - 0.05 && w.t < to).map((w) => w.w).join(" ");
|
|
22077
|
+
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)));
|
|
22078
|
+
const moments = [
|
|
22079
|
+
...plainAt !== null ? [{ t: plainAt, end: Number(effects[0].start), what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
|
|
22080
|
+
...effects.map((f, k) => {
|
|
22081
|
+
const name = title(styles.effects, f.slug);
|
|
22082
|
+
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
|
|
22083
|
+
})
|
|
22084
|
+
];
|
|
22085
|
+
const shots = join3(work, "storyboard");
|
|
22086
|
+
rmSync(shots, { recursive: true, force: true });
|
|
22087
|
+
writeFileSync2(join3(work, "index.html"), html);
|
|
22088
|
+
let r;
|
|
22089
|
+
try {
|
|
22090
|
+
r = sh("npx", ["--yes", HF, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
|
|
22091
|
+
} finally {
|
|
22092
|
+
rmSync(join3(work, "index.html"), { force: true });
|
|
22093
|
+
}
|
|
22094
|
+
const pngs = existsSync2(shots) ? readdirSync(shots).filter((f) => f.endsWith(".png")).sort() : [];
|
|
22095
|
+
if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
|
|
22096
|
+
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22097
|
+
const board = join3(pj.folder, `${pj.name}-storyboard.jpg`);
|
|
22098
|
+
const cols = Math.min(4, moments.length);
|
|
22099
|
+
const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
|
|
22100
|
+
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join3(shots, f)]);
|
|
22101
|
+
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(";");
|
|
22102
|
+
const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
|
|
22103
|
+
const stack = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
|
|
22104
|
+
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
|
|
22105
|
+
if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
|
|
22106
|
+
${tail(t.stderr)}`);
|
|
22107
|
+
rmSync(shots, { recursive: true, force: true });
|
|
22108
|
+
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
|
|
22109
|
+
writeFileSync2(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22110
|
+
const text = [
|
|
22111
|
+
`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)." : ""}`,
|
|
22112
|
+
"",
|
|
22113
|
+
"| Time | What you say | What shows on screen |",
|
|
22114
|
+
"| - | - | - |",
|
|
22115
|
+
...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, "/")} |`),
|
|
22116
|
+
"",
|
|
22117
|
+
`Storyboard: ${board}`
|
|
22118
|
+
].join("\n");
|
|
22119
|
+
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." };
|
|
22120
|
+
}
|
|
22121
|
+
async function renderReel(input) {
|
|
22122
|
+
if (input.plan) {
|
|
22123
|
+
const folder = resolveProject(input.project).folder;
|
|
22124
|
+
if (!existsSync2(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
|
|
22125
|
+
input = { ...JSON.parse(readFileSync3(planFile(folder), "utf8")), project: input.project, draft: input.draft };
|
|
22126
|
+
}
|
|
22127
|
+
const { pj, work, html, cutout } = await composition(input);
|
|
22056
22128
|
const out = join3(pj.folder, `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
|
|
22057
22129
|
const tmp = join3(work, "render.mp4");
|
|
22058
22130
|
writeFileSync2(join3(work, "index.html"), html);
|
|
22059
|
-
writeFileSync2(join3(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
22060
22131
|
const started = Date.now();
|
|
22061
22132
|
let r;
|
|
22062
22133
|
try {
|
|
@@ -22069,7 +22140,7 @@ ${tail(r.stderr) || tail(r.stdout)}`);
|
|
|
22069
22140
|
renameSync(tmp, out);
|
|
22070
22141
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22071
22142
|
const qa = await qaVideo({ video: out, write_report: true, layout: input.layout });
|
|
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
|
|
22143
|
+
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." };
|
|
22073
22144
|
}
|
|
22074
22145
|
async function qaVideo(input) {
|
|
22075
22146
|
const file = resolve2(input.video.replace(/^~/, homedir2()));
|
|
@@ -22133,16 +22204,16 @@ Tear down a creator: call creator_teardown (compare_to = the user's own handle i
|
|
|
22133
22204
|
Finish a video (raw talking-head file: edit it, add captions or effects, make it a reel):
|
|
22134
22205
|
1. prepare_footage with the file path.
|
|
22135
22206
|
2. From the timed transcript, list words that look misheard (names, brands) and confirm spellings with the user.
|
|
22136
|
-
3. list_styles (share its gallery link so the user can see each style moving), then
|
|
22137
|
-
4. render_reel with draft:true; tell them where the file is and what QA found.
|
|
22138
|
-
5. After they approve
|
|
22207
|
+
3. Plan: list_styles (share its gallery link so the user can see each style moving), then get_method('edit_plan') and follow it: every scene of the transcript gets a visual, shown as a plain table (Time | What you say | What shows on screen), never effect codes. Call plan_reel with that choice and show the user its table and the storyboard (one frame per scene). 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.
|
|
22208
|
+
4. On a go: render_reel with plan:true and draft:true; tell them where the file is and what QA found.
|
|
22209
|
+
5. After they approve the draft: render_reel with plan:true (no draft) for the final. After changes: plan_reel, then render again.
|
|
22139
22210
|
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
22211
|
|
|
22141
22212
|
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.
|
|
22142
22213
|
|
|
22143
22214
|
Results are saved in ~/Cutroom. Study only for other creators' reels: never reuse their words, footage or branding.`;
|
|
22144
22215
|
await serveStdio(() => {
|
|
22145
|
-
const server = new McpServer({ name: "cutroom", version: "0.5.
|
|
22216
|
+
const server = new McpServer({ name: "cutroom", version: "0.5.3" }, { instructions: INSTRUCTIONS });
|
|
22146
22217
|
server.registerTool("cutroom_doctor", {
|
|
22147
22218
|
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.",
|
|
22148
22219
|
inputSchema: object({})
|
|
@@ -22204,17 +22275,39 @@ ${m.text}`;
|
|
|
22204
22275
|
cut_pauses: boolean2().default(true).describe("Cut pauses longer than about half a second.")
|
|
22205
22276
|
})
|
|
22206
22277
|
}, async (input) => safe(() => prepareFootage(input)));
|
|
22278
|
+
server.registerTool("plan_reel", {
|
|
22279
|
+
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.",
|
|
22280
|
+
inputSchema: object({
|
|
22281
|
+
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22282
|
+
layout: string2().default("full-frame"),
|
|
22283
|
+
caption: string2().default("two-tier"),
|
|
22284
|
+
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'}]."),
|
|
22285
|
+
images: array(string2()).max(8).optional().describe("Pictures for the layout, full paths on this computer."),
|
|
22286
|
+
emphasis: array(string2()).optional(),
|
|
22287
|
+
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word.")
|
|
22288
|
+
})
|
|
22289
|
+
}, async (input) => {
|
|
22290
|
+
try {
|
|
22291
|
+
const r = await planReel(input);
|
|
22292
|
+
return { content: [{ type: "text", text: `${r.text}
|
|
22293
|
+
|
|
22294
|
+
${r.next}` }, { type: "image", data: readFileSync4(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
|
|
22295
|
+
} catch (e) {
|
|
22296
|
+
return failure2(e);
|
|
22297
|
+
}
|
|
22298
|
+
});
|
|
22207
22299
|
server.registerTool("render_reel", {
|
|
22208
|
-
description: "Step
|
|
22300
|
+
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.",
|
|
22209
22301
|
inputSchema: object({
|
|
22210
22302
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
22211
22303
|
layout: string2().default("full-frame"),
|
|
22212
22304
|
caption: string2().default("two-tier"),
|
|
22213
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(5).optional() }).passthrough()).max(
|
|
22305
|
+
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."),
|
|
22214
22306
|
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."),
|
|
22215
22307
|
emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
|
|
22216
22308
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word."),
|
|
22217
|
-
draft: boolean2().default(false)
|
|
22309
|
+
draft: boolean2().default(false),
|
|
22310
|
+
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).")
|
|
22218
22311
|
})
|
|
22219
22312
|
}, async (input) => safe(() => renderReel(input)));
|
|
22220
22313
|
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.3",
|
|
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": {
|