@keepylabs/cutroom 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cutroom.mjs +345 -174
- package/package.json +1 -1
package/cutroom.mjs
CHANGED
|
@@ -8,7 +8,7 @@ var __export = (target, all) => {
|
|
|
8
8
|
|
|
9
9
|
// src/community/local/server.ts
|
|
10
10
|
import { homedir as homedir3 } from "node:os";
|
|
11
|
-
import { delimiter, join as
|
|
11
|
+
import { delimiter, join as join7 } from "node:path";
|
|
12
12
|
|
|
13
13
|
// src/community/local/proc.ts
|
|
14
14
|
import { spawnSync } from "node:child_process";
|
|
@@ -19201,8 +19201,8 @@ var require_formats = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
19201
19201
|
if (!matches) return false;
|
|
19202
19202
|
const year = +matches[1];
|
|
19203
19203
|
const month = +matches[2];
|
|
19204
|
-
const
|
|
19205
|
-
return month >= 1 && month <= 12 &&
|
|
19204
|
+
const day4 = +matches[3];
|
|
19205
|
+
return month >= 1 && month <= 12 && day4 >= 1 && day4 <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]);
|
|
19206
19206
|
}
|
|
19207
19207
|
function compareDate(d1, d2) {
|
|
19208
19208
|
if (!(d1 && d2)) return void 0;
|
|
@@ -21559,13 +21559,13 @@ import { readFileSync as readFileSync6 } from "node:fs";
|
|
|
21559
21559
|
|
|
21560
21560
|
// src/community/local/render.ts
|
|
21561
21561
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
21562
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
21562
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync5, mkdirSync as mkdirSync5, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync5, renameSync as renameSync3, rmSync as rmSync3, statSync, writeFileSync as writeFileSync5 } from "node:fs";
|
|
21563
21563
|
import { homedir as homedir2, tmpdir } from "node:os";
|
|
21564
|
-
import { basename as basename2, extname, join as
|
|
21564
|
+
import { basename as basename2, extname, join as join6, resolve as resolve3 } from "node:path";
|
|
21565
21565
|
|
|
21566
21566
|
// src/community/local/measure.ts
|
|
21567
21567
|
import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
|
|
21568
|
-
import { mkdirSync, readFileSync } from "node:fs";
|
|
21568
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21569
21569
|
import { join } from "node:path";
|
|
21570
21570
|
var ffmpegErr = (args) => spawnSync2("ffmpeg", ["-nostdin", "-hide_banner", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).stderr ?? "";
|
|
21571
21571
|
function probe(file) {
|
|
@@ -21609,6 +21609,34 @@ function sampleFrames(file, dir, duration3, interval = 1, max = 40) {
|
|
|
21609
21609
|
}
|
|
21610
21610
|
return { frames: out, interval: step };
|
|
21611
21611
|
}
|
|
21612
|
+
function sheetGrid(n) {
|
|
21613
|
+
const cols = Math.max(1, Math.min(8, n));
|
|
21614
|
+
return { cols, rows: Math.max(1, Math.ceil(n / cols)) };
|
|
21615
|
+
}
|
|
21616
|
+
function contactSheet(frames, out) {
|
|
21617
|
+
if (!frames.length) return null;
|
|
21618
|
+
const tmp = `${out}.parts`;
|
|
21619
|
+
mkdirSync(tmp, { recursive: true });
|
|
21620
|
+
try {
|
|
21621
|
+
const parts = frames.map((f, i) => {
|
|
21622
|
+
const part = join(tmp, `${String(i).padStart(3, "0")}.jpg`);
|
|
21623
|
+
const label = `${Math.floor(f.t / 60)}\\:${(f.t % 60).toFixed(1).padStart(4, "0")}`;
|
|
21624
|
+
const scale = "scale=200:-2";
|
|
21625
|
+
const labelled = spawnSync2("ffmpeg", ["-v", "error", "-y", "-i", f.path, "-vf", `${scale},drawtext=text='${label}':x=8:y=8:fontsize=22:fontcolor=white:box=1:boxcolor=black@0.7:boxborderw=6`, part]);
|
|
21626
|
+
if (labelled.status !== 0) execFileSync("ffmpeg", ["-v", "error", "-y", "-i", f.path, "-vf", scale, part]);
|
|
21627
|
+
return part;
|
|
21628
|
+
});
|
|
21629
|
+
const list = join(tmp, "list.txt");
|
|
21630
|
+
writeFileSync(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
|
|
21631
|
+
const { cols, rows } = sheetGrid(parts.length);
|
|
21632
|
+
execFileSync("ffmpeg", ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", list, "-vf", `tile=${cols}x${rows}:padding=4:color=black`, "-frames:v", "1", "-q:v", "4", out]);
|
|
21633
|
+
return out;
|
|
21634
|
+
} catch {
|
|
21635
|
+
return null;
|
|
21636
|
+
} finally {
|
|
21637
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
21638
|
+
}
|
|
21639
|
+
}
|
|
21612
21640
|
function parseSilences(log, duration3) {
|
|
21613
21641
|
const out = [];
|
|
21614
21642
|
let start = null;
|
|
@@ -21658,29 +21686,146 @@ function technicalChecks(file) {
|
|
|
21658
21686
|
}
|
|
21659
21687
|
|
|
21660
21688
|
// src/community/local/studio.ts
|
|
21661
|
-
import {
|
|
21662
|
-
import {
|
|
21689
|
+
import { existsSync as existsSync4, renameSync as renameSync2 } from "node:fs";
|
|
21690
|
+
import { join as join5 } from "node:path";
|
|
21691
|
+
|
|
21692
|
+
// src/studio-core.ts
|
|
21693
|
+
var studio_core_exports = {};
|
|
21694
|
+
__export(studio_core_exports, {
|
|
21695
|
+
STATUSES: () => STATUSES,
|
|
21696
|
+
addCheck: () => addCheck,
|
|
21697
|
+
day: () => day,
|
|
21698
|
+
ensureLayout: () => ensureLayout,
|
|
21699
|
+
keepRender: () => keepRender,
|
|
21700
|
+
layout: () => layout,
|
|
21701
|
+
nextVersion: () => nextVersion,
|
|
21702
|
+
setStatus: () => setStatus,
|
|
21703
|
+
slug: () => slug,
|
|
21704
|
+
startVideoNotes: () => startVideoNotes,
|
|
21705
|
+
statusOf: () => statusOf,
|
|
21706
|
+
takeRaw: () => takeRaw,
|
|
21707
|
+
videoFolderOf: () => videoFolderOf,
|
|
21708
|
+
videoPath: () => videoPath
|
|
21709
|
+
});
|
|
21710
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
21711
|
+
import { basename, dirname, isAbsolute, join as join2, relative, resolve } from "node:path";
|
|
21712
|
+
var STATUSES = ["footage ready", "plan ready", "preview", "final", "posted"];
|
|
21713
|
+
var day = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
21714
|
+
var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
21715
|
+
var layout = (home) => ({ home, brand: join2(home, "brand"), references: join2(home, "references"), inbox: join2(home, "inbox"), videos: join2(home, "videos") });
|
|
21716
|
+
function ensureLayout(home, claudeMd) {
|
|
21717
|
+
const s = layout(home);
|
|
21718
|
+
for (const d of [s.brand, s.references, s.inbox, s.videos]) mkdirSync2(d, { recursive: true });
|
|
21719
|
+
const claude = join2(home, "CLAUDE.md");
|
|
21720
|
+
if (!existsSync(claude)) writeFileSync2(claude, claudeMd);
|
|
21721
|
+
return s;
|
|
21722
|
+
}
|
|
21723
|
+
function videoPath(home, topic, date4 = day()) {
|
|
21724
|
+
const videos = layout(home).videos;
|
|
21725
|
+
const t = slug(topic) || "video";
|
|
21726
|
+
const existing = existsSync(videos) ? readdirSync(videos).filter((name) => /^\d{4}-\d{2}-\d{2}-/.test(name) && name.slice(11) === t).sort() : [];
|
|
21727
|
+
return join2(videos, existing.at(-1) ?? `${date4}-${t}`);
|
|
21728
|
+
}
|
|
21729
|
+
function videoFolderOf(path) {
|
|
21730
|
+
for (let p = resolve(path); p !== dirname(p); p = dirname(p)) {
|
|
21731
|
+
if (basename(dirname(p)) === "videos" && existsSync(join2(p, "plan.md"))) return p;
|
|
21732
|
+
}
|
|
21733
|
+
return null;
|
|
21734
|
+
}
|
|
21735
|
+
var inside = (child, parent) => {
|
|
21736
|
+
const r = relative(parent, child);
|
|
21737
|
+
return !!r && !r.startsWith("..") && !isAbsolute(r);
|
|
21738
|
+
};
|
|
21739
|
+
function takeRaw(source, folder, inbox) {
|
|
21740
|
+
const raw = join2(folder, "raw");
|
|
21741
|
+
const file = resolve(source);
|
|
21742
|
+
if (inside(file, raw)) return { file, how: "already here" };
|
|
21743
|
+
mkdirSync2(raw, { recursive: true });
|
|
21744
|
+
let target = join2(raw, basename(file));
|
|
21745
|
+
for (let n = 2; existsSync(target); n++) target = join2(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
|
|
21746
|
+
if (inbox && inside(file, inbox)) {
|
|
21747
|
+
try {
|
|
21748
|
+
renameSync(file, target);
|
|
21749
|
+
return { file: target, how: "moved from inbox" };
|
|
21750
|
+
} catch {
|
|
21751
|
+
}
|
|
21752
|
+
}
|
|
21753
|
+
copyFileSync(file, target);
|
|
21754
|
+
return { file: target, how: "copied" };
|
|
21755
|
+
}
|
|
21756
|
+
function setStatus(folder, status, plan) {
|
|
21757
|
+
const file = join2(folder, "plan.md");
|
|
21758
|
+
const current = existsSync(file) ? readFileSync2(file, "utf8") : `# ${basename(folder)}
|
|
21759
|
+
|
|
21760
|
+
status:
|
|
21761
|
+
`;
|
|
21762
|
+
const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
|
|
21763
|
+
status: ${status}
|
|
21764
|
+
`);
|
|
21765
|
+
writeFileSync2(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
|
|
21766
|
+
|
|
21767
|
+
## Plan
|
|
21768
|
+
${plan}
|
|
21769
|
+
`);
|
|
21770
|
+
}
|
|
21771
|
+
var statusOf = (folder) => {
|
|
21772
|
+
const file = join2(folder, "plan.md");
|
|
21773
|
+
return existsSync(file) ? readFileSync2(file, "utf8").match(/^status: *(.*)$/m)?.[1].trim() ?? null : null;
|
|
21774
|
+
};
|
|
21775
|
+
var NO_SCRIPT = "(no script yet)";
|
|
21776
|
+
function startVideoNotes(folder, script, who = "is said", source = "from the footage; fix any misheard words here") {
|
|
21777
|
+
const scriptFile = join2(folder, "script.md"), facts = join2(folder, "facts.md"), notes = join2(folder, "references", "notes.md");
|
|
21778
|
+
mkdirSync2(join2(folder, "references"), { recursive: true });
|
|
21779
|
+
if (!existsSync(notes)) writeFileSync2(notes, "# References for this video\n\nWhat this video copies and why it works, one line each.\n\n");
|
|
21780
|
+
if (!existsSync(facts)) writeFileSync2(facts, "# Facts\n\nEvery number or claim that shows on screen, with where it comes from.\n\n| on screen | source |\n| - | - |\n");
|
|
21781
|
+
const waiting = !existsSync(scriptFile) || script !== void 0 && readFileSync2(scriptFile, "utf8").includes(NO_SCRIPT);
|
|
21782
|
+
if (waiting) writeFileSync2(scriptFile, `# Script
|
|
21783
|
+
|
|
21784
|
+
What ${who}, word for word${script ? ` (${source})` : ""}.
|
|
21785
|
+
|
|
21786
|
+
${script ?? NO_SCRIPT}
|
|
21787
|
+
`);
|
|
21788
|
+
}
|
|
21789
|
+
function addCheck(folder, entry) {
|
|
21790
|
+
const file = join2(folder, "checks.md");
|
|
21791
|
+
if (!existsSync(file)) writeFileSync2(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
|
|
21792
|
+
appendFileSync(file, `
|
|
21793
|
+
${entry.trim()}
|
|
21794
|
+
`);
|
|
21795
|
+
}
|
|
21796
|
+
function nextVersion(base, ext, dirs) {
|
|
21797
|
+
let n = 1;
|
|
21798
|
+
while (dirs.some((d) => existsSync(join2(d, `${base}-v${n}${ext}`)))) n++;
|
|
21799
|
+
return `${base}-v${n}${ext}`;
|
|
21800
|
+
}
|
|
21801
|
+
function keepRender(folder, file, name = basename(file)) {
|
|
21802
|
+
mkdirSync2(join2(folder, "renders"), { recursive: true });
|
|
21803
|
+
let target = join2(folder, "renders", name);
|
|
21804
|
+
for (let n = 2; existsSync(target); n++) target = join2(folder, "renders", name.replace(/(\.[^.]*)?$/, `-${n}$1`));
|
|
21805
|
+
copyFileSync(file, target);
|
|
21806
|
+
return target;
|
|
21807
|
+
}
|
|
21663
21808
|
|
|
21664
21809
|
// src/community/local/tools.ts
|
|
21665
|
-
import { createWriteStream, existsSync as
|
|
21810
|
+
import { createWriteStream, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
21666
21811
|
import { homedir } from "node:os";
|
|
21667
|
-
import { join as
|
|
21812
|
+
import { join as join4, resolve as resolve2 } from "node:path";
|
|
21668
21813
|
import { Readable } from "node:stream";
|
|
21669
21814
|
import { pipeline } from "node:stream/promises";
|
|
21670
21815
|
|
|
21671
21816
|
// src/community/local/signin.ts
|
|
21672
21817
|
import { spawn } from "node:child_process";
|
|
21673
21818
|
import { createHash, randomBytes } from "node:crypto";
|
|
21674
|
-
import { chmodSync, existsSync, mkdirSync as
|
|
21819
|
+
import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
21675
21820
|
import { createServer } from "node:http";
|
|
21676
|
-
import { join as
|
|
21821
|
+
import { join as join3 } from "node:path";
|
|
21677
21822
|
var WHOP_APP = "app_bFhJFdBJn9jZ0v";
|
|
21678
21823
|
var PORTS = [53171, 53172, 53173];
|
|
21679
21824
|
var WAIT_MS = 5 * 6e4;
|
|
21680
|
-
var keyFile = () =>
|
|
21825
|
+
var keyFile = () => join3(HOME(), ".cutroom-key");
|
|
21681
21826
|
function currentKey() {
|
|
21682
21827
|
try {
|
|
21683
|
-
const k =
|
|
21828
|
+
const k = readFileSync3(keyFile(), "utf8").trim();
|
|
21684
21829
|
if (k) return k;
|
|
21685
21830
|
} catch {
|
|
21686
21831
|
}
|
|
@@ -21688,14 +21833,14 @@ function currentKey() {
|
|
|
21688
21833
|
return env && !env.includes("${") ? env : null;
|
|
21689
21834
|
}
|
|
21690
21835
|
function saveKey(key) {
|
|
21691
|
-
|
|
21692
|
-
|
|
21836
|
+
mkdirSync3(HOME(), { recursive: true });
|
|
21837
|
+
writeFileSync3(keyFile(), key, { mode: 384 });
|
|
21693
21838
|
try {
|
|
21694
21839
|
chmodSync(keyFile(), 384);
|
|
21695
21840
|
} catch {
|
|
21696
21841
|
}
|
|
21697
21842
|
}
|
|
21698
|
-
var forgetKey = () =>
|
|
21843
|
+
var forgetKey = () => rmSync2(keyFile(), { force: true });
|
|
21699
21844
|
function openBrowser(url2) {
|
|
21700
21845
|
const [cmd, args] = process.platform === "darwin" ? ["open", [url2]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url2]] : ["xdg-open", [url2]];
|
|
21701
21846
|
try {
|
|
@@ -21788,9 +21933,9 @@ ${authorize}
|
|
|
21788
21933
|
}
|
|
21789
21934
|
|
|
21790
21935
|
// src/community/local/tools.ts
|
|
21791
|
-
var VERSION = "0.
|
|
21936
|
+
var VERSION = "0.8.0";
|
|
21792
21937
|
var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
|
|
21793
|
-
var HOME = () => process.env.CUTROOM_HOME ||
|
|
21938
|
+
var HOME = () => process.env.CUTROOM_HOME || join4(homedir(), "Cutroom");
|
|
21794
21939
|
async function call(op, body = {}, fetchImpl = fetch) {
|
|
21795
21940
|
const key = currentKey();
|
|
21796
21941
|
if (!key) throw Object.assign(new Error("Not signed in to Cutroom yet. Run the setup check (cutroom_doctor): it opens the Whop sign-in."), { status: 401 });
|
|
@@ -21810,8 +21955,8 @@ async function call(op, body = {}, fetchImpl = fetch) {
|
|
|
21810
21955
|
if (!res.ok) throw Object.assign(new Error(json?.error ?? `Cutroom error (HTTP ${res.status}).`), { status: res.status });
|
|
21811
21956
|
return json;
|
|
21812
21957
|
}
|
|
21813
|
-
var
|
|
21814
|
-
var
|
|
21958
|
+
var slug2 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
21959
|
+
var day2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
21815
21960
|
var fmt = (t) => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, "0")}`;
|
|
21816
21961
|
var pct = (n) => `${Math.round(n * 100)}%`;
|
|
21817
21962
|
async function download(url2, target) {
|
|
@@ -21870,7 +22015,7 @@ function localWords(file, dir) {
|
|
|
21870
22015
|
try {
|
|
21871
22016
|
const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
|
|
21872
22017
|
if (r.status !== 0 || !meta2.ok) return null;
|
|
21873
|
-
const raw = JSON.parse(
|
|
22018
|
+
const raw = JSON.parse(readFileSync4(meta2.transcriptPath ?? join4(dir, "transcript.json"), "utf8"));
|
|
21874
22019
|
const words = raw.map((w) => ({ t: Math.round(w.start * 100) / 100, e: Math.round(w.end * 100) / 100, w: w.text.trim() })).filter((w) => w.w);
|
|
21875
22020
|
return words.length ? words : null;
|
|
21876
22021
|
} catch {
|
|
@@ -21891,22 +22036,25 @@ async function breakdownReel(input) {
|
|
|
21891
22036
|
let meta2 = null, transcript = null, file;
|
|
21892
22037
|
const src = input.source.trim();
|
|
21893
22038
|
if (/^https?:\/\//i.test(src)) {
|
|
21894
|
-
const r = await call("reel", { url: src })
|
|
22039
|
+
const r = await call("reel", { url: src }).catch((e) => {
|
|
22040
|
+
throw new Error(`Couldn't fetch that Instagram link right now (${e instanceof Error ? e.message : e}). Save the reel to this computer and give me the file path instead: breakdown_reel works the same on a file.`);
|
|
22041
|
+
});
|
|
21895
22042
|
meta2 = r.meta;
|
|
21896
22043
|
transcript = r.transcript;
|
|
21897
|
-
const dir2 =
|
|
21898
|
-
|
|
21899
|
-
file =
|
|
21900
|
-
if (!
|
|
22044
|
+
const dir2 = join4(studio.references(), `${day2()}-${slug2(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
|
|
22045
|
+
mkdirSync4(dir2, { recursive: true });
|
|
22046
|
+
file = join4(dir2, "source.mp4");
|
|
22047
|
+
if (!existsSync3(file)) await download(meta2.videoUrl, file);
|
|
21901
22048
|
} else {
|
|
21902
|
-
file =
|
|
21903
|
-
if (!
|
|
22049
|
+
file = resolve2(src.replace(/^~/, homedir()));
|
|
22050
|
+
if (!existsSync3(file)) throw new Error(`No such file: ${file}`);
|
|
21904
22051
|
}
|
|
21905
|
-
const dir = /^https?:/i.test(src) ?
|
|
21906
|
-
|
|
22052
|
+
const dir = /^https?:/i.test(src) ? join4(file, "..") : join4(studio.references(), `${day2()}-${slug2(file.split(/[\\/]/).pop() ?? "video")}`);
|
|
22053
|
+
mkdirSync4(dir, { recursive: true });
|
|
21907
22054
|
const m = measure(file);
|
|
21908
|
-
const { frames, interval } = sampleFrames(file,
|
|
22055
|
+
const { frames, interval } = sampleFrames(file, join4(dir, "frames"), m.duration, 1, 40);
|
|
21909
22056
|
const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
|
|
22057
|
+
const sheet = contactSheet(frames, join4(dir, "sheet.jpg"));
|
|
21910
22058
|
const timed = localWords(file, dir);
|
|
21911
22059
|
const words = timed ?? approxWords(transcript, m.duration);
|
|
21912
22060
|
const bs = beats(m, words, match.frames);
|
|
@@ -21929,6 +22077,7 @@ async function breakdownReel(input) {
|
|
|
21929
22077
|
hook ? `- first line: "${hook}"` : "- no transcript (silent video or local file)",
|
|
21930
22078
|
"",
|
|
21931
22079
|
"## The look",
|
|
22080
|
+
sheet ? "Every sampled frame, labelled with its time: sheet.jpg. The look below is the closest Cutroom parts; check it against the sheet." : "Sampled frames are in frames/. The look below is the closest Cutroom parts; check it against them.",
|
|
21932
22081
|
"Layout, share of sampled frames:",
|
|
21933
22082
|
...Object.entries(s.layout_share).map(([k, v]) => `- ${k}: ${pct(v)}`),
|
|
21934
22083
|
"Captions:",
|
|
@@ -21945,9 +22094,9 @@ async function breakdownReel(input) {
|
|
|
21945
22094
|
transcript ?? "(none)",
|
|
21946
22095
|
""
|
|
21947
22096
|
].join("\n");
|
|
21948
|
-
|
|
21949
|
-
const out = { folder: dir, report:
|
|
21950
|
-
|
|
22097
|
+
writeFileSync4(join4(dir, "breakdown.md"), md);
|
|
22098
|
+
const out = { folder: dir, report: join4(dir, "breakdown.md"), sheet, hook, measure: m, look: s, beats: bs, words_timing: timed ? "measured" : transcript ? "approximate" : "none", transcript, meta: meta2, next: "Get the 'breakdown_writeup' method with get_method and follow it." };
|
|
22099
|
+
writeFileSync4(join4(dir, "breakdown.json"), JSON.stringify(out, null, 2));
|
|
21951
22100
|
return out;
|
|
21952
22101
|
}
|
|
21953
22102
|
function ctaKeyword(caption) {
|
|
@@ -21956,15 +22105,15 @@ function ctaKeyword(caption) {
|
|
|
21956
22105
|
}
|
|
21957
22106
|
async function teardownOne(handle, limit) {
|
|
21958
22107
|
const { reels } = await call("reels", { handle, limit });
|
|
21959
|
-
const dir =
|
|
21960
|
-
|
|
22108
|
+
const dir = join4(studio.references(), `${day2()}-${slug2(handle)}-creator`);
|
|
22109
|
+
mkdirSync4(join4(dir, "raw"), { recursive: true });
|
|
21961
22110
|
const rows = [];
|
|
21962
22111
|
for (const [i, r] of reels.entries()) {
|
|
21963
22112
|
try {
|
|
21964
|
-
const file =
|
|
21965
|
-
if (!
|
|
22113
|
+
const file = join4(dir, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
|
|
22114
|
+
if (!existsSync3(file)) await download(r.videoUrl, file);
|
|
21966
22115
|
const m = measure(file);
|
|
21967
|
-
const { frames, interval } = sampleFrames(file,
|
|
22116
|
+
const { frames, interval } = sampleFrames(file, join4(dir, "frames", r.shortCode), m.duration, 2, 20);
|
|
21968
22117
|
const [match, tr] = await Promise.all([call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval }), call("transcript", { url: r.url }).catch(() => ({ transcript: null }))]);
|
|
21969
22118
|
const top = (o) => Object.keys(o)[0] ?? null;
|
|
21970
22119
|
rows.push({ code: r.shortCode, url: r.url, posted: String(r.timestamp ?? "").slice(0, 10), plays: r.videoPlayCount ?? null, duration_s: Math.round(m.duration * 10) / 10, cuts_per_min: m.cuts_per_min, layout: top(match.summary.layout_share), caption: top(match.summary.caption_share), effects: match.summary.effects.filter((e) => e.share >= 0.1 || e.frames >= 2).map((e) => e.title), off_menu: match.summary.effects.filter((e) => e.in_menu === false).map((e) => e.title), hook: tr.transcript ? String(tr.transcript).split(/(?<=[.!?])\s/)[0] : null, cta: ctaKeyword(r.caption) });
|
|
@@ -22036,18 +22185,18 @@ async function creatorTeardown(input) {
|
|
|
22036
22185
|
...a.rows.map((x) => x.error ? `| ${x.posted} | ${x.plays ?? ""} | | | failed: ${String(x.error).slice(0, 60)} | | |` : `| [${x.posted}](${x.url}) | ${x.plays ?? ""} | ${x.duration_s} | ${x.cuts_per_min} | ${x.layout ?? ""} | ${x.effects.join(", ")} | ${(x.hook ?? "").replace(/\|/g, "/").slice(0, 90)} |`),
|
|
22037
22186
|
""
|
|
22038
22187
|
].join("\n");
|
|
22039
|
-
|
|
22040
|
-
const out = { folder: a.dir, report:
|
|
22041
|
-
|
|
22188
|
+
writeFileSync4(join4(a.dir, "teardown.md"), md);
|
|
22189
|
+
const out = { folder: a.dir, report: join4(a.dir, "teardown.md"), rollup: r, compare: cmp, rows: a.rows, next: "Get the 'teardown_writeup' method with get_method and follow it." };
|
|
22190
|
+
writeFileSync4(join4(a.dir, "teardown.json"), JSON.stringify(out, null, 2));
|
|
22042
22191
|
return out;
|
|
22043
22192
|
}
|
|
22044
22193
|
|
|
22045
22194
|
// src/community/local/studio.ts
|
|
22046
22195
|
var studio = {
|
|
22047
|
-
brand: () =>
|
|
22048
|
-
references: () =>
|
|
22049
|
-
inbox: () =>
|
|
22050
|
-
videos: () =>
|
|
22196
|
+
brand: () => layout(HOME()).brand,
|
|
22197
|
+
references: () => layout(HOME()).references,
|
|
22198
|
+
inbox: () => layout(HOME()).inbox,
|
|
22199
|
+
videos: () => layout(HOME()).videos
|
|
22051
22200
|
};
|
|
22052
22201
|
var CLAUDE_MD = `# My Cutroom studio
|
|
22053
22202
|
|
|
@@ -22075,80 +22224,25 @@ Everything for my videos lives in this folder. Open Claude Code here so it can s
|
|
|
22075
22224
|
## My rules
|
|
22076
22225
|
`;
|
|
22077
22226
|
function ensureStudio() {
|
|
22078
|
-
|
|
22079
|
-
const
|
|
22080
|
-
if (!
|
|
22081
|
-
const oldBrand = join4(HOME(), "brand.json"), newBrand = join4(studio.brand(), "brand.json");
|
|
22082
|
-
if (existsSync3(oldBrand) && !existsSync3(newBrand)) renameSync(oldBrand, newBrand);
|
|
22227
|
+
ensureLayout(HOME(), CLAUDE_MD);
|
|
22228
|
+
const oldBrand = join5(HOME(), "brand.json"), newBrand = join5(studio.brand(), "brand.json");
|
|
22229
|
+
if (existsSync4(oldBrand) && !existsSync4(newBrand)) renameSync2(oldBrand, newBrand);
|
|
22083
22230
|
return HOME();
|
|
22084
22231
|
}
|
|
22085
|
-
var
|
|
22086
|
-
|
|
22087
|
-
|
|
22088
|
-
};
|
|
22089
|
-
function takeRaw(source, folder) {
|
|
22090
|
-
const raw = join4(folder, "raw");
|
|
22091
|
-
const file = resolve2(source);
|
|
22092
|
-
if (inside(file, raw)) return { file, how: "already here" };
|
|
22093
|
-
mkdirSync4(raw, { recursive: true });
|
|
22094
|
-
let target = join4(raw, basename(file));
|
|
22095
|
-
for (let n = 2; existsSync3(target); n++) target = join4(raw, basename(file).replace(/(\.[^.]*)?$/, `-${n}$1`));
|
|
22096
|
-
if (inside(file, studio.inbox())) {
|
|
22097
|
-
try {
|
|
22098
|
-
renameSync(file, target);
|
|
22099
|
-
return { file: target, how: "moved from inbox" };
|
|
22100
|
-
} catch {
|
|
22101
|
-
}
|
|
22102
|
-
}
|
|
22103
|
-
copyFileSync(file, target);
|
|
22104
|
-
return { file: target, how: "copied" };
|
|
22105
|
-
}
|
|
22106
|
-
function setStatus(folder, status, plan) {
|
|
22107
|
-
const file = join4(folder, "plan.md");
|
|
22108
|
-
const name = basename(folder);
|
|
22109
|
-
const current = existsSync3(file) ? readFileSync4(file, "utf8") : `# ${name}
|
|
22110
|
-
|
|
22111
|
-
status:
|
|
22112
|
-
`;
|
|
22113
|
-
const withStatus = /^status:.*$/m.test(current) ? current.replace(/^status:.*$/m, `status: ${status}`) : current.replace(/^(# .*\n)/, `$1
|
|
22114
|
-
status: ${status}
|
|
22115
|
-
`);
|
|
22116
|
-
writeFileSync3(file, plan === void 0 ? withStatus : `${withStatus.split(/\n## Plan\n/)[0].trimEnd()}
|
|
22117
|
-
|
|
22118
|
-
## Plan
|
|
22119
|
-
${plan}
|
|
22120
|
-
`);
|
|
22121
|
-
}
|
|
22122
|
-
function startVideoNotes(folder, transcript) {
|
|
22123
|
-
const script = join4(folder, "script.md"), facts = join4(folder, "facts.md"), notes = join4(folder, "references", "notes.md");
|
|
22124
|
-
mkdirSync4(join4(folder, "references"), { recursive: true });
|
|
22125
|
-
if (!existsSync3(notes)) writeFileSync3(notes, "# References for this video\n\nWhat this video copies and why it works, one line each.\n\n");
|
|
22126
|
-
if (!existsSync3(script)) writeFileSync3(script, `# Script
|
|
22127
|
-
|
|
22128
|
-
What I say, word for word (from the footage; fix any misheard words here).
|
|
22129
|
-
|
|
22130
|
-
${transcript}
|
|
22131
|
-
`);
|
|
22132
|
-
if (!existsSync3(facts)) writeFileSync3(facts, "# Facts\n\nEvery number or claim that shows on screen, with where it comes from.\n\n| on screen | source |\n| - | - |\n");
|
|
22133
|
-
}
|
|
22134
|
-
function addCheck(folder, entry) {
|
|
22135
|
-
const file = join4(folder, "checks.md");
|
|
22136
|
-
if (!existsSync3(file)) writeFileSync3(file, "# Checks\n\nWhat QA found on each render, and what got fixed.\n");
|
|
22137
|
-
appendFileSync(file, `
|
|
22138
|
-
${entry.trim()}
|
|
22139
|
-
`);
|
|
22140
|
-
}
|
|
22232
|
+
var takeRaw2 = (source, folder) => takeRaw(source, folder, studio.inbox());
|
|
22233
|
+
var startVideoNotes2 = (folder, transcript) => startVideoNotes(folder, transcript, "I say");
|
|
22234
|
+
var { setStatus: setStatus2, addCheck: addCheck2 } = studio_core_exports;
|
|
22141
22235
|
|
|
22142
22236
|
// src/community/local/render.ts
|
|
22143
22237
|
var HF = "hyperframes@0.8.99";
|
|
22144
|
-
var
|
|
22145
|
-
var
|
|
22238
|
+
var day3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
22239
|
+
var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
|
|
22146
22240
|
var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
22147
22241
|
var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
|
|
22148
|
-
var projectFile = (folder) =>
|
|
22242
|
+
var projectFile = (folder) => join6(folder, ".cutroom", "project.json");
|
|
22149
22243
|
var resolveProject = (p) => {
|
|
22150
22244
|
const folder = resolve3(p.replace(/^~/, homedir2()));
|
|
22151
|
-
const file = [folder,
|
|
22245
|
+
const file = [folder, join6(studio.videos(), p), join6(HOME(), "renders", p)].map(projectFile).find((f) => existsSync5(f)) ?? null;
|
|
22152
22246
|
if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
|
|
22153
22247
|
return JSON.parse(readFileSync5(file, "utf8"));
|
|
22154
22248
|
};
|
|
@@ -22166,19 +22260,19 @@ function looksLikeSpeech(words, duration3) {
|
|
|
22166
22260
|
}
|
|
22167
22261
|
async function prepareFootage(input) {
|
|
22168
22262
|
const source = resolve3(input.video.replace(/^~/, homedir2()));
|
|
22169
|
-
if (!
|
|
22263
|
+
if (!existsSync5(source)) throw new Error(`No such file: ${source}`);
|
|
22170
22264
|
const p = probe(source);
|
|
22171
22265
|
if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
|
|
22172
22266
|
if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
|
|
22173
|
-
const name =
|
|
22267
|
+
const name = slug3(input.name ?? basename2(source, extname(source)));
|
|
22174
22268
|
ensureStudio();
|
|
22175
|
-
const folder =
|
|
22176
|
-
const work =
|
|
22269
|
+
const folder = join6(studio.videos(), `${day3()}-${name}`);
|
|
22270
|
+
const work = join6(folder, ".cutroom");
|
|
22177
22271
|
mkdirSync5(work, { recursive: true });
|
|
22178
|
-
const clip =
|
|
22272
|
+
const clip = takeRaw2(source, folder);
|
|
22179
22273
|
const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
|
|
22180
22274
|
if (!segments.length) throw new Error("Couldn't find any speech in that video.");
|
|
22181
|
-
const footage =
|
|
22275
|
+
const footage = join6(work, "footage.mp4");
|
|
22182
22276
|
cutAndNormalize(clip.file, footage, segments);
|
|
22183
22277
|
const duration3 = probe(footage).duration;
|
|
22184
22278
|
const lang = (input.language ?? "en").toLowerCase().slice(0, 5);
|
|
@@ -22192,13 +22286,13 @@ async function prepareFootage(input) {
|
|
|
22192
22286
|
})();
|
|
22193
22287
|
if (t.status !== 0 || !meta2.ok) throw new Error(`Transcription failed. Run cutroom_doctor.
|
|
22194
22288
|
${meta2.error ?? tail(t.stderr)}`);
|
|
22195
|
-
const raw = JSON.parse(readFileSync5(meta2.transcriptPath ??
|
|
22289
|
+
const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join6(work, "transcript.json"), "utf8"));
|
|
22196
22290
|
const words = raw.map((w) => ({ t: Math.round(w.start * 100) / 100, e: Math.round(w.end * 100) / 100, w: w.text.trim() })).filter((w) => w.w);
|
|
22197
22291
|
if (!looksLikeSpeech(words, duration3)) throw new Error(lang === "en" ? "I couldn't hear anyone talking in this video (it sounds like music or silence). Cutroom finishing needs you talking to camera. If you speak another language, tell me which one and I'll try again." : `I couldn't make out the speech in this video, even in that language. Check the sound is you talking to camera.`);
|
|
22198
22292
|
const project = { name, folder, source: clip.file, footage, duration: Math.round(duration3 * 100) / 100, words, removed_s: Math.round((p.duration - duration3) * 10) / 10, segments };
|
|
22199
|
-
|
|
22200
|
-
|
|
22201
|
-
|
|
22293
|
+
writeFileSync5(projectFile(folder), JSON.stringify(project, null, 2));
|
|
22294
|
+
startVideoNotes2(folder, words.map((w) => w.w).join(" "));
|
|
22295
|
+
setStatus2(folder, "footage ready");
|
|
22202
22296
|
const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
|
|
22203
22297
|
return {
|
|
22204
22298
|
project: folder,
|
|
@@ -22211,15 +22305,15 @@ ${meta2.error ?? tail(t.stderr)}`);
|
|
|
22211
22305
|
};
|
|
22212
22306
|
}
|
|
22213
22307
|
async function ensureFonts(work) {
|
|
22214
|
-
const cache =
|
|
22308
|
+
const cache = join6(HOME(), ".kit", "fonts");
|
|
22215
22309
|
const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
|
|
22216
|
-
if (!need.every((f) =>
|
|
22310
|
+
if (!need.every((f) => existsSync5(join6(cache, f)))) {
|
|
22217
22311
|
const { fonts } = await call("fonts");
|
|
22218
22312
|
mkdirSync5(cache, { recursive: true });
|
|
22219
|
-
for (const [f, b64] of Object.entries(fonts))
|
|
22313
|
+
for (const [f, b64] of Object.entries(fonts)) writeFileSync5(join6(cache, basename2(f)), Buffer.from(b64, "base64"));
|
|
22220
22314
|
}
|
|
22221
|
-
mkdirSync5(
|
|
22222
|
-
for (const f of need)
|
|
22315
|
+
mkdirSync5(join6(work, "fonts"), { recursive: true });
|
|
22316
|
+
for (const f of need) writeFileSync5(join6(work, "fonts", f), readFileSync5(join6(cache, f)));
|
|
22223
22317
|
}
|
|
22224
22318
|
function applyFixes(words, fixes = []) {
|
|
22225
22319
|
return words.map((w) => {
|
|
@@ -22227,9 +22321,9 @@ function applyFixes(words, fixes = []) {
|
|
|
22227
22321
|
return f ? { ...w, w: f.to } : w;
|
|
22228
22322
|
}).filter((w) => w.w);
|
|
22229
22323
|
}
|
|
22230
|
-
var brandFile = () =>
|
|
22324
|
+
var brandFile = () => join6(studio.brand(), "brand.json");
|
|
22231
22325
|
function getBrand() {
|
|
22232
|
-
for (const f of [brandFile(),
|
|
22326
|
+
for (const f of [brandFile(), join6(HOME(), "brand.json")]) {
|
|
22233
22327
|
try {
|
|
22234
22328
|
return JSON.parse(readFileSync5(f, "utf8"));
|
|
22235
22329
|
} catch {
|
|
@@ -22239,14 +22333,14 @@ function getBrand() {
|
|
|
22239
22333
|
}
|
|
22240
22334
|
async function setBrand(input) {
|
|
22241
22335
|
if (input.reset) {
|
|
22242
|
-
|
|
22336
|
+
rmSync3(brandFile(), { force: true });
|
|
22243
22337
|
return { brand: null, saved: "Back to the default look." };
|
|
22244
22338
|
}
|
|
22245
22339
|
const { reset, ...fields } = input;
|
|
22246
22340
|
const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
|
|
22247
22341
|
await call("compose", { duration: 2, words: [], brand: next, check_only: true });
|
|
22248
22342
|
ensureStudio();
|
|
22249
|
-
|
|
22343
|
+
writeFileSync5(brandFile(), JSON.stringify(next, null, 2));
|
|
22250
22344
|
return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
|
|
22251
22345
|
}
|
|
22252
22346
|
var PICTURE = /\.(jpe?g|png|webp)$/i;
|
|
@@ -22254,21 +22348,21 @@ function safeAssetName(file, i) {
|
|
|
22254
22348
|
const m = basename2(file).match(PICTURE);
|
|
22255
22349
|
if (!m) throw new Error(`Pictures must be jpg, png or webp: ${file}`);
|
|
22256
22350
|
const ext = m[1].toLowerCase() === "jpeg" ? "jpg" : m[1].toLowerCase();
|
|
22257
|
-
return `${i + 1}-${
|
|
22351
|
+
return `${i + 1}-${slug3(basename2(file, extname(file)))}.${ext}`;
|
|
22258
22352
|
}
|
|
22259
22353
|
function localImages(work, layoutImages, effects) {
|
|
22260
22354
|
const names = /* @__PURE__ */ new Map();
|
|
22261
|
-
const dir =
|
|
22355
|
+
const dir = join6(work, "assets");
|
|
22262
22356
|
const swap = (v) => {
|
|
22263
22357
|
const raw = String(v ?? "").trim();
|
|
22264
|
-
if (/^assets\/[\w.-]+$/.test(raw) &&
|
|
22358
|
+
if (/^assets\/[\w.-]+$/.test(raw) && existsSync5(join6(work, raw))) return raw;
|
|
22265
22359
|
const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir2()));
|
|
22266
22360
|
if (!names.has(file)) {
|
|
22267
|
-
if (!
|
|
22361
|
+
if (!existsSync5(file) || !statSync(file).isFile()) throw new Error(`Can't find the picture ${raw}. Use the full path of a jpg, png or webp file on this computer.`);
|
|
22268
22362
|
if (names.size >= 8) throw new Error("8 pictures at most per reel.");
|
|
22269
22363
|
const name = safeAssetName(file, names.size);
|
|
22270
22364
|
mkdirSync5(dir, { recursive: true });
|
|
22271
|
-
copyFileSync2(file,
|
|
22365
|
+
copyFileSync2(file, join6(dir, name));
|
|
22272
22366
|
names.set(file, `assets/${name}`);
|
|
22273
22367
|
}
|
|
22274
22368
|
return names.get(file);
|
|
@@ -22281,10 +22375,10 @@ function localImages(work, layoutImages, effects) {
|
|
|
22281
22375
|
}));
|
|
22282
22376
|
return { images, effects: fx, count: names.size };
|
|
22283
22377
|
}
|
|
22284
|
-
async function wantsCutout(
|
|
22378
|
+
async function wantsCutout(layout2, effects) {
|
|
22285
22379
|
const styles = await call("styles");
|
|
22286
22380
|
const slugs = new Set(effects.map((f) => String(f.slug)));
|
|
22287
|
-
return [...styles.layouts.filter((l) => l.slug === (
|
|
22381
|
+
return [...styles.layouts.filter((l) => l.slug === (layout2 ?? "full-frame")), ...styles.effects.filter((e) => slugs.has(e.slug))].some((x) => x.needs?.includes("cutout"));
|
|
22288
22382
|
}
|
|
22289
22383
|
function headTop(work, duration3) {
|
|
22290
22384
|
const tops = [];
|
|
@@ -22305,7 +22399,7 @@ function headTop(work, duration3) {
|
|
|
22305
22399
|
return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
|
|
22306
22400
|
}
|
|
22307
22401
|
function ensureCutout(work, duration3) {
|
|
22308
|
-
const footage =
|
|
22402
|
+
const footage = join6(work, "footage.mp4"), cut = join6(work, "cut.webm"), stamp = join6(work, "cut.json"), still = join6(work, "assets", "still.jpg");
|
|
22309
22403
|
const st = statSync(footage);
|
|
22310
22404
|
const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
22311
22405
|
let cached2 = null;
|
|
@@ -22314,31 +22408,31 @@ function ensureCutout(work, duration3) {
|
|
|
22314
22408
|
} catch {
|
|
22315
22409
|
}
|
|
22316
22410
|
let made_s = 0;
|
|
22317
|
-
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime &&
|
|
22411
|
+
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync5(cut))) {
|
|
22318
22412
|
const started = Date.now();
|
|
22319
22413
|
const r = sh("npx", ["--yes", HF, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
|
|
22320
|
-
if (r.status !== 0 || !
|
|
22414
|
+
if (r.status !== 0 || !existsSync5(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
|
|
22321
22415
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22322
22416
|
made_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22323
22417
|
cached2 = { ...key, head: headTop(work, duration3), made_s };
|
|
22324
|
-
|
|
22418
|
+
writeFileSync5(stamp, JSON.stringify(cached2));
|
|
22325
22419
|
}
|
|
22326
|
-
if (!
|
|
22327
|
-
mkdirSync5(
|
|
22420
|
+
if (!existsSync5(still)) {
|
|
22421
|
+
mkdirSync5(join6(work, "assets"), { recursive: true });
|
|
22328
22422
|
sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
|
|
22329
22423
|
}
|
|
22330
22424
|
return { head: cached2.head, made_s };
|
|
22331
22425
|
}
|
|
22332
|
-
var planFile = (folder) =>
|
|
22426
|
+
var planFile = (folder) => join6(folder, ".cutroom", "plan.json");
|
|
22333
22427
|
async function composition(input) {
|
|
22334
22428
|
const pj = resolveProject(input.project);
|
|
22335
|
-
const work =
|
|
22429
|
+
const work = join6(pj.folder, ".cutroom");
|
|
22336
22430
|
const words = applyFixes(pj.words, input.fixes);
|
|
22337
22431
|
const local = localImages(work, input.images, input.effects ?? []);
|
|
22338
22432
|
const cutout = await wantsCutout(input.layout, local.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22339
22433
|
const { html } = await call("compose", { duration: pj.duration, words, layout: input.layout, caption: input.caption, effects: local.effects, images: local.images, emphasis: input.emphasis, brand: getBrand() ?? void 0, ...cutout ? { cutout: true, head: cutout.head } : {} });
|
|
22340
22434
|
await ensureFonts(work);
|
|
22341
|
-
|
|
22435
|
+
writeFileSync5(join6(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
22342
22436
|
return { pj, work, html, cutout, words };
|
|
22343
22437
|
}
|
|
22344
22438
|
var fmtT = (t) => `${t.toFixed(1)}s`;
|
|
@@ -22354,7 +22448,7 @@ async function planReel(input) {
|
|
|
22354
22448
|
};
|
|
22355
22449
|
const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
|
|
22356
22450
|
const styles = await call("styles");
|
|
22357
|
-
const title = (list,
|
|
22451
|
+
const title = (list, slug4) => (list.find((x) => x.slug === slug4)?.title ?? String(slug4)).replace(/\s*\(.*\)$/, "");
|
|
22358
22452
|
const sentences = words.reduce((out, w, k) => {
|
|
22359
22453
|
if (!out.length || /[.!?]["”']?$/.test(words[k - 1]?.w ?? "")) out.push({ t: w.t, text: [] });
|
|
22360
22454
|
out[out.length - 1].text.push(w.w);
|
|
@@ -22388,31 +22482,31 @@ async function planReel(input) {
|
|
|
22388
22482
|
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
|
|
22389
22483
|
})
|
|
22390
22484
|
];
|
|
22391
|
-
const shots =
|
|
22392
|
-
|
|
22393
|
-
|
|
22485
|
+
const shots = join6(work, "storyboard");
|
|
22486
|
+
rmSync3(shots, { recursive: true, force: true });
|
|
22487
|
+
writeFileSync5(join6(work, "index.html"), html);
|
|
22394
22488
|
let r;
|
|
22395
22489
|
try {
|
|
22396
22490
|
r = sh("npx", ["--yes", HF, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
|
|
22397
22491
|
} finally {
|
|
22398
|
-
|
|
22492
|
+
rmSync3(join6(work, "index.html"), { force: true });
|
|
22399
22493
|
}
|
|
22400
|
-
const pngs =
|
|
22494
|
+
const pngs = existsSync5(shots) ? readdirSync2(shots).filter((f) => f.endsWith(".png")).sort() : [];
|
|
22401
22495
|
if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
|
|
22402
22496
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22403
|
-
const board =
|
|
22497
|
+
const board = join6(pj.folder, "storyboard.jpg");
|
|
22404
22498
|
const cols = Math.min(4, moments.length);
|
|
22405
22499
|
const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
|
|
22406
|
-
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i",
|
|
22500
|
+
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join6(shots, f)]);
|
|
22407
22501
|
const cells = moments.map((m, k) => `[${k}:v]scale=270:480,pad=270:540:0:0:#0A0A0A,drawtext=text='${label(`${fmtT(m.t)} ${m.tag}`)}':x=10:y=496:fontsize=18:fontcolor=#F7F3E8[c${k}]`).join(";");
|
|
22408
22502
|
const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
|
|
22409
22503
|
const stack = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
|
|
22410
22504
|
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
|
|
22411
22505
|
if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
|
|
22412
22506
|
${tail(t.stderr)}`);
|
|
22413
|
-
|
|
22507
|
+
rmSync3(shots, { recursive: true, force: true });
|
|
22414
22508
|
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
|
|
22415
|
-
|
|
22509
|
+
writeFileSync5(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22416
22510
|
const text = [
|
|
22417
22511
|
`Plan for ${pj.name} (${pj.duration.toFixed(1)}s). Layout: ${title(styles.layouts, plan.layout)}. Captions: ${title(styles.captions, plan.caption)}.${cutout ? " You are cut out of the background for this one (done)." : ""}`,
|
|
22418
22512
|
"",
|
|
@@ -22422,41 +22516,41 @@ ${tail(t.stderr)}`);
|
|
|
22422
22516
|
"",
|
|
22423
22517
|
`Storyboard: ${board}`
|
|
22424
22518
|
].join("\n");
|
|
22425
|
-
|
|
22519
|
+
setStatus2(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
|
|
22426
22520
|
return { text, storyboard: board, plan, words_used: words.length, next: "Show this table as it is (plain words, no effect codes) and the storyboard. On changes, plan_reel again with the new choice. On a go, render_reel with plan:true and draft:true." };
|
|
22427
22521
|
}
|
|
22428
22522
|
async function renderReel(input) {
|
|
22429
22523
|
if (input.plan) {
|
|
22430
22524
|
const folder = resolveProject(input.project).folder;
|
|
22431
|
-
if (!
|
|
22525
|
+
if (!existsSync5(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
|
|
22432
22526
|
input = { ...JSON.parse(readFileSync5(planFile(folder), "utf8")), project: input.project, draft: input.draft };
|
|
22433
22527
|
}
|
|
22434
22528
|
const { pj, work, html, cutout } = await composition(input);
|
|
22435
|
-
mkdirSync5(
|
|
22436
|
-
const out =
|
|
22437
|
-
const tmp =
|
|
22438
|
-
|
|
22529
|
+
mkdirSync5(join6(pj.folder, "renders"), { recursive: true });
|
|
22530
|
+
const out = join6(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
|
|
22531
|
+
const tmp = join6(work, "render.mp4");
|
|
22532
|
+
writeFileSync5(join6(work, "index.html"), html);
|
|
22439
22533
|
const started = Date.now();
|
|
22440
22534
|
let r;
|
|
22441
22535
|
try {
|
|
22442
22536
|
r = sh("npx", ["--yes", HF, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
|
|
22443
22537
|
} finally {
|
|
22444
|
-
|
|
22538
|
+
rmSync3(join6(work, "index.html"), { force: true });
|
|
22445
22539
|
}
|
|
22446
|
-
if (r.status !== 0 || !
|
|
22540
|
+
if (r.status !== 0 || !existsSync5(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
|
|
22447
22541
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22448
|
-
|
|
22542
|
+
renameSync3(tmp, out);
|
|
22449
22543
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22450
22544
|
const { report: checks, ...qa } = await qaVideo({ video: out, layout: input.layout });
|
|
22451
|
-
|
|
22452
|
-
|
|
22545
|
+
addCheck2(pj.folder, checks);
|
|
22546
|
+
setStatus2(pj.folder, input.draft ? "preview" : "final");
|
|
22453
22547
|
return { video: out, render_s, ...cutout?.made_s ? { cutout_s: cutout.made_s } : {}, qa, next: qa.passed ? "Watch it once, then it's ready to post." : qa.technical?.length ? "Fix the technical issues listed, then render again." : "Look at each flagged spot yourself (grab the frame) and tell the user which are real problems, with the fix, and which are the design." };
|
|
22454
22548
|
}
|
|
22455
22549
|
async function qaVideo(input) {
|
|
22456
22550
|
const file = resolve3(input.video.replace(/^~/, homedir2()));
|
|
22457
|
-
if (!
|
|
22551
|
+
if (!existsSync5(file)) throw new Error(`No such file: ${file}`);
|
|
22458
22552
|
const tech = technicalChecks(file);
|
|
22459
|
-
const dir = mkdtempSync(
|
|
22553
|
+
const dir = mkdtempSync(join6(tmpdir(), "cutroom-qa-"));
|
|
22460
22554
|
const { frames } = sampleFrames(file, dir, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
|
|
22461
22555
|
let flags = [];
|
|
22462
22556
|
let judged = 0, judge_error = null;
|
|
@@ -22467,7 +22561,7 @@ async function qaVideo(input) {
|
|
|
22467
22561
|
} catch (e) {
|
|
22468
22562
|
judge_error = e.message;
|
|
22469
22563
|
}
|
|
22470
|
-
|
|
22564
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
22471
22565
|
const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
|
|
22472
22566
|
const passed = !tech.issues.length && !flags.length && !judge_error;
|
|
22473
22567
|
const report = {
|
|
@@ -22492,8 +22586,76 @@ async function qaVideo(input) {
|
|
|
22492
22586
|
return { ...report, report: md };
|
|
22493
22587
|
}
|
|
22494
22588
|
|
|
22589
|
+
// src/community/local/prompts.ts
|
|
22590
|
+
var say = (label, value) => value?.trim() ? ` ${label}: ${value.trim()}.` : "";
|
|
22591
|
+
var PROMPTS = [
|
|
22592
|
+
{
|
|
22593
|
+
name: "copy-reel",
|
|
22594
|
+
title: "Copy a reel's format",
|
|
22595
|
+
description: "Your version of a reel: its structure, pacing and look, with your topic and words.",
|
|
22596
|
+
args: [{ name: "link", description: "The reel (Instagram link or a video file path)" }, { name: "topic", description: "Your topic (optional)" }],
|
|
22597
|
+
ask: (a) => `Copy a reel's format for me: get_method('copy_reel') and follow it step by step.${say("Reel", a.link)}${say("My topic", a.topic)}${a.link ? "" : " Ask me for the reel first."}`
|
|
22598
|
+
},
|
|
22599
|
+
{
|
|
22600
|
+
name: "finish-video",
|
|
22601
|
+
title: "Finish a raw video",
|
|
22602
|
+
description: "Turn a raw video of you talking into a finished reel: captions, effects, your brand.",
|
|
22603
|
+
args: [{ name: "video", description: "The raw video file (optional: otherwise the newest in your inbox)" }],
|
|
22604
|
+
ask: (a) => `Finish my raw video into a reel: get_method('finish_video') and follow it step by step.${say("Video", a.video)}${a.video ? "" : " Use the newest video in my studio's inbox/ and tell me which one you picked."}`
|
|
22605
|
+
},
|
|
22606
|
+
{
|
|
22607
|
+
name: "breakdown",
|
|
22608
|
+
title: "Break down a reel",
|
|
22609
|
+
description: "Why a reel works: hook, beats, pacing and the look, with how you'd make your own.",
|
|
22610
|
+
args: [{ name: "link", description: "The reel (Instagram link or a video file path)" }],
|
|
22611
|
+
ask: (a) => `Break down this reel: breakdown_reel, then get_method('breakdown_writeup') and follow it.${say("Reel", a.link)}${a.link ? "" : " Ask me for the reel first."}`
|
|
22612
|
+
},
|
|
22613
|
+
{
|
|
22614
|
+
name: "teardown",
|
|
22615
|
+
title: "Tear down a creator",
|
|
22616
|
+
description: "A creator's system from their latest reels, and the gap to yours.",
|
|
22617
|
+
args: [{ name: "handle", description: "Their Instagram handle" }, { name: "compare_to", description: "Your handle (optional)" }],
|
|
22618
|
+
ask: (a) => `Tear down this creator: creator_teardown, then get_method('teardown_writeup') and follow it.${say("Their handle", a.handle)}${say("Compare to my handle", a.compare_to)}${a.handle ? "" : " Ask me for their handle first."}`
|
|
22619
|
+
},
|
|
22620
|
+
{
|
|
22621
|
+
name: "hooks",
|
|
22622
|
+
title: "Write hooks",
|
|
22623
|
+
description: "Hooks for your next video, scored and ranked.",
|
|
22624
|
+
args: [{ name: "topic", description: "What the video is about" }],
|
|
22625
|
+
ask: (a) => `Write hooks for my next video: get_method('hooks') and follow it.${say("Topic", a.topic)}${a.topic ? "" : " Ask me for the topic first."}`
|
|
22626
|
+
},
|
|
22627
|
+
{
|
|
22628
|
+
name: "script",
|
|
22629
|
+
title: "Write a short script",
|
|
22630
|
+
description: "An 8-line short script for one idea, scored.",
|
|
22631
|
+
args: [{ name: "topic", description: "The idea" }],
|
|
22632
|
+
ask: (a) => `Write a short script: get_method('script') and follow it.${say("Idea", a.topic)}${a.topic ? "" : " Ask me for the idea first."}`
|
|
22633
|
+
},
|
|
22634
|
+
{
|
|
22635
|
+
name: "caption",
|
|
22636
|
+
title: "Write the post caption",
|
|
22637
|
+
description: "The caption and comment-keyword CTA for a finished video.",
|
|
22638
|
+
args: [{ name: "topic", description: "The video (optional)" }],
|
|
22639
|
+
ask: (a) => `Write the post caption: get_method('caption') and follow it.${say("Video", a.topic)}`
|
|
22640
|
+
},
|
|
22641
|
+
{
|
|
22642
|
+
name: "brand",
|
|
22643
|
+
title: "Set your brand",
|
|
22644
|
+
description: "See or change the colors and font every render uses.",
|
|
22645
|
+
args: [],
|
|
22646
|
+
ask: () => "Show my Cutroom brand kit with show_brand. If none is set, offer to set one with set_brand (from colors, a website or a logo), show me the colors, and save only on my yes."
|
|
22647
|
+
},
|
|
22648
|
+
{
|
|
22649
|
+
name: "doctor",
|
|
22650
|
+
title: "Check Cutroom setup",
|
|
22651
|
+
description: "Check everything Cutroom needs on this computer and fix what's missing.",
|
|
22652
|
+
args: [],
|
|
22653
|
+
ask: () => "Check my Cutroom setup with cutroom_doctor and give me a short checklist with the fix for anything missing."
|
|
22654
|
+
}
|
|
22655
|
+
];
|
|
22656
|
+
|
|
22495
22657
|
// src/community/local/server.ts
|
|
22496
|
-
process.env.PATH = (WIN ? [process.env.PATH,
|
|
22658
|
+
process.env.PATH = (WIN ? [process.env.PATH, join7(process.env.LOCALAPPDATA ?? join7(homedir3(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join7(homedir3(), ".local", "bin")]).filter(Boolean).join(delimiter);
|
|
22497
22659
|
var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
22498
22660
|
var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
|
|
22499
22661
|
var safe = (op) => Promise.resolve().then(op).then(result).catch(failure2);
|
|
@@ -22504,6 +22666,9 @@ Setup or any failure: cutroom_doctor, then a short checklist. If they aren't sig
|
|
|
22504
22666
|
Finish a video (a raw video of them talking: edit it, captions, effects, make it a reel): get_method('finish_video') first and follow it step by step. Nothing renders until they approve the plan.
|
|
22505
22667
|
|
|
22506
22668
|
Hooks, scripts, captions: get_method('hooks', 'script' or 'caption') and follow it.
|
|
22669
|
+
|
|
22670
|
+
Copy a reel (their own version of a reel's format, on their topic): get_method('copy_reel') and follow it step by step. Nothing gets filmed or rendered until they approve the script.
|
|
22671
|
+
|
|
22507
22672
|
Break down a reel: breakdown_reel, then get_method('breakdown_writeup').
|
|
22508
22673
|
Tear down a creator: creator_teardown (compare_to = their handle), then get_method('teardown_writeup').
|
|
22509
22674
|
|
|
@@ -22527,7 +22692,7 @@ await serveStdio(() => {
|
|
|
22527
22692
|
inputSchema: object({})
|
|
22528
22693
|
}, async () => safe(() => call("methods")));
|
|
22529
22694
|
server.registerTool("get_method", {
|
|
22530
|
-
description: "Get a Cutroom method and follow it step by step: 'hooks' for video hooks, 'script' for an 8-line short script, 'caption' for the post caption and comment CTA, 'breakdown_writeup' and 'teardown_writeup' after those tools. Always fetch the method fresh; it is updated on the server.",
|
|
22695
|
+
description: "Get a Cutroom method and follow it step by step: 'hooks' for video hooks, 'script' for an 8-line short script, 'caption' for the post caption and comment CTA, 'copy_reel' to copy a reel's format onto their topic, 'breakdown_writeup' and 'teardown_writeup' after those tools. Always fetch the method fresh; it is updated on the server.",
|
|
22531
22696
|
inputSchema: object({ name: string2().min(1) })
|
|
22532
22697
|
}, async ({ name }) => safe(async () => {
|
|
22533
22698
|
const m = await call("method", { name });
|
|
@@ -22618,7 +22783,13 @@ ${r.next}` }, { type: "image", data: readFileSync6(r.storyboard).toString("base6
|
|
|
22618
22783
|
server.registerTool("qa_video", {
|
|
22619
22784
|
description: "QA any finished vertical video: black or frozen picture, loudness, dead air, size, and every sampled frame judged for visual problems (cut-off text, covered face, empty frames). Returns passed plus the issues with timestamps.",
|
|
22620
22785
|
inputSchema: object({ video: string2().min(1), layout: string2().optional().describe("The layout used, if it puts the face in a card (e.g. explainer-panel), so the card edge is not flagged.") })
|
|
22621
|
-
}, async ({ video, layout }) => safe(() => qaVideo({ video, layout })));
|
|
22786
|
+
}, async ({ video, layout: layout2 }) => safe(() => qaVideo({ video, layout: layout2 })));
|
|
22787
|
+
for (const p of PROMPTS) {
|
|
22788
|
+
const shape = Object.fromEntries(p.args.map((a) => [a.name, string2().optional().describe(a.description)]));
|
|
22789
|
+
server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema: object(shape) }, (args) => ({
|
|
22790
|
+
messages: [{ role: "user", content: { type: "text", text: p.ask(args ?? {}) } }]
|
|
22791
|
+
}));
|
|
22792
|
+
}
|
|
22622
22793
|
return server;
|
|
22623
22794
|
});
|
|
22624
22795
|
/*! Bundled license information:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepylabs/cutroom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Cutroom for Claude Code: hooks and scripts with scoring, reel breakdowns, creator teardowns, and finishing your own videos with QA. Sign in with your Keepy Labs membership on Whop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|