@keepylabs/cutroom 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cutroom.mjs +278 -192
- 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 homedir6 } from "node:os";
|
|
11
|
-
import { delimiter as delimiter2, join as
|
|
11
|
+
import { delimiter as delimiter2, join as join12 } from "node:path";
|
|
12
12
|
|
|
13
13
|
// src/community/local/proc.ts
|
|
14
14
|
import { spawnSync } from "node:child_process";
|
|
@@ -21550,13 +21550,13 @@ function toError(value) {
|
|
|
21550
21550
|
}
|
|
21551
21551
|
|
|
21552
21552
|
// src/community/local/server.ts
|
|
21553
|
-
import { readFileSync as
|
|
21553
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
21554
21554
|
|
|
21555
21555
|
// src/community/local/render.ts
|
|
21556
21556
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
21557
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
21557
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync7, mkdirSync as mkdirSync7, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync8, renameSync as renameSync4, rmSync as rmSync5, statSync as statSync2, writeFileSync as writeFileSync7 } from "node:fs";
|
|
21558
21558
|
import { homedir as homedir3, tmpdir } from "node:os";
|
|
21559
|
-
import { basename as basename2, extname, join as
|
|
21559
|
+
import { basename as basename2, extname, join as join9, resolve as resolve3 } from "node:path";
|
|
21560
21560
|
|
|
21561
21561
|
// src/community/local/measure.ts
|
|
21562
21562
|
import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
|
|
@@ -21592,13 +21592,13 @@ function measure(file) {
|
|
|
21592
21592
|
lufs: p.has_audio ? parseLufs(ffmpegErr(["-i", file, "-vn", "-af", "ebur128", "-f", "null", "-"])) : null
|
|
21593
21593
|
};
|
|
21594
21594
|
}
|
|
21595
|
-
function sampleFrames(file,
|
|
21596
|
-
mkdirSync(
|
|
21595
|
+
function sampleFrames(file, dir2, duration3, interval = 1, max = 40) {
|
|
21596
|
+
mkdirSync(dir2, { recursive: true });
|
|
21597
21597
|
let step = interval;
|
|
21598
21598
|
if (duration3 / step > max) step = Math.ceil(duration3 / max * 10) / 10;
|
|
21599
21599
|
const out = [];
|
|
21600
21600
|
for (let t = Math.min(0.05, duration3); t < duration3 - 0.05 && out.length < max; t = Math.round((t + step) * 100) / 100) {
|
|
21601
|
-
const path = join(
|
|
21601
|
+
const path = join(dir2, `t${t.toFixed(2).padStart(6, "0")}.jpg`);
|
|
21602
21602
|
execFileSync("ffmpeg", ["-v", "error", "-y", "-ss", t.toFixed(2), "-i", file, "-frames:v", "1", "-vf", "scale='if(gt(iw,ih),-2,540)':'if(gt(iw,ih),540,-2)'", "-q:v", "5", path]);
|
|
21603
21603
|
out.push({ t, jpg_base64: readFileSync(path).toString("base64"), path });
|
|
21604
21604
|
}
|
|
@@ -22040,8 +22040,8 @@ function extractAudio(video, out) {
|
|
|
22040
22040
|
${(r.stderr ?? "").trim().split("\n").slice(-3).join("\n")}`);
|
|
22041
22041
|
return out;
|
|
22042
22042
|
}
|
|
22043
|
-
async function cloudWords(video,
|
|
22044
|
-
const audio = extractAudio(video, join5(
|
|
22043
|
+
async function cloudWords(video, dir2, language, deps = {}) {
|
|
22044
|
+
const audio = extractAudio(video, join5(dir2, "audio.mp3"));
|
|
22045
22045
|
let id;
|
|
22046
22046
|
try {
|
|
22047
22047
|
({ id } = await call("transcribe_start", { audio_base64: readFileSync5(audio).toString("base64"), ...language ? { language } : {} }));
|
|
@@ -22057,31 +22057,31 @@ async function cloudWords(video, dir, language, deps = {}) {
|
|
|
22057
22057
|
}
|
|
22058
22058
|
throw new Error("Transcription is taking too long. Try again in a minute.");
|
|
22059
22059
|
}
|
|
22060
|
-
function localWhisperWords(video,
|
|
22060
|
+
function localWhisperWords(video, dir2, language) {
|
|
22061
22061
|
if (!has("whisper-cli")) return null;
|
|
22062
22062
|
const lang = (language ?? "en").toLowerCase().slice(0, 5);
|
|
22063
|
-
const r = run("npx", ["--yes", HF, "transcribe", video, "--json", "-d",
|
|
22063
|
+
const r = run("npx", ["--yes", HF, "transcribe", video, "--json", "-d", dir2, ...lang === "en" ? [] : ["--language", lang, "-m", "large-v3"]], { cwd: dir2, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
22064
22064
|
try {
|
|
22065
22065
|
const meta2 = JSON.parse(r.stdout.trim().split("\n").pop() ?? "{}");
|
|
22066
22066
|
if (r.status !== 0 || !meta2.ok) return null;
|
|
22067
|
-
const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(
|
|
22067
|
+
const raw = JSON.parse(readFileSync5(meta2.transcriptPath ?? join5(dir2, "transcript.json"), "utf8"));
|
|
22068
22068
|
return 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);
|
|
22069
22069
|
} catch {
|
|
22070
22070
|
return null;
|
|
22071
22071
|
}
|
|
22072
22072
|
}
|
|
22073
|
-
async function transcribeVideo(video,
|
|
22073
|
+
async function transcribeVideo(video, dir2, language) {
|
|
22074
22074
|
try {
|
|
22075
|
-
return await cloudWords(video,
|
|
22075
|
+
return await cloudWords(video, dir2, language);
|
|
22076
22076
|
} catch (e) {
|
|
22077
|
-
const local2 = localWhisperWords(video,
|
|
22077
|
+
const local2 = localWhisperWords(video, dir2, language);
|
|
22078
22078
|
if (local2) return local2;
|
|
22079
22079
|
throw e;
|
|
22080
22080
|
}
|
|
22081
22081
|
}
|
|
22082
22082
|
|
|
22083
22083
|
// src/community/local/tools.ts
|
|
22084
|
-
var VERSION = "0.
|
|
22084
|
+
var VERSION = "0.10.1";
|
|
22085
22085
|
var GATEWAY = () => (process.env.CUTROOM_GATEWAY || "https://keepylabs.com/api/cutroom").replace(/\/$/, "");
|
|
22086
22086
|
var HOME = () => process.env.CUTROOM_HOME || join6(homedir2(), "Cutroom");
|
|
22087
22087
|
async function call(op, body = {}, fetchImpl = fetch) {
|
|
@@ -22161,9 +22161,9 @@ function beatBounds(duration3, cuts, words) {
|
|
|
22161
22161
|
for (const x of all) if (x - out[out.length - 1] >= MIN_BEAT && duration3 - x >= MIN_BEAT / 2) out.push(x);
|
|
22162
22162
|
return [...out, duration3];
|
|
22163
22163
|
}
|
|
22164
|
-
async function timedWords(file,
|
|
22164
|
+
async function timedWords(file, dir2) {
|
|
22165
22165
|
try {
|
|
22166
|
-
const words = await transcribeVideo(file,
|
|
22166
|
+
const words = await transcribeVideo(file, dir2);
|
|
22167
22167
|
return words.length ? words : null;
|
|
22168
22168
|
} catch {
|
|
22169
22169
|
return null;
|
|
@@ -22188,21 +22188,21 @@ async function breakdownReel(input) {
|
|
|
22188
22188
|
});
|
|
22189
22189
|
meta2 = r.meta;
|
|
22190
22190
|
transcript = r.transcript;
|
|
22191
|
-
const
|
|
22192
|
-
mkdirSync5(
|
|
22193
|
-
file = join6(
|
|
22191
|
+
const dir3 = join6(studio.references(), `${day2()}-${slug2(meta2.ownerUsername ?? "reel")}-${meta2.shortCode}`);
|
|
22192
|
+
mkdirSync5(dir3, { recursive: true });
|
|
22193
|
+
file = join6(dir3, "source.mp4");
|
|
22194
22194
|
if (!existsSync4(file)) await download(meta2.videoUrl, file);
|
|
22195
22195
|
} else {
|
|
22196
22196
|
file = resolve2(src.replace(/^~/, homedir2()));
|
|
22197
22197
|
if (!existsSync4(file)) throw new Error(`No such file: ${file}`);
|
|
22198
22198
|
}
|
|
22199
|
-
const
|
|
22200
|
-
mkdirSync5(
|
|
22199
|
+
const dir2 = /^https?:/i.test(src) ? join6(file, "..") : join6(studio.references(), `${day2()}-${slug2(file.split(/[\\/]/).pop() ?? "video")}`);
|
|
22200
|
+
mkdirSync5(dir2, { recursive: true });
|
|
22201
22201
|
const m = measure(file);
|
|
22202
|
-
const { frames, interval } = sampleFrames(file, join6(
|
|
22202
|
+
const { frames, interval } = sampleFrames(file, join6(dir2, "frames"), m.duration, 1, 40);
|
|
22203
22203
|
const match = await call("match", { frames: frames.map(({ t, jpg_base64 }) => ({ t, jpg_base64 })), interval });
|
|
22204
|
-
const sheet = contactSheet(frames, join6(
|
|
22205
|
-
const timed = await timedWords(file,
|
|
22204
|
+
const sheet = contactSheet(frames, join6(dir2, "sheet.jpg"));
|
|
22205
|
+
const timed = await timedWords(file, dir2);
|
|
22206
22206
|
const words = timed ?? approxWords(transcript, m.duration);
|
|
22207
22207
|
const bs = beats(m, words, match.frames);
|
|
22208
22208
|
if (!transcript && timed) transcript = timed.map((w) => w.w).join(" ");
|
|
@@ -22241,9 +22241,9 @@ async function breakdownReel(input) {
|
|
|
22241
22241
|
transcript ?? "(none)",
|
|
22242
22242
|
""
|
|
22243
22243
|
].join("\n");
|
|
22244
|
-
writeFileSync5(join6(
|
|
22245
|
-
const out = { folder:
|
|
22246
|
-
writeFileSync5(join6(
|
|
22244
|
+
writeFileSync5(join6(dir2, "breakdown.md"), md);
|
|
22245
|
+
const out = { folder: dir2, report: join6(dir2, "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." };
|
|
22246
|
+
writeFileSync5(join6(dir2, "breakdown.json"), JSON.stringify(out, null, 2));
|
|
22247
22247
|
return out;
|
|
22248
22248
|
}
|
|
22249
22249
|
function ctaKeyword(caption) {
|
|
@@ -22252,15 +22252,15 @@ function ctaKeyword(caption) {
|
|
|
22252
22252
|
}
|
|
22253
22253
|
async function teardownOne(handle, limit) {
|
|
22254
22254
|
const { reels } = await call("reels", { handle, limit });
|
|
22255
|
-
const
|
|
22256
|
-
mkdirSync5(join6(
|
|
22255
|
+
const dir2 = join6(studio.references(), `${day2()}-${slug2(handle)}-creator`);
|
|
22256
|
+
mkdirSync5(join6(dir2, "raw"), { recursive: true });
|
|
22257
22257
|
const rows = [];
|
|
22258
22258
|
for (const [i, r] of reels.entries()) {
|
|
22259
22259
|
try {
|
|
22260
|
-
const file = join6(
|
|
22260
|
+
const file = join6(dir2, "raw", `${String(i + 1).padStart(2, "0")}-${r.shortCode}.mp4`);
|
|
22261
22261
|
if (!existsSync4(file)) await download(r.videoUrl, file);
|
|
22262
22262
|
const m = measure(file);
|
|
22263
|
-
const { frames, interval } = sampleFrames(file, join6(
|
|
22263
|
+
const { frames, interval } = sampleFrames(file, join6(dir2, "frames", r.shortCode), m.duration, 2, 20);
|
|
22264
22264
|
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 }))]);
|
|
22265
22265
|
const top = (o) => Object.keys(o)[0] ?? null;
|
|
22266
22266
|
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) });
|
|
@@ -22268,7 +22268,7 @@ async function teardownOne(handle, limit) {
|
|
|
22268
22268
|
rows.push({ code: r.shortCode, url: r.url, posted: String(r.timestamp ?? "").slice(0, 10), plays: r.videoPlayCount ?? null, error: e.message, effects: [] });
|
|
22269
22269
|
}
|
|
22270
22270
|
}
|
|
22271
|
-
return { dir, rows };
|
|
22271
|
+
return { dir: dir2, rows };
|
|
22272
22272
|
}
|
|
22273
22273
|
function rollup(rows) {
|
|
22274
22274
|
const ok = rows.filter((r) => !r.error);
|
|
@@ -22380,18 +22380,80 @@ var takeRaw2 = (source, folder) => takeRaw(source, folder, studio.inbox());
|
|
|
22380
22380
|
var startVideoNotes2 = (folder, transcript) => startVideoNotes(folder, transcript, "I say");
|
|
22381
22381
|
var { setStatus: setStatus2, addCheck: addCheck2 } = studio_core_exports;
|
|
22382
22382
|
|
|
22383
|
+
// src/community/local/custom.ts
|
|
22384
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
22385
|
+
import { join as join8 } from "node:path";
|
|
22386
|
+
var isCustom = (f) => f.slug === "custom";
|
|
22387
|
+
var customDur = (f) => Math.min(Math.max(Number(f.dur) || 4, 1.5), 15);
|
|
22388
|
+
var dir = (work) => join8(work, "custom");
|
|
22389
|
+
var cardOf = (work, id) => {
|
|
22390
|
+
try {
|
|
22391
|
+
return JSON.parse(readFileSync7(join8(dir(work), `${id}.json`), "utf8"));
|
|
22392
|
+
} catch {
|
|
22393
|
+
return null;
|
|
22394
|
+
}
|
|
22395
|
+
};
|
|
22396
|
+
var same = (a, b) => (a ?? "").trim().toLowerCase() === (b ?? "").trim().toLowerCase();
|
|
22397
|
+
var near = (a, b) => Math.abs(a - b) < 0.05;
|
|
22398
|
+
function savedFor(work, s, dur, layout2) {
|
|
22399
|
+
if (!existsSync6(dir(work))) return null;
|
|
22400
|
+
return readdirSync2(dir(work)).filter((f) => f.endsWith(".json")).map((f) => ({ f, at: statSync(join8(dir(work), f)).mtimeMs })).sort((a, b) => b.at - a.at).map(({ f }) => cardOf(work, f.replace(/\.json$/, ""))).find((c) => !!c && near(c.start, s.start) && near(c.dur, dur) && same(c.request, s.request) && c.layout === layout2 && existsSync6(join8(dir(work), `${c.id}.html`))) ?? null;
|
|
22401
|
+
}
|
|
22402
|
+
function previousAt(work, start) {
|
|
22403
|
+
if (!existsSync6(dir(work))) return null;
|
|
22404
|
+
return readdirSync2(dir(work)).filter((f) => f.endsWith(".json")).map((f) => ({ f, at: statSync(join8(dir(work), f)).mtimeMs })).sort((a, b) => b.at - a.at).map(({ f }) => cardOf(work, f.replace(/\.json$/, ""))).find((c) => !!c && near(c.start, start)) ?? null;
|
|
22405
|
+
}
|
|
22406
|
+
async function waitScene(id, wait = (ms) => new Promise((r) => setTimeout(r, ms)), deadline = Date.now() + 7 * 6e4) {
|
|
22407
|
+
while (Date.now() < deadline) {
|
|
22408
|
+
await wait(8e3);
|
|
22409
|
+
const r = await call("scene_status", { id });
|
|
22410
|
+
if (r.status === "done" && r.scene) return r.scene;
|
|
22411
|
+
if (r.status === "failed") throw new Error(r.error ?? "The scene writer couldn't make this scene.");
|
|
22412
|
+
}
|
|
22413
|
+
throw new Error("The scene writer took too long. Try plan_reel again in a minute.");
|
|
22414
|
+
}
|
|
22415
|
+
async function ensureCustom(work, slots, ctx) {
|
|
22416
|
+
mkdirSync6(dir(work), { recursive: true });
|
|
22417
|
+
let made = 0;
|
|
22418
|
+
const out = await Promise.all(slots.map(async (s) => {
|
|
22419
|
+
const dur = customDur(s);
|
|
22420
|
+
const given = s.scene ? cardOf(work, s.scene) : null;
|
|
22421
|
+
const have = given && existsSync6(join8(dir(work), `${given.id}.html`)) && near(given.start, s.start) && near(given.dur, dur) && same(given.request, s.request) && given.layout === ctx.layout ? given : s.redo ? null : savedFor(work, s, dur, ctx.layout);
|
|
22422
|
+
if (have && !s.redo) return { ...s, dur, scene: have.id, title: have.title, shows: have.shows, redo: void 0 };
|
|
22423
|
+
const before = given ?? previousAt(work, s.start);
|
|
22424
|
+
const { id } = await call("scene_start", {
|
|
22425
|
+
start: s.start,
|
|
22426
|
+
dur,
|
|
22427
|
+
words: ctx.words,
|
|
22428
|
+
layout: ctx.layout,
|
|
22429
|
+
brand: ctx.brand ?? void 0,
|
|
22430
|
+
about: ctx.about,
|
|
22431
|
+
request: s.request,
|
|
22432
|
+
job: s.job,
|
|
22433
|
+
...before ? { previous: { shows: before.shows } } : {}
|
|
22434
|
+
});
|
|
22435
|
+
const scene = await waitScene(id, ctx.wait);
|
|
22436
|
+
writeFileSync6(join8(dir(work), `${id}.html`), scene.html);
|
|
22437
|
+
const card = { id, start: s.start, dur, request: s.request, job: scene.job, title: scene.title, shows: scene.shows, layout: ctx.layout };
|
|
22438
|
+
writeFileSync6(join8(dir(work), `${id}.json`), JSON.stringify(card, null, 2));
|
|
22439
|
+
made++;
|
|
22440
|
+
return { ...s, dur, scene: id, title: scene.title, shows: scene.shows, redo: void 0 };
|
|
22441
|
+
}));
|
|
22442
|
+
return { slots: out, made };
|
|
22443
|
+
}
|
|
22444
|
+
|
|
22383
22445
|
// src/community/local/render.ts
|
|
22384
22446
|
var HF2 = "hyperframes@0.8.99";
|
|
22385
22447
|
var day3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
22386
22448
|
var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
|
|
22387
22449
|
var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
22388
22450
|
var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
|
|
22389
|
-
var projectFile = (folder) =>
|
|
22451
|
+
var projectFile = (folder) => join9(folder, ".cutroom", "project.json");
|
|
22390
22452
|
var resolveProject = (p) => {
|
|
22391
22453
|
const folder = resolve3(p.replace(/^~/, homedir3()));
|
|
22392
|
-
const file = [folder,
|
|
22454
|
+
const file = [folder, join9(studio.videos(), p), join9(HOME(), "renders", p)].map(projectFile).find((f) => existsSync7(f)) ?? null;
|
|
22393
22455
|
if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
|
|
22394
|
-
return JSON.parse(
|
|
22456
|
+
return JSON.parse(readFileSync8(file, "utf8"));
|
|
22395
22457
|
};
|
|
22396
22458
|
function cutAndNormalize(src, out, segments) {
|
|
22397
22459
|
const parts = segments.map(([s, e], i) => `[0:v]trim=${s}:${e},setpts=PTS-STARTPTS[v${i}];[0:a]atrim=${s}:${e},asetpts=PTS-STARTPTS[a${i}];`).join("");
|
|
@@ -22407,19 +22469,19 @@ function looksLikeSpeech(words, duration3) {
|
|
|
22407
22469
|
}
|
|
22408
22470
|
async function prepareFootage(input) {
|
|
22409
22471
|
const source = resolve3(input.video.replace(/^~/, homedir3()));
|
|
22410
|
-
if (!
|
|
22472
|
+
if (!existsSync7(source)) throw new Error(`No such file: ${source}`);
|
|
22411
22473
|
const p = probe(source);
|
|
22412
22474
|
if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
|
|
22413
22475
|
if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
|
|
22414
22476
|
const name = slug3(input.name ?? basename2(source, extname(source)));
|
|
22415
22477
|
ensureStudio();
|
|
22416
|
-
const folder =
|
|
22417
|
-
const work =
|
|
22418
|
-
|
|
22478
|
+
const folder = join9(studio.videos(), `${day3()}-${name}`);
|
|
22479
|
+
const work = join9(folder, ".cutroom");
|
|
22480
|
+
mkdirSync7(work, { recursive: true });
|
|
22419
22481
|
const clip = takeRaw2(source, folder);
|
|
22420
22482
|
const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
|
|
22421
22483
|
if (!segments.length) throw new Error("Couldn't find any speech in that video.");
|
|
22422
|
-
const footage =
|
|
22484
|
+
const footage = join9(work, "footage.mp4");
|
|
22423
22485
|
cutAndNormalize(clip.file, footage, segments);
|
|
22424
22486
|
const duration3 = probe(footage).duration;
|
|
22425
22487
|
const lang = input.language ? input.language.toLowerCase().slice(0, 5) : void 0;
|
|
@@ -22432,7 +22494,7 @@ ${e.message}`);
|
|
|
22432
22494
|
}
|
|
22433
22495
|
if (!looksLikeSpeech(words, duration3)) throw new Error(!lang || 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.`);
|
|
22434
22496
|
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 };
|
|
22435
|
-
|
|
22497
|
+
writeFileSync7(projectFile(folder), JSON.stringify(project, null, 2));
|
|
22436
22498
|
startVideoNotes2(folder, words.map((w) => w.w).join(" "));
|
|
22437
22499
|
setStatus2(folder, "footage ready");
|
|
22438
22500
|
const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
|
|
@@ -22447,15 +22509,15 @@ ${e.message}`);
|
|
|
22447
22509
|
};
|
|
22448
22510
|
}
|
|
22449
22511
|
async function ensureFonts(work) {
|
|
22450
|
-
const cache =
|
|
22512
|
+
const cache = join9(HOME(), ".kit", "fonts");
|
|
22451
22513
|
const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
|
|
22452
|
-
if (!need.every((f) =>
|
|
22514
|
+
if (!need.every((f) => existsSync7(join9(cache, f)))) {
|
|
22453
22515
|
const { fonts } = await call("fonts");
|
|
22454
|
-
|
|
22455
|
-
for (const [f, b64] of Object.entries(fonts))
|
|
22516
|
+
mkdirSync7(cache, { recursive: true });
|
|
22517
|
+
for (const [f, b64] of Object.entries(fonts)) writeFileSync7(join9(cache, basename2(f)), Buffer.from(b64, "base64"));
|
|
22456
22518
|
}
|
|
22457
|
-
|
|
22458
|
-
for (const f of need)
|
|
22519
|
+
mkdirSync7(join9(work, "fonts"), { recursive: true });
|
|
22520
|
+
for (const f of need) writeFileSync7(join9(work, "fonts", f), readFileSync8(join9(cache, f)));
|
|
22459
22521
|
}
|
|
22460
22522
|
function applyFixes(words, fixes = []) {
|
|
22461
22523
|
return words.map((w) => {
|
|
@@ -22463,11 +22525,11 @@ function applyFixes(words, fixes = []) {
|
|
|
22463
22525
|
return f ? { ...w, w: f.to } : w;
|
|
22464
22526
|
}).filter((w) => w.w);
|
|
22465
22527
|
}
|
|
22466
|
-
var brandFile = () =>
|
|
22528
|
+
var brandFile = () => join9(studio.brand(), "brand.json");
|
|
22467
22529
|
function getBrand() {
|
|
22468
|
-
for (const f of [brandFile(),
|
|
22530
|
+
for (const f of [brandFile(), join9(HOME(), "brand.json")]) {
|
|
22469
22531
|
try {
|
|
22470
|
-
return JSON.parse(
|
|
22532
|
+
return JSON.parse(readFileSync8(f, "utf8"));
|
|
22471
22533
|
} catch {
|
|
22472
22534
|
}
|
|
22473
22535
|
}
|
|
@@ -22482,7 +22544,7 @@ async function setBrand(input) {
|
|
|
22482
22544
|
const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
|
|
22483
22545
|
await call("compose", { duration: 2, words: [], brand: next, check_only: true });
|
|
22484
22546
|
ensureStudio();
|
|
22485
|
-
|
|
22547
|
+
writeFileSync7(brandFile(), JSON.stringify(next, null, 2));
|
|
22486
22548
|
return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
|
|
22487
22549
|
}
|
|
22488
22550
|
var PICTURE = /\.(jpe?g|png|webp)$/i;
|
|
@@ -22494,17 +22556,17 @@ function safeAssetName(file, i) {
|
|
|
22494
22556
|
}
|
|
22495
22557
|
function localImages(work, layoutImages, effects) {
|
|
22496
22558
|
const names = /* @__PURE__ */ new Map();
|
|
22497
|
-
const
|
|
22559
|
+
const dir2 = join9(work, "assets");
|
|
22498
22560
|
const swap = (v) => {
|
|
22499
22561
|
const raw = String(v ?? "").trim();
|
|
22500
|
-
if (/^assets\/[\w.-]+$/.test(raw) &&
|
|
22562
|
+
if (/^assets\/[\w.-]+$/.test(raw) && existsSync7(join9(work, raw))) return raw;
|
|
22501
22563
|
const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir3()));
|
|
22502
22564
|
if (!names.has(file)) {
|
|
22503
|
-
if (!
|
|
22565
|
+
if (!existsSync7(file) || !statSync2(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.`);
|
|
22504
22566
|
if (names.size >= 8) throw new Error("8 pictures at most per reel.");
|
|
22505
22567
|
const name = safeAssetName(file, names.size);
|
|
22506
|
-
|
|
22507
|
-
copyFileSync2(file,
|
|
22568
|
+
mkdirSync7(dir2, { recursive: true });
|
|
22569
|
+
copyFileSync2(file, join9(dir2, name));
|
|
22508
22570
|
names.set(file, `assets/${name}`);
|
|
22509
22571
|
}
|
|
22510
22572
|
return names.get(file);
|
|
@@ -22541,51 +22603,60 @@ function headTop(work, duration3) {
|
|
|
22541
22603
|
return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
|
|
22542
22604
|
}
|
|
22543
22605
|
function ensureCutout(work, duration3) {
|
|
22544
|
-
const footage =
|
|
22545
|
-
const st =
|
|
22606
|
+
const footage = join9(work, "footage.mp4"), cut = join9(work, "cut.webm"), stamp = join9(work, "cut.json"), still = join9(work, "assets", "still.jpg");
|
|
22607
|
+
const st = statSync2(footage);
|
|
22546
22608
|
const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
22547
22609
|
let cached2 = null;
|
|
22548
22610
|
try {
|
|
22549
|
-
cached2 = JSON.parse(
|
|
22611
|
+
cached2 = JSON.parse(readFileSync8(stamp, "utf8"));
|
|
22550
22612
|
} catch {
|
|
22551
22613
|
}
|
|
22552
22614
|
let made_s = 0;
|
|
22553
|
-
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime &&
|
|
22615
|
+
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync7(cut))) {
|
|
22554
22616
|
const started = Date.now();
|
|
22555
22617
|
const r = sh("npx", ["--yes", HF2, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
|
|
22556
|
-
if (r.status !== 0 || !
|
|
22618
|
+
if (r.status !== 0 || !existsSync7(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
|
|
22557
22619
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22558
22620
|
made_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22559
22621
|
cached2 = { ...key, head: headTop(work, duration3), made_s };
|
|
22560
|
-
|
|
22622
|
+
writeFileSync7(stamp, JSON.stringify(cached2));
|
|
22561
22623
|
}
|
|
22562
|
-
if (!
|
|
22563
|
-
|
|
22624
|
+
if (!existsSync7(still)) {
|
|
22625
|
+
mkdirSync7(join9(work, "assets"), { recursive: true });
|
|
22564
22626
|
sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
|
|
22565
22627
|
}
|
|
22566
22628
|
return { head: cached2.head, made_s };
|
|
22567
22629
|
}
|
|
22568
|
-
var planFile = (folder) =>
|
|
22630
|
+
var planFile = (folder) => join9(folder, ".cutroom", "plan.json");
|
|
22569
22631
|
async function composition(input) {
|
|
22570
22632
|
const pj = resolveProject(input.project);
|
|
22571
|
-
const work =
|
|
22633
|
+
const work = join9(pj.folder, ".cutroom");
|
|
22572
22634
|
const words = applyFixes(pj.words, input.fixes);
|
|
22573
|
-
const
|
|
22635
|
+
const all = input.effects ?? [];
|
|
22636
|
+
const local2 = localImages(work, input.images, all.filter((f) => !isCustom(f)));
|
|
22637
|
+
const brand = getBrand();
|
|
22638
|
+
const base = { duration: pj.duration, words, layout: input.layout, caption: input.caption, effects: local2.effects, images: local2.images, emphasis: input.emphasis, brand: brand ?? void 0 };
|
|
22639
|
+
const slots = all.filter(isCustom);
|
|
22640
|
+
if (slots.length) await call("compose", { ...base, custom: slots.map((c, k2) => ({ id: `cs-check${k2}00`, start: c.start, dur: customDur(c) })), check_only: true });
|
|
22641
|
+
const custom = slots.length ? await ensureCustom(work, slots, { words, layout: input.layout ?? "full-frame", brand, about: input.about }) : { slots: [], made: 0 };
|
|
22574
22642
|
const cutout = await wantsCutout(input.layout, local2.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22575
|
-
const { html } = await call("compose", {
|
|
22643
|
+
const { html } = await call("compose", { ...base, custom: custom.slots.map((c) => ({ id: c.scene, start: c.start, dur: c.dur })), ...cutout ? { cutout: true, head: cutout.head } : {} });
|
|
22576
22644
|
await ensureFonts(work);
|
|
22577
|
-
|
|
22578
|
-
|
|
22645
|
+
writeFileSync7(join9(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
22646
|
+
let k = 0;
|
|
22647
|
+
const effects = all.map((f) => isCustom(f) ? custom.slots[k++] : f);
|
|
22648
|
+
return { pj, work, html, cutout, words, effects, made: custom.made };
|
|
22579
22649
|
}
|
|
22580
22650
|
var fmtT = (t) => `${t.toFixed(1)}s`;
|
|
22581
22651
|
var SHOWN = ["word", "text", "title", "number", "label", "eyebrow", "keyword", "items", "lines", "labels", "steps", "message", "before", "after", "note"];
|
|
22582
22652
|
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 ");
|
|
22583
22653
|
async function planReel(input) {
|
|
22584
|
-
const { pj, work, html, cutout, words } = await composition(input);
|
|
22585
|
-
const effects = [...
|
|
22654
|
+
const { pj, work, html, cutout, words, effects: filled, made } = await composition(input);
|
|
22655
|
+
const effects = [...filled].sort((a, b) => Number(a.start) - Number(b.start));
|
|
22656
|
+
const durOf = (f) => isCustom(f) ? customDur(f) : Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5);
|
|
22586
22657
|
const peak = (f) => {
|
|
22587
|
-
const d =
|
|
22588
|
-
const at = f.slug === "frame-rip" ? d * 0.5 : Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : 0.55));
|
|
22658
|
+
const d = durOf(f);
|
|
22659
|
+
const at = f.slug === "frame-rip" ? d * 0.5 : Math.max(d * 0.5, d - (f.slug === "comment-dm" ? 0.32 : isCustom(f) ? 0.7 : 0.55));
|
|
22589
22660
|
return Math.min(pj.duration - 0.1, Number(f.start) + at);
|
|
22590
22661
|
};
|
|
22591
22662
|
const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
|
|
@@ -22616,39 +22687,42 @@ async function planReel(input) {
|
|
|
22616
22687
|
const cur = sentences.filter((x) => x.t <= from).slice(-1);
|
|
22617
22688
|
return cur.length ? `(\u2026${cur[0].text.join(" ")})` : "";
|
|
22618
22689
|
};
|
|
22619
|
-
const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) +
|
|
22690
|
+
const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) + durOf(f)));
|
|
22620
22691
|
const moments = [
|
|
22621
|
-
...plainAt !== null ? [{ t: 0, end: effects.length ? Number(effects[0].start) : pj.duration, what: "just you and the captions", tag: "you + captions", at: plainAt }] : [],
|
|
22692
|
+
...plainAt !== null ? [{ t: 0, end: effects.length ? Number(effects[0].start) : pj.duration, what: "just you and the captions", tag: "you + captions", at: plainAt, custom: false }] : [],
|
|
22622
22693
|
...effects.map((f, k) => {
|
|
22694
|
+
if (isCustom(f)) return { t: Number(f.start), end: ends[k], what: `CUSTOM scene, made for this line: ${f.shows || f.title}`, tag: `CUSTOM ${f.title ?? ""}`, at: peak(f), custom: true };
|
|
22623
22695
|
const name = title(styles.effects, f.slug);
|
|
22624
|
-
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f) };
|
|
22696
|
+
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f), custom: false };
|
|
22625
22697
|
})
|
|
22626
22698
|
];
|
|
22627
|
-
const shots =
|
|
22699
|
+
const shots = join9(work, "storyboard");
|
|
22628
22700
|
rmSync5(shots, { recursive: true, force: true });
|
|
22629
|
-
|
|
22701
|
+
writeFileSync7(join9(work, "index.html"), html);
|
|
22630
22702
|
let r;
|
|
22631
22703
|
try {
|
|
22632
22704
|
r = sh("npx", ["--yes", HF2, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
|
|
22633
22705
|
} finally {
|
|
22634
|
-
rmSync5(
|
|
22706
|
+
rmSync5(join9(work, "index.html"), { force: true });
|
|
22635
22707
|
}
|
|
22636
|
-
const pngs =
|
|
22708
|
+
const pngs = existsSync7(shots) ? readdirSync3(shots).filter((f) => f.endsWith(".png")).sort() : [];
|
|
22637
22709
|
if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
|
|
22638
22710
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22639
|
-
const board =
|
|
22711
|
+
const board = join9(pj.folder, "storyboard.jpg");
|
|
22640
22712
|
const cols = Math.min(4, moments.length);
|
|
22641
22713
|
const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
|
|
22642
|
-
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i",
|
|
22643
|
-
const
|
|
22714
|
+
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join9(shots, f)]);
|
|
22715
|
+
const mark = getBrand()?.highlight?.replace(/^#?/, "#") ?? "#8B8EF5";
|
|
22716
|
+
const badge = `,drawbox=x=0:y=0:w=270:h=480:color=${mark}:t=5,drawbox=x=8:y=8:w=86:h=26:color=${mark}:t=fill,drawtext=text='CUSTOM':x=14:y=13:fontsize=16:fontcolor=#0A0A0A`;
|
|
22717
|
+
const cells = moments.map((m, k) => `[${k}:v]scale=270:480,pad=270:540:0:0:#0A0A0A${m.custom ? badge : ""},drawtext=text='${label(`${fmtT(m.t)} ${m.tag}`)}':x=10:y=496:fontsize=18:fontcolor=#F7F3E8[c${k}]`).join(";");
|
|
22644
22718
|
const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
|
|
22645
22719
|
const stack2 = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
|
|
22646
22720
|
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack2}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
|
|
22647
22721
|
if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
|
|
22648
22722
|
${tail(t.stderr)}`);
|
|
22649
22723
|
rmSync5(shots, { recursive: true, force: true });
|
|
22650
|
-
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes };
|
|
22651
|
-
|
|
22724
|
+
const plan = { layout: input.layout ?? "full-frame", caption: input.caption ?? "two-tier", effects, images: input.images, emphasis: input.emphasis, fixes: input.fixes, about: input.about };
|
|
22725
|
+
writeFileSync7(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22652
22726
|
const text = [
|
|
22653
22727
|
`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)." : ""}`,
|
|
22654
22728
|
"",
|
|
@@ -22659,27 +22733,29 @@ ${tail(t.stderr)}`);
|
|
|
22659
22733
|
`Storyboard: ${board}`
|
|
22660
22734
|
].join("\n");
|
|
22661
22735
|
setStatus2(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
|
|
22662
|
-
|
|
22736
|
+
const nCustom = effects.filter(isCustom).length;
|
|
22737
|
+
const scenesNote = nCustom ? ` Rows marked CUSTOM are graphics Cutroom made for those lines (${made} new this time; each new one counts toward the daily limit of custom scenes, unchanged ones are reused). To change one, give that slot a request (e.g. request:'a phone with the feed') or redo:true for a fresh take; to go back to a menu effect, put that effect in the slot instead.` : "";
|
|
22738
|
+
return { text, storyboard: board, plan, words_used: words.length, next: `Show this table as it is (plain words, no effect codes) and the storyboard.${scenesNote} On changes, plan_reel again with the whole choice; a custom slot with the same time, length and request reuses its scene (no new one is written), so only change the slots they asked to change. On a go, render_reel with plan:true and draft:true.` };
|
|
22663
22739
|
}
|
|
22664
22740
|
async function renderReel(input) {
|
|
22665
22741
|
if (input.plan) {
|
|
22666
22742
|
const folder = resolveProject(input.project).folder;
|
|
22667
|
-
if (!
|
|
22668
|
-
input = { ...JSON.parse(
|
|
22743
|
+
if (!existsSync7(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
|
|
22744
|
+
input = { ...JSON.parse(readFileSync8(planFile(folder), "utf8")), project: input.project, draft: input.draft };
|
|
22669
22745
|
}
|
|
22670
22746
|
const { pj, work, html, cutout } = await composition(input);
|
|
22671
|
-
|
|
22672
|
-
const out =
|
|
22673
|
-
const tmp =
|
|
22674
|
-
|
|
22747
|
+
mkdirSync7(join9(pj.folder, "renders"), { recursive: true });
|
|
22748
|
+
const out = join9(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
|
|
22749
|
+
const tmp = join9(work, "render.mp4");
|
|
22750
|
+
writeFileSync7(join9(work, "index.html"), html);
|
|
22675
22751
|
const started = Date.now();
|
|
22676
22752
|
let r;
|
|
22677
22753
|
try {
|
|
22678
22754
|
r = sh("npx", ["--yes", HF2, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
|
|
22679
22755
|
} finally {
|
|
22680
|
-
rmSync5(
|
|
22756
|
+
rmSync5(join9(work, "index.html"), { force: true });
|
|
22681
22757
|
}
|
|
22682
|
-
if (r.status !== 0 || !
|
|
22758
|
+
if (r.status !== 0 || !existsSync7(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
|
|
22683
22759
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22684
22760
|
renameSync4(tmp, out);
|
|
22685
22761
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
@@ -22690,10 +22766,10 @@ ${tail(r.stderr) || tail(r.stdout)}`);
|
|
|
22690
22766
|
}
|
|
22691
22767
|
async function qaVideo(input) {
|
|
22692
22768
|
const file = resolve3(input.video.replace(/^~/, homedir3()));
|
|
22693
|
-
if (!
|
|
22769
|
+
if (!existsSync7(file)) throw new Error(`No such file: ${file}`);
|
|
22694
22770
|
const tech = technicalChecks(file);
|
|
22695
|
-
const
|
|
22696
|
-
const { frames } = sampleFrames(file,
|
|
22771
|
+
const dir2 = mkdtempSync(join9(tmpdir(), "cutroom-qa-"));
|
|
22772
|
+
const { frames } = sampleFrames(file, dir2, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
|
|
22697
22773
|
let flags = [];
|
|
22698
22774
|
let judged = 0, judge_error = null;
|
|
22699
22775
|
try {
|
|
@@ -22703,7 +22779,7 @@ async function qaVideo(input) {
|
|
|
22703
22779
|
} catch (e) {
|
|
22704
22780
|
judge_error = e.message;
|
|
22705
22781
|
}
|
|
22706
|
-
rmSync5(
|
|
22782
|
+
rmSync5(dir2, { recursive: true, force: true });
|
|
22707
22783
|
const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
|
|
22708
22784
|
const passed = !tech.issues.length && !flags.length && !judge_error;
|
|
22709
22785
|
const report = {
|
|
@@ -22733,18 +22809,24 @@ var MEMBER_CAROUSEL = false;
|
|
|
22733
22809
|
var METHODS = {
|
|
22734
22810
|
hooks: {
|
|
22735
22811
|
title: "Video hooks",
|
|
22736
|
-
text: `Write hooks for a short video (Reel, Short, TikTok) that
|
|
22812
|
+
text: `Write hooks for a short video (Reel, Short, TikTok) spoken to camera. Every hook has three parts that say the same thing: the spoken line, on-screen text (3 to 7 words that add to the line, no punctuation except quotes) and the first visual (a moment from their footage moved to second zero, or a hook scene made for it).
|
|
22737
22813
|
|
|
22738
|
-
1.
|
|
22739
|
-
2.
|
|
22740
|
-
|
|
22741
|
-
|
|
22742
|
-
|
|
22743
|
-
4.
|
|
22744
|
-
|
|
22745
|
-
|
|
22814
|
+
1. Facts: who it's for, what they want or fear, the proof the user has, what the footage shows. Missing: ask in one message. One problem per run.
|
|
22815
|
+
2. Contrast in one line: "Viewers think A. This video shows B." If the video can't prove a contrast, say the idea is weak.
|
|
22816
|
+
3. Write 10 to 15 hooks across different formats and angles. Build each as context (topic at once), lean in (proof, pain, benefit or surprise), then snap back against what they expect.
|
|
22817
|
+
Formats: Fortune Teller (what changes for them next), Contrarian (a common belief, flipped fairly), Insider (what people inside know), Proven Winner (results first), Magnet (a sight they can't look away from), Connector (two unrelated things joined).
|
|
22818
|
+
Angles: shocking number, contrarian belief, personal confession, result transformation, industry death, secret system, time compression, cost of inaction, insider leak, story in motion.
|
|
22819
|
+
4. Rules:
|
|
22820
|
+
- Topic obvious in line one, under 15 words. Pause there: could a friend say what the video is about?
|
|
22821
|
+
- About the viewer (their problem, want or belief), not the user, their tool, team or routine. The user's story comes later, as proof.
|
|
22822
|
+
- Line two is proof: results for others beat their own results beat effort.
|
|
22823
|
+
- Every fact at its true size: an average stays an average, a count keeps its meaning ("posted three times" never becomes "flopped three times"), no times or lengths they didn't give, quote only words they gave. A missing number is a [placeholder].
|
|
22824
|
+
- Definitive, one breath, contractions, no em dashes, no clich\xE9s, no view promises.
|
|
22825
|
+
5. score_hooks with the spoken lines and the audience. Take the top 3 and fix every failure its checks name (the exact words), then score the fixed lines once more.
|
|
22826
|
+
6. Give each of the top 3 its on-screen text and first visual. Sound off: do picture and text give the topic? Eyes closed: does the line?
|
|
22827
|
+
7. Lock-in for the winner: the next 2 to 3 sentences that pay the hook off with proof, a picture of the result or the first step. No backstory, no intro.
|
|
22746
22828
|
|
|
22747
|
-
Output: top 3
|
|
22829
|
+
Output: the top 3, each as Say / On-screen text / First visual / format / score. Then the winner, why in one line, its lock-in lines, and facts to verify before filming.`
|
|
22748
22830
|
},
|
|
22749
22831
|
script: {
|
|
22750
22832
|
title: "Short script (8 lines)",
|
|
@@ -22753,15 +22835,15 @@ Output: top 3 with their angle, the winner, the second sentence, and any number
|
|
|
22753
22835
|
1. Thesis: "This video will prove that ___." One video, one idea.
|
|
22754
22836
|
2. Payoff first: "By the end, viewers understand ___." This is the ending; write it before any hook.
|
|
22755
22837
|
3. Story spine in 5 sentences: before, problem, cost, discovery, after. Prefer a real story with a number the user can prove.
|
|
22756
|
-
4.
|
|
22757
|
-
5. The 8 lines: 1 hook (
|
|
22838
|
+
4. Hook: follow get_method('hooks') steps 2 to 6 in short (5 or 6 hooks, score_hooks, fix the failures). If the user brings a hook, run it through score_hooks and fix what fails. The hook ships as spoken line, on-screen text and first visual.
|
|
22839
|
+
5. The 8 lines: 1 hook (topic in line one, about the viewer, under 15 words), 2 lock-in (proof, a picture of the result or the first step; never backstory), 3 setup (only needed context), 4 conflict (the cost), 5 turning point, 6 mechanism or proof (proof beats explanation), 7 rehook only for scripts over 20 seconds ("But that wasn't the real problem."), 8 payoff that closes the hook's question. Optional single CTA.
|
|
22758
22840
|
6. Question test after every line: what question does it create? No question and no useful answer: delete it.
|
|
22759
22841
|
7. Cut pass: remove repeats, long intros, generic claims, extra CTAs. Aloud pass: most sentences under 12 words, contractions, 70 to 100 words.
|
|
22760
22842
|
8. Send the script to the score_script tool. Below its pass mark, revise the weakest two lines once and score again.
|
|
22761
22843
|
|
|
22762
|
-
Rules: definitive statements, no em dashes, name the outcome not the tool, one CTA at most, every number provable and
|
|
22844
|
+
Rules: definitive statements, no em dashes, name the outcome not the tool, one CTA at most, every number provable, correctly attributed and at its true size (an average stays an average, a count keeps its meaning).
|
|
22763
22845
|
|
|
22764
|
-
Output: thesis, payoff, spine, chosen hook, the numbered script with word count, the score, and numbers to verify before filming.`
|
|
22846
|
+
Output: thesis, payoff, spine, chosen hook with its on-screen text and first visual, the numbered script with word count, the score, and numbers to verify before filming.`
|
|
22765
22847
|
},
|
|
22766
22848
|
caption: {
|
|
22767
22849
|
title: "Post caption and comment CTA",
|
|
@@ -22780,15 +22862,16 @@ Output: the caption ready to paste, and the keyword with what gets sent.`
|
|
|
22780
22862
|
|
|
22781
22863
|
1. prepare_footage with the file path (add language if they do not speak English). If it says it could not hear anyone talking, tell them plainly and stop. If it returns a warning (a wide or already-edited video), tell them plainly and ask for the raw vertical take; go on only if they say this one is fine.
|
|
22782
22864
|
2. From the timed transcript, list words that look misheard (names, brands, numbers) and confirm the spelling. Pass corrections as fixes later.
|
|
22783
|
-
3.
|
|
22784
|
-
4.
|
|
22785
|
-
5.
|
|
22865
|
+
3. Opening check: send what they say in the first 10 to 15 seconds to score_hooks as one text, with who the video is for. Tell them in plain words: who it's about (the viewer, or them and their tool), the second the topic becomes clear, each failure with its exact words, and when the contrast or payoff first comes. Suggest lines to cut or move (e.g. "start at 0:10, where you say most brands copy trends"). Cutroom keeps the take as filmed: to cut or move lines they trim the raw video or refilm the opening, then prepare_footage again. For a new opening, get_method('hooks'). Keep the chosen hook's on-screen text and first visual.
|
|
22866
|
+
4. Brand: show_brand. If none is set, offer to set one now (it changes every color in the edit); if they skip, use the default look.
|
|
22867
|
+
5. Plan: list_styles (share the gallery link so they can see each style moving), then get_method('edit_plan') and follow it, with the hook's on-screen text and first visual in the hook scene. Show the plan as the plain table it asks for.
|
|
22868
|
+
6. plan_reel with that choice. Show its table and the storyboard image (one real frame per scene). They change anything ("move the checklist to 30s", "swap orbit for card wall", "serif captions"): plan_reel again. No B-roll unless they ask.
|
|
22786
22869
|
- Effects or layouts with needs "cutout" first cut them out of the background on this computer: about 6 seconds per second of video, once per video. Say so before you plan one.
|
|
22787
22870
|
- Effects with pictures "optional" can use their own images (file paths); ask if a scene would be better with a screenshot, otherwise they work without.
|
|
22788
|
-
|
|
22789
|
-
|
|
22790
|
-
|
|
22791
|
-
|
|
22871
|
+
7. On a go: render_reel with plan:true and draft:true. Tell them where the file is.
|
|
22872
|
+
8. QA: technical issues (frozen picture, loudness, black frames, size) are real; fix them. Look flags are spots to check: grab each flagged frame (ffmpeg) and look at it yourself, then say which are real problems (with the fix) and which are the design.
|
|
22873
|
+
9. They approve the draft: render_reel with plan:true (no draft) for the final. After changes: plan_reel, then render again.
|
|
22874
|
+
10. Offer the post caption next (get_method('caption')).`
|
|
22792
22875
|
},
|
|
22793
22876
|
edit_plan: {
|
|
22794
22877
|
title: "Planning the edit scene by scene",
|
|
@@ -22812,9 +22895,10 @@ Output: the caption ready to paste, and the keyword with what gets sent.`
|
|
|
22812
22895
|
- pointing at something on screen: Freeze and label or Doodle
|
|
22813
22896
|
- the brand or style: Brand kit card
|
|
22814
22897
|
- the call to action: Comment to DM (their keyword), Follow tap, or CTA card (their offer and button), as the last scene
|
|
22815
|
-
The hook gets the strongest visual (Text behind you, Orbit, Card wall or Pull-back in Full-frame; Paper cut, Frame rip or Cover story in Explainer panel or Split reel).
|
|
22898
|
+
The hook gets the strongest visual (Text behind you, Orbit, Card wall or Pull-back in Full-frame; Paper cut, Frame rip or Cover story in Explainer panel or Split reel). It carries the chosen hook's on-screen text (3 to 7 words, the same thing the line says) and shows its first visual: their screenshot or picture of it when they have one, else the effect that best shows it.
|
|
22816
22899
|
4. Do not repeat the same effect in two scenes in a row.
|
|
22817
22900
|
5. Layout: Explainer panel or Split reel when most scenes have a visual (the space above stays busy); Full-frame when only a few do. Captions: Two-tier or Box unless they want another. Text behind you, Orbit and Card wall need Full-frame or a cut-out layout; in Explainer panel or Split reel use the others.
|
|
22901
|
+
Custom scenes (Explainer panel or Split reel only): by default the hook and the 2 or 3 key moments (the main claim, the method, the proof) are custom: {slug:'custom', start, dur} covering the whole line or idea (3 to 10 seconds), with job when it is clear (number, comparison, process, app, social-proof, list, chart, map, emphasis). Cutroom makes each one for that line, in their brand, filling the space above the face; every other scene uses the menu. For the hook slot, put the chosen hook's on-screen text and first visual in its request (e.g. request:'on-screen text "Trends without copying", a feed where 3 reels get picked'). No menu effect overlaps a custom scene. In the table write "Custom: <what it will show>". Requests like "make the 13s scene custom", "change the hook scene to a phone" (request:'a phone scrolling the feed' on that slot) or "use number pop again" (that menu effect back in the slot): plan_reel again. About 10 new custom scenes a day per member, so keep the ones they like (pass the slots back as plan_reel returned them).
|
|
22818
22902
|
6. Effect text: only the user's own words and numbers, short (the effect's limits are in list_styles). For Comment to DM or Follow tap, ask their Instagram handle once (if you don't know it) and use it.
|
|
22819
22903
|
7. Show the plan as a table, plain words only, never effect codes:
|
|
22820
22904
|
| Time | What you say | What shows on screen |
|
|
@@ -22986,15 +23070,15 @@ var PROMPTS = [
|
|
|
22986
23070
|
].filter((p) => MEMBER_CAROUSEL || p.name !== "copy-carousel");
|
|
22987
23071
|
|
|
22988
23072
|
// src/community/local/carousel.ts
|
|
22989
|
-
import { existsSync as
|
|
23073
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
22990
23074
|
import { homedir as homedir5 } from "node:os";
|
|
22991
|
-
import { isAbsolute as isAbsolute3, join as
|
|
23075
|
+
import { isAbsolute as isAbsolute3, join as join11, resolve as resolve5 } from "node:path";
|
|
22992
23076
|
|
|
22993
23077
|
// src/carousel.ts
|
|
22994
23078
|
import { execFileSync as execFileSync2, spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
|
|
22995
|
-
import { copyFileSync as copyFileSync3, existsSync as
|
|
23079
|
+
import { copyFileSync as copyFileSync3, existsSync as existsSync8, mkdirSync as mkdirSync8, readdirSync as readdirSync4, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
|
|
22996
23080
|
import { homedir as homedir4 } from "node:os";
|
|
22997
|
-
import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as
|
|
23081
|
+
import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join10, resolve as resolve4 } from "node:path";
|
|
22998
23082
|
|
|
22999
23083
|
// src/scrapecreators.ts
|
|
23000
23084
|
import { createWriteStream as createWriteStream2 } from "node:fs";
|
|
@@ -23028,13 +23112,13 @@ function paletteFromRgb(px, k = 6) {
|
|
|
23028
23112
|
const merged = [];
|
|
23029
23113
|
for (const b of [...buckets.values()].sort((x, y) => y.n - x.n)) {
|
|
23030
23114
|
const c = { n: b.n, r: b.r / b.n, g: b.g / b.n, b: b.b / b.n };
|
|
23031
|
-
const
|
|
23032
|
-
if (
|
|
23033
|
-
const n =
|
|
23034
|
-
|
|
23035
|
-
|
|
23036
|
-
|
|
23037
|
-
|
|
23115
|
+
const near2 = merged.find((m) => Math.hypot(m.r - c.r, m.g - c.g, m.b - c.b) < 48);
|
|
23116
|
+
if (near2) {
|
|
23117
|
+
const n = near2.n + c.n;
|
|
23118
|
+
near2.r = (near2.r * near2.n + c.r * c.n) / n;
|
|
23119
|
+
near2.g = (near2.g * near2.n + c.g * c.n) / n;
|
|
23120
|
+
near2.b = (near2.b * near2.n + c.b * c.n) / n;
|
|
23121
|
+
near2.n = n;
|
|
23038
23122
|
} else merged.push(c);
|
|
23039
23123
|
}
|
|
23040
23124
|
const hex = (v) => Math.round(v).toString(16).padStart(2, "0");
|
|
@@ -23043,18 +23127,18 @@ function paletteFromRgb(px, k = 6) {
|
|
|
23043
23127
|
function tileSheet(files, out, cell = 324) {
|
|
23044
23128
|
if (!files.length) return null;
|
|
23045
23129
|
const tmp = `${out}.parts`;
|
|
23046
|
-
|
|
23130
|
+
mkdirSync8(tmp, { recursive: true });
|
|
23047
23131
|
try {
|
|
23048
23132
|
const h = Math.round(cell * 1.25 / 2) * 2;
|
|
23049
23133
|
const parts = files.map((f, i) => {
|
|
23050
|
-
const part =
|
|
23134
|
+
const part = join10(tmp, `${String(i).padStart(3, "0")}.jpg`);
|
|
23051
23135
|
const fit = `scale=${cell}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=${cell}:${h}:(ow-iw)/2:(oh-ih)/2:color=0x202020`;
|
|
23052
23136
|
const labelled = spawnSync4("ffmpeg", ["-v", "error", "-y", "-i", f, "-vf", `${fit},drawtext=text='${i + 1}':x=10:y=10:fontsize=26:fontcolor=white:box=1:boxcolor=black@0.75:boxborderw=7`, part]);
|
|
23053
23137
|
if (labelled.status !== 0) execFileSync2("ffmpeg", ["-v", "error", "-y", "-i", f, "-vf", fit, part]);
|
|
23054
23138
|
return part;
|
|
23055
23139
|
});
|
|
23056
|
-
const list =
|
|
23057
|
-
|
|
23140
|
+
const list = join10(tmp, "list.txt");
|
|
23141
|
+
writeFileSync8(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
|
|
23058
23142
|
const cols = Math.min(5, parts.length), rows = Math.ceil(parts.length / cols);
|
|
23059
23143
|
execFileSync2("ffmpeg", ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", list, "-vf", `tile=${cols}x${rows}:padding=6:color=black`, "-frames:v", "1", "-q:v", "3", out]);
|
|
23060
23144
|
return out;
|
|
@@ -23065,30 +23149,30 @@ function tileSheet(files, out, cell = 324) {
|
|
|
23065
23149
|
}
|
|
23066
23150
|
}
|
|
23067
23151
|
var IMAGE_EXT = /\.(jpe?g|png|webp|heic)$/i;
|
|
23068
|
-
async function saveCarousel(meta2,
|
|
23069
|
-
const slidesDir =
|
|
23070
|
-
|
|
23152
|
+
async function saveCarousel(meta2, dir2, opts = {}) {
|
|
23153
|
+
const slidesDir = join10(dir2, "slides");
|
|
23154
|
+
mkdirSync8(slidesDir, { recursive: true });
|
|
23071
23155
|
const files = [];
|
|
23072
23156
|
if (meta2) {
|
|
23073
23157
|
for (const [i, img] of meta2.images.entries()) {
|
|
23074
|
-
const target =
|
|
23075
|
-
if (!
|
|
23158
|
+
const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}.jpg`);
|
|
23159
|
+
if (!existsSync8(target)) await download2(img.url, target, opts.fetchImpl);
|
|
23076
23160
|
files.push(target);
|
|
23077
23161
|
}
|
|
23078
23162
|
} else if (opts.folder) {
|
|
23079
|
-
const names =
|
|
23163
|
+
const names = readdirSync4(opts.folder).filter((n) => IMAGE_EXT.test(n)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
|
|
23080
23164
|
if (!names.length) throw new Error(`No images in ${opts.folder} (jpg, png, webp).`);
|
|
23081
23165
|
for (const [i, n] of names.entries()) {
|
|
23082
|
-
const target =
|
|
23083
|
-
copyFileSync3(
|
|
23166
|
+
const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}${extname2(n).toLowerCase()}`);
|
|
23167
|
+
copyFileSync3(join10(opts.folder, n), target);
|
|
23084
23168
|
files.push(target);
|
|
23085
23169
|
}
|
|
23086
23170
|
}
|
|
23087
23171
|
const slides = files.map((f, i) => ({ n: i + 1, file: f, size: imageSize(f), video_cover: !!meta2?.images[i]?.video, palette: palette(f) }));
|
|
23088
|
-
const sheet = tileSheet(files,
|
|
23089
|
-
if (meta2?.caption)
|
|
23172
|
+
const sheet = tileSheet(files, join10(dir2, "sheet.jpg"));
|
|
23173
|
+
if (meta2?.caption) writeFileSync8(join10(dir2, "caption.txt"), meta2.caption);
|
|
23090
23174
|
const info = { source: meta2?.url ?? opts.folder, platform: meta2?.platform ?? "folder", owner: meta2?.owner, code: meta2?.code, views: meta2?.views, likes: meta2?.likes, comments: meta2?.comments, timestamp: meta2?.timestamp, slides: slides.length };
|
|
23091
|
-
|
|
23175
|
+
writeFileSync8(join10(dir2, "meta.json"), JSON.stringify({ ...info, slides }, null, 2));
|
|
23092
23176
|
const md = [
|
|
23093
23177
|
"# Carousel breakdown",
|
|
23094
23178
|
"",
|
|
@@ -23105,8 +23189,8 @@ async function saveCarousel(meta2, dir, opts = {}) {
|
|
|
23105
23189
|
meta2?.caption ?? "(none)",
|
|
23106
23190
|
""
|
|
23107
23191
|
].join("\n");
|
|
23108
|
-
|
|
23109
|
-
return { folder:
|
|
23192
|
+
writeFileSync8(join10(dir2, "breakdown.md"), md);
|
|
23193
|
+
return { folder: dir2, report: join10(dir2, "breakdown.md"), sheet, slides: slides.map(({ n, file, size, video_cover, palette: palette2 }) => ({ n, file, size, video_cover, palette: palette2 })), caption: meta2?.caption ?? null, meta: info };
|
|
23110
23194
|
}
|
|
23111
23195
|
function imageSize(file) {
|
|
23112
23196
|
const r = spawnSync4("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file], { encoding: "utf8" });
|
|
@@ -23203,8 +23287,8 @@ function resolveStyle(spec, opts = {}) {
|
|
|
23203
23287
|
return mergeStyle({ ...BASE, name: ref }, preset.style);
|
|
23204
23288
|
}
|
|
23205
23289
|
const file = isAbsolute2(ref) ? ref : resolve4(opts.baseDir ?? ".", ref.replace(/^~/, homedir4()));
|
|
23206
|
-
if (!
|
|
23207
|
-
const j = JSON.parse(
|
|
23290
|
+
if (!existsSync8(file)) throw new Error(`No style called ${ref} (presets: ${Object.keys(STYLE_PRESETS).join(", ")}; or a style.json path).`);
|
|
23291
|
+
const j = JSON.parse(readFileSync9(file, "utf8"));
|
|
23208
23292
|
return j.preset || j.path ? resolveStyle(j, { ...opts, baseDir: dirname2(file) }) : mergeStyle({ ...BASE, name: basename3(file, ".json") }, j);
|
|
23209
23293
|
};
|
|
23210
23294
|
if (typeof spec === "string") return load(spec);
|
|
@@ -23440,12 +23524,12 @@ function findChrome(env = process.env) {
|
|
|
23440
23524
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
23441
23525
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
23442
23526
|
].filter(Boolean);
|
|
23443
|
-
for (const c of cands) if (
|
|
23444
|
-
const pw =
|
|
23445
|
-
if (
|
|
23446
|
-
for (const d of
|
|
23527
|
+
for (const c of cands) if (existsSync8(c)) return c;
|
|
23528
|
+
const pw = join10(homedir4(), "Library", "Caches", "ms-playwright");
|
|
23529
|
+
if (existsSync8(pw)) {
|
|
23530
|
+
for (const d of readdirSync4(pw).filter((n) => /^chromium-\d+$/.test(n)).sort().reverse()) {
|
|
23447
23531
|
for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"]) {
|
|
23448
|
-
if (
|
|
23532
|
+
if (existsSync8(join10(pw, d, sub))) return join10(pw, d, sub);
|
|
23449
23533
|
}
|
|
23450
23534
|
}
|
|
23451
23535
|
}
|
|
@@ -23477,9 +23561,9 @@ function contrast(a, b) {
|
|
|
23477
23561
|
return Math.round((x + 0.05) / (y + 0.05) * 100) / 100;
|
|
23478
23562
|
}
|
|
23479
23563
|
async function renderDeck(folder, opts = {}) {
|
|
23480
|
-
const deckFile =
|
|
23481
|
-
if (!
|
|
23482
|
-
const deck = validateDeck(JSON.parse(
|
|
23564
|
+
const deckFile = join10(folder, "deck.json");
|
|
23565
|
+
if (!existsSync8(deckFile)) throw new Error(`No deck.json in ${folder}.`);
|
|
23566
|
+
const deck = validateDeck(JSON.parse(readFileSync9(deckFile, "utf8")));
|
|
23483
23567
|
const bin = findChrome();
|
|
23484
23568
|
if (!bin) throw new Error("Rendering slides needs Google Chrome (or Chromium) on this computer. Install Chrome, or set CHROME_PATH to it.");
|
|
23485
23569
|
let style = resolveStyle(deck.style, { baseDir: folder, internal: opts.internal });
|
|
@@ -23488,24 +23572,24 @@ async function renderDeck(folder, opts = {}) {
|
|
|
23488
23572
|
style = applyBrand(style, brand);
|
|
23489
23573
|
const total = deck.slides.length;
|
|
23490
23574
|
const targets = (opts.only?.length ? opts.only : deck.slides.map((_, i) => i + 1)).filter((n) => n >= 1 && n <= total);
|
|
23491
|
-
const renders =
|
|
23492
|
-
for (const d of [renders, html])
|
|
23575
|
+
const renders = join10(folder, "renders"), html = join10(folder, "html"), history = join10(renders, "history");
|
|
23576
|
+
for (const d of [renders, html]) mkdirSync8(d, { recursive: true });
|
|
23493
23577
|
const queue = [...targets];
|
|
23494
23578
|
const results = [];
|
|
23495
23579
|
const worker = async () => {
|
|
23496
23580
|
for (let n = queue.shift(); n !== void 0; n = queue.shift()) {
|
|
23497
23581
|
const name = String(n).padStart(2, "0");
|
|
23498
|
-
const page2 =
|
|
23499
|
-
|
|
23500
|
-
if (
|
|
23501
|
-
|
|
23582
|
+
const page2 = join10(html, `${name}.html`), png = join10(renders, `${name}.png`);
|
|
23583
|
+
writeFileSync8(page2, slideHtml(deck.slides[n - 1], style, n, total, { baseDir: folder, handle: deck.handle }));
|
|
23584
|
+
if (existsSync8(png)) {
|
|
23585
|
+
mkdirSync8(history, { recursive: true });
|
|
23502
23586
|
let v = 1;
|
|
23503
|
-
while (
|
|
23504
|
-
copyFileSync3(png,
|
|
23587
|
+
while (existsSync8(join10(history, `${name}-v${v}.png`))) v++;
|
|
23588
|
+
copyFileSync3(png, join10(history, `${name}-v${v}.png`));
|
|
23505
23589
|
}
|
|
23506
23590
|
const [W, H] = style.size;
|
|
23507
23591
|
const shot = await chrome(bin, [`--window-size=${W},${H}`, `--screenshot=${png}`, fileUrl(page2)]);
|
|
23508
|
-
if (!
|
|
23592
|
+
if (!existsSync8(png) || shot.code !== 0) throw new Error(`Chrome could not render slide ${n}.`);
|
|
23509
23593
|
const dom = await chrome(bin, [`--window-size=${W},${H}`, "--dump-dom", fileUrl(page2)]);
|
|
23510
23594
|
let qa = {};
|
|
23511
23595
|
try {
|
|
@@ -23519,15 +23603,15 @@ async function renderDeck(folder, opts = {}) {
|
|
|
23519
23603
|
...qa.shrunk >= 25 ? [`text shrunk ${qa.shrunk}% to fit: consider fewer words`] : [],
|
|
23520
23604
|
...qa.missing_fonts?.length ? [`font not loaded (fallback used): ${qa.missing_fonts.join(", ")}`] : [],
|
|
23521
23605
|
...ratio !== void 0 && ratio < 4.5 ? [`low contrast ${ratio}:1 (text on background)`] : [],
|
|
23522
|
-
...s.image && !/^(https?:|data:)/.test(s.image.src) && !
|
|
23606
|
+
...s.image && !/^(https?:|data:)/.test(s.image.src) && !existsSync8(resolve4(folder, s.image.src.replace(/^~/, homedir4()))) ? [`image missing: ${s.image.src}`] : []
|
|
23523
23607
|
];
|
|
23524
23608
|
results.push({ n, png, shrunk: qa.shrunk, overflow: qa.overflow, missing_fonts: qa.missing_fonts, contrast: ratio, flags });
|
|
23525
23609
|
}
|
|
23526
23610
|
};
|
|
23527
23611
|
await Promise.all(Array.from({ length: Math.min(opts.concurrency ?? 4, targets.length) }, worker));
|
|
23528
23612
|
results.sort((a, b) => a.n - b.n);
|
|
23529
|
-
const all = deck.slides.map((_, i) =>
|
|
23530
|
-
const preview = tileSheet(all,
|
|
23613
|
+
const all = deck.slides.map((_, i) => join10(renders, `${String(i + 1).padStart(2, "0")}.png`)).filter(existsSync8);
|
|
23614
|
+
const preview = tileSheet(all, join10(folder, "preview.jpg"), 360);
|
|
23531
23615
|
return { folder, style: style.name, rendered: results, slides: all, preview, next: "Show the user the preview (and any slide they asked about). For changes, edit deck.json or pass edits, then render only those slides." };
|
|
23532
23616
|
}
|
|
23533
23617
|
function applyEdits(deck, edits) {
|
|
@@ -23547,21 +23631,21 @@ function applyEdits(deck, edits) {
|
|
|
23547
23631
|
}
|
|
23548
23632
|
async function renderCarousel(input, opts = {}) {
|
|
23549
23633
|
const folder = resolve4(input.folder.replace(/^~/, homedir4()));
|
|
23550
|
-
|
|
23551
|
-
const file =
|
|
23634
|
+
mkdirSync8(folder, { recursive: true });
|
|
23635
|
+
const file = join10(folder, "deck.json");
|
|
23552
23636
|
let only = input.only;
|
|
23553
23637
|
if (input.deck) {
|
|
23554
|
-
|
|
23638
|
+
writeFileSync8(file, JSON.stringify(validateDeck(input.deck), null, 2));
|
|
23555
23639
|
} else {
|
|
23556
|
-
if (!
|
|
23557
|
-
let deck = validateDeck(JSON.parse(
|
|
23640
|
+
if (!existsSync8(file)) throw new Error(`No deck.json in ${folder}: pass deck first.`);
|
|
23641
|
+
let deck = validateDeck(JSON.parse(readFileSync9(file, "utf8")));
|
|
23558
23642
|
if (input.style !== void 0) deck.style = input.style;
|
|
23559
23643
|
if (input.edits?.length) {
|
|
23560
23644
|
const r = applyEdits(deck, input.edits);
|
|
23561
23645
|
deck = r.deck;
|
|
23562
23646
|
if (!only?.length && input.style === void 0) only = r.touched;
|
|
23563
23647
|
}
|
|
23564
|
-
|
|
23648
|
+
writeFileSync8(file, JSON.stringify(deck, null, 2));
|
|
23565
23649
|
}
|
|
23566
23650
|
return renderDeck(folder, { only, internal: opts.internal, brand: opts.brand });
|
|
23567
23651
|
}
|
|
@@ -23571,28 +23655,28 @@ var home = (p) => resolve5(p.replace(/^~/, homedir5()));
|
|
|
23571
23655
|
async function breakdownCarousel(input) {
|
|
23572
23656
|
ensureStudio();
|
|
23573
23657
|
const src = input.source.trim();
|
|
23574
|
-
const base =
|
|
23658
|
+
const base = join11(studio.references(), "carousels");
|
|
23575
23659
|
const next = "Open sheet.jpg and every slide, then follow get_method('copy_carousel').";
|
|
23576
23660
|
if (/^https?:\/\//i.test(src)) {
|
|
23577
23661
|
const { meta: meta2 } = await call("carousel", { url: src }).catch((e) => {
|
|
23578
23662
|
throw new Error(`Couldn't fetch that carousel right now (${e instanceof Error ? e.message : e}). Save the slides as images in one folder and give me the folder instead.`);
|
|
23579
23663
|
});
|
|
23580
|
-
const
|
|
23581
|
-
return { ...await saveCarousel(meta2,
|
|
23664
|
+
const dir2 = join11(base, `${day()}-${slug(meta2.owner ?? meta2.platform)}-${meta2.code ?? "post"}`);
|
|
23665
|
+
return { ...await saveCarousel(meta2, dir2), next };
|
|
23582
23666
|
}
|
|
23583
23667
|
const folder = home(src);
|
|
23584
|
-
if (!
|
|
23585
|
-
return { ...await saveCarousel(null,
|
|
23668
|
+
if (!existsSync9(folder)) throw new Error(`No such folder: ${folder}`);
|
|
23669
|
+
return { ...await saveCarousel(null, join11(base, `${day()}-${slug(folder.split(/[\\/]/).pop() ?? "carousel")}`), { folder }), next };
|
|
23586
23670
|
}
|
|
23587
23671
|
function renderCarouselLocal(input) {
|
|
23588
23672
|
ensureStudio();
|
|
23589
|
-
const root =
|
|
23590
|
-
const folder = input.folder ? isAbsolute3(input.folder) || input.folder.startsWith("~") ? home(input.folder) :
|
|
23673
|
+
const root = join11(HOME(), "carousels");
|
|
23674
|
+
const folder = input.folder ? isAbsolute3(input.folder) || input.folder.startsWith("~") ? home(input.folder) : join11(root, input.folder) : join11(root, `${day()}-${slug(input.title ?? input.deck?.title ?? "carousel")}`);
|
|
23591
23675
|
return renderCarousel({ folder, deck: input.deck, edits: input.edits, style: input.style, only: input.only }, { brand: (ref) => ref === "mine" ? brandFromJson(getBrand()) : null });
|
|
23592
23676
|
}
|
|
23593
23677
|
|
|
23594
23678
|
// src/community/local/server.ts
|
|
23595
|
-
process.env.PATH = (WIN ? [process.env.PATH,
|
|
23679
|
+
process.env.PATH = (WIN ? [process.env.PATH, join12(process.env.LOCALAPPDATA ?? join12(homedir6(), "AppData", "Local"), "Microsoft", "WinGet", "Links"), "C:\\Program Files\\nodejs"] : [process.env.PATH, "/opt/homebrew/bin", "/usr/local/bin", join12(homedir6(), ".local", "bin")]).filter(Boolean).join(delimiter2);
|
|
23596
23680
|
useTools();
|
|
23597
23681
|
var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
23598
23682
|
var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
|
|
@@ -23651,7 +23735,7 @@ await serveStdio(() => {
|
|
|
23651
23735
|
${m.text}`;
|
|
23652
23736
|
}));
|
|
23653
23737
|
server.registerTool("score_hooks", {
|
|
23654
|
-
description: "Score and rank 1 to 20
|
|
23738
|
+
description: "Score and rank 1 to 20 spoken hook lines (stops the right viewer, clarity, specificity, tension, one breath, and about the viewer rather than the creator or their tool, which counts double). The top 3 also get four failure checks (delay, confusion, irrelevance, disinterest) with the exact failing words, where the topic becomes clear and where the contrast first comes. Also checks a filmed opening (its first 10 to 15 seconds as one text). Returns a score out of 100 and the pass mark.",
|
|
23655
23739
|
inputSchema: object({ hooks: array(string2().min(1)).min(1).max(20), audience: string2().optional().describe("Who the video is for, e.g. 'founders of B2B SaaS'.") })
|
|
23656
23740
|
}, async ({ hooks, audience }) => safe(() => call("score", { kind: "hook", texts: hooks, audience })));
|
|
23657
23741
|
server.registerTool("score_script", {
|
|
@@ -23713,12 +23797,13 @@ ${m.text}`;
|
|
|
23713
23797
|
})
|
|
23714
23798
|
}, async (input) => local(() => prepareFootage(input)));
|
|
23715
23799
|
server.registerTool("plan_reel", {
|
|
23716
|
-
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 (plan.md in the video's folder, status plan ready) so render_reel with plan:true renders exactly this. Show the storyboard and get a go before rendering.",
|
|
23800
|
+
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; custom scenes are marked CUSTOM. About 10 to 20 seconds (plus 1 to 2 minutes for new custom scenes, plus the one-time cut-out if the choice needs it). Saves the plan (plan.md in the video's folder, status plan ready) so render_reel with plan:true renders exactly this. Show the storyboard and get a go before rendering.",
|
|
23717
23801
|
inputSchema: object({
|
|
23718
23802
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
23719
23803
|
layout: string2().default("full-frame"),
|
|
23720
23804
|
caption: string2().default("two-tier"),
|
|
23721
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(
|
|
23805
|
+
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(15).optional() }).passthrough()).max(24).default([]).describe("Same as render_reel: e.g. [{slug:'number-pop', start:3.2, number:'24', label:'hours later'}]. Menu effects last 1.2 to 5s. A custom scene, made by Cutroom for that line (Explainer Panel and Split Reel layouts): {slug:'custom', start, dur (1.5 to 15s, the whole line or idea), request (optional, what they want it to show, e.g. 'a phone scrolling a feed'), job (optional: number, comparison, process, app, social-proof, list, chart, map, emphasis)}. A slot with the same start, dur and request as before reuses its scene; change request (or add redo:true) for a new one. A new scene takes about 1 to 2 minutes; they are written at the same time."),
|
|
23806
|
+
about: string2().optional().describe("What the video is about, in a sentence or two (for custom scenes)."),
|
|
23722
23807
|
images: array(string2()).max(8).optional().describe("Pictures for the layout, full paths on this computer."),
|
|
23723
23808
|
emphasis: array(string2()).optional(),
|
|
23724
23809
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word.")
|
|
@@ -23729,7 +23814,7 @@ ${m.text}`;
|
|
|
23729
23814
|
const r = await planReel(input);
|
|
23730
23815
|
return { content: [...note ? [{ type: "text", text: note }] : [], { type: "text", text: `${r.text}
|
|
23731
23816
|
|
|
23732
|
-
${r.next}` }, { type: "image", data:
|
|
23817
|
+
${r.next}` }, { type: "image", data: readFileSync10(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
|
|
23733
23818
|
} catch (e) {
|
|
23734
23819
|
return failure2(e);
|
|
23735
23820
|
}
|
|
@@ -23740,10 +23825,11 @@ ${r.next}` }, { type: "image", data: readFileSync9(r.storyboard).toString("base6
|
|
|
23740
23825
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
23741
23826
|
layout: string2().default("full-frame"),
|
|
23742
23827
|
caption: string2().default("two-tier"),
|
|
23743
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(
|
|
23828
|
+
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(15).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."),
|
|
23744
23829
|
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."),
|
|
23745
23830
|
emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
|
|
23746
23831
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word."),
|
|
23832
|
+
about: string2().optional().describe("What the video is about (for custom scenes)."),
|
|
23747
23833
|
draft: boolean2().default(false),
|
|
23748
23834
|
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).")
|
|
23749
23835
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepylabs/cutroom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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": {
|