@keepylabs/cutroom 0.9.0 → 0.10.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 +258 -182
- 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 readdirSync2, readFileSync as readFileSync8, renameSync as renameSync4, rmSync as rmSync5, statSync, 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.0";
|
|
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,70 @@ 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, readFileSync as readFileSync7, 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
|
+
async function waitScene(id, wait = (ms) => new Promise((r) => setTimeout(r, ms)), deadline = Date.now() + 7 * 6e4) {
|
|
22398
|
+
while (Date.now() < deadline) {
|
|
22399
|
+
await wait(8e3);
|
|
22400
|
+
const r = await call("scene_status", { id });
|
|
22401
|
+
if (r.status === "done" && r.scene) return r.scene;
|
|
22402
|
+
if (r.status === "failed") throw new Error(r.error ?? "The scene writer couldn't make this scene.");
|
|
22403
|
+
}
|
|
22404
|
+
throw new Error("The scene writer took too long. Try plan_reel again in a minute.");
|
|
22405
|
+
}
|
|
22406
|
+
async function ensureCustom(work, slots, ctx) {
|
|
22407
|
+
mkdirSync6(dir(work), { recursive: true });
|
|
22408
|
+
let made = 0;
|
|
22409
|
+
const out = await Promise.all(slots.map(async (s) => {
|
|
22410
|
+
const dur = customDur(s);
|
|
22411
|
+
const have = s.scene ? cardOf(work, s.scene) : null;
|
|
22412
|
+
if (have && existsSync6(join8(dir(work), `${have.id}.html`)) && !s.redo && Math.abs(have.start - s.start) < 0.05 && Math.abs(have.dur - dur) < 0.05 && same(have.request, s.request) && have.layout === ctx.layout)
|
|
22413
|
+
return { ...s, dur, scene: have.id, title: have.title, shows: have.shows, redo: void 0 };
|
|
22414
|
+
const { id } = await call("scene_start", {
|
|
22415
|
+
start: s.start,
|
|
22416
|
+
dur,
|
|
22417
|
+
words: ctx.words,
|
|
22418
|
+
layout: ctx.layout,
|
|
22419
|
+
brand: ctx.brand ?? void 0,
|
|
22420
|
+
about: ctx.about,
|
|
22421
|
+
request: s.request,
|
|
22422
|
+
job: s.job,
|
|
22423
|
+
...have ? { previous: { shows: have.shows } } : {}
|
|
22424
|
+
});
|
|
22425
|
+
const scene = await waitScene(id);
|
|
22426
|
+
writeFileSync6(join8(dir(work), `${id}.html`), scene.html);
|
|
22427
|
+
const card = { id, start: s.start, dur, request: s.request, job: scene.job, title: scene.title, shows: scene.shows, layout: ctx.layout };
|
|
22428
|
+
writeFileSync6(join8(dir(work), `${id}.json`), JSON.stringify(card, null, 2));
|
|
22429
|
+
made++;
|
|
22430
|
+
return { ...s, dur, scene: id, title: scene.title, shows: scene.shows, redo: void 0 };
|
|
22431
|
+
}));
|
|
22432
|
+
return { slots: out, made };
|
|
22433
|
+
}
|
|
22434
|
+
|
|
22383
22435
|
// src/community/local/render.ts
|
|
22384
22436
|
var HF2 = "hyperframes@0.8.99";
|
|
22385
22437
|
var day3 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
22386
22438
|
var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "reel";
|
|
22387
22439
|
var sh = (cmd, args, cwd) => run(cmd, args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
22388
22440
|
var tail = (s) => (s ?? "").trim().split("\n").slice(-6).join("\n");
|
|
22389
|
-
var projectFile = (folder) =>
|
|
22441
|
+
var projectFile = (folder) => join9(folder, ".cutroom", "project.json");
|
|
22390
22442
|
var resolveProject = (p) => {
|
|
22391
22443
|
const folder = resolve3(p.replace(/^~/, homedir3()));
|
|
22392
|
-
const file = [folder,
|
|
22444
|
+
const file = [folder, join9(studio.videos(), p), join9(HOME(), "renders", p)].map(projectFile).find((f) => existsSync7(f)) ?? null;
|
|
22393
22445
|
if (!file) throw new Error(`No prepared footage at ${p}. Run prepare_footage first.`);
|
|
22394
|
-
return JSON.parse(
|
|
22446
|
+
return JSON.parse(readFileSync8(file, "utf8"));
|
|
22395
22447
|
};
|
|
22396
22448
|
function cutAndNormalize(src, out, segments) {
|
|
22397
22449
|
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 +22459,19 @@ function looksLikeSpeech(words, duration3) {
|
|
|
22407
22459
|
}
|
|
22408
22460
|
async function prepareFootage(input) {
|
|
22409
22461
|
const source = resolve3(input.video.replace(/^~/, homedir3()));
|
|
22410
|
-
if (!
|
|
22462
|
+
if (!existsSync7(source)) throw new Error(`No such file: ${source}`);
|
|
22411
22463
|
const p = probe(source);
|
|
22412
22464
|
if (!p.has_audio) throw new Error("That video has no sound. Cutroom finishing needs you talking to camera.");
|
|
22413
22465
|
if (p.duration > 180) throw new Error("Keep it under 3 minutes for one reel.");
|
|
22414
22466
|
const name = slug3(input.name ?? basename2(source, extname(source)));
|
|
22415
22467
|
ensureStudio();
|
|
22416
|
-
const folder =
|
|
22417
|
-
const work =
|
|
22418
|
-
|
|
22468
|
+
const folder = join9(studio.videos(), `${day3()}-${name}`);
|
|
22469
|
+
const work = join9(folder, ".cutroom");
|
|
22470
|
+
mkdirSync7(work, { recursive: true });
|
|
22419
22471
|
const clip = takeRaw2(source, folder);
|
|
22420
22472
|
const segments = input.cut_pauses === false ? [[0, p.duration]] : keepSegments(p.duration, parseSilences(silenceLog(clip.file), p.duration));
|
|
22421
22473
|
if (!segments.length) throw new Error("Couldn't find any speech in that video.");
|
|
22422
|
-
const footage =
|
|
22474
|
+
const footage = join9(work, "footage.mp4");
|
|
22423
22475
|
cutAndNormalize(clip.file, footage, segments);
|
|
22424
22476
|
const duration3 = probe(footage).duration;
|
|
22425
22477
|
const lang = input.language ? input.language.toLowerCase().slice(0, 5) : void 0;
|
|
@@ -22432,7 +22484,7 @@ ${e.message}`);
|
|
|
22432
22484
|
}
|
|
22433
22485
|
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
22486
|
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
|
-
|
|
22487
|
+
writeFileSync7(projectFile(folder), JSON.stringify(project, null, 2));
|
|
22436
22488
|
startVideoNotes2(folder, words.map((w) => w.w).join(" "));
|
|
22437
22489
|
setStatus2(folder, "footage ready");
|
|
22438
22490
|
const landscape = (p.width ?? 0) > (p.height ?? 0) * 1.05;
|
|
@@ -22447,15 +22499,15 @@ ${e.message}`);
|
|
|
22447
22499
|
};
|
|
22448
22500
|
}
|
|
22449
22501
|
async function ensureFonts(work) {
|
|
22450
|
-
const cache =
|
|
22502
|
+
const cache = join9(HOME(), ".kit", "fonts");
|
|
22451
22503
|
const need = ["Inter-Regular.woff2", "Inter-SemiBold.woff2", "RobotoMono-Bold.ttf", "InstrumentSerif-Italic.ttf"];
|
|
22452
|
-
if (!need.every((f) =>
|
|
22504
|
+
if (!need.every((f) => existsSync7(join9(cache, f)))) {
|
|
22453
22505
|
const { fonts } = await call("fonts");
|
|
22454
|
-
|
|
22455
|
-
for (const [f, b64] of Object.entries(fonts))
|
|
22506
|
+
mkdirSync7(cache, { recursive: true });
|
|
22507
|
+
for (const [f, b64] of Object.entries(fonts)) writeFileSync7(join9(cache, basename2(f)), Buffer.from(b64, "base64"));
|
|
22456
22508
|
}
|
|
22457
|
-
|
|
22458
|
-
for (const f of need)
|
|
22509
|
+
mkdirSync7(join9(work, "fonts"), { recursive: true });
|
|
22510
|
+
for (const f of need) writeFileSync7(join9(work, "fonts", f), readFileSync8(join9(cache, f)));
|
|
22459
22511
|
}
|
|
22460
22512
|
function applyFixes(words, fixes = []) {
|
|
22461
22513
|
return words.map((w) => {
|
|
@@ -22463,11 +22515,11 @@ function applyFixes(words, fixes = []) {
|
|
|
22463
22515
|
return f ? { ...w, w: f.to } : w;
|
|
22464
22516
|
}).filter((w) => w.w);
|
|
22465
22517
|
}
|
|
22466
|
-
var brandFile = () =>
|
|
22518
|
+
var brandFile = () => join9(studio.brand(), "brand.json");
|
|
22467
22519
|
function getBrand() {
|
|
22468
|
-
for (const f of [brandFile(),
|
|
22520
|
+
for (const f of [brandFile(), join9(HOME(), "brand.json")]) {
|
|
22469
22521
|
try {
|
|
22470
|
-
return JSON.parse(
|
|
22522
|
+
return JSON.parse(readFileSync8(f, "utf8"));
|
|
22471
22523
|
} catch {
|
|
22472
22524
|
}
|
|
22473
22525
|
}
|
|
@@ -22482,7 +22534,7 @@ async function setBrand(input) {
|
|
|
22482
22534
|
const next = { ...getBrand() ?? {}, ...Object.fromEntries(Object.entries(fields).filter(([, v]) => v)) };
|
|
22483
22535
|
await call("compose", { duration: 2, words: [], brand: next, check_only: true });
|
|
22484
22536
|
ensureStudio();
|
|
22485
|
-
|
|
22537
|
+
writeFileSync7(brandFile(), JSON.stringify(next, null, 2));
|
|
22486
22538
|
return { brand: next, saved: brandFile(), next: "Every render now uses these. Render a draft to see them." };
|
|
22487
22539
|
}
|
|
22488
22540
|
var PICTURE = /\.(jpe?g|png|webp)$/i;
|
|
@@ -22494,17 +22546,17 @@ function safeAssetName(file, i) {
|
|
|
22494
22546
|
}
|
|
22495
22547
|
function localImages(work, layoutImages, effects) {
|
|
22496
22548
|
const names = /* @__PURE__ */ new Map();
|
|
22497
|
-
const
|
|
22549
|
+
const dir2 = join9(work, "assets");
|
|
22498
22550
|
const swap = (v) => {
|
|
22499
22551
|
const raw = String(v ?? "").trim();
|
|
22500
|
-
if (/^assets\/[\w.-]+$/.test(raw) &&
|
|
22552
|
+
if (/^assets\/[\w.-]+$/.test(raw) && existsSync7(join9(work, raw))) return raw;
|
|
22501
22553
|
const file = resolve3(raw.replace(/^~(?=$|\/)/, homedir3()));
|
|
22502
22554
|
if (!names.has(file)) {
|
|
22503
|
-
if (!
|
|
22555
|
+
if (!existsSync7(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.`);
|
|
22504
22556
|
if (names.size >= 8) throw new Error("8 pictures at most per reel.");
|
|
22505
22557
|
const name = safeAssetName(file, names.size);
|
|
22506
|
-
|
|
22507
|
-
copyFileSync2(file,
|
|
22558
|
+
mkdirSync7(dir2, { recursive: true });
|
|
22559
|
+
copyFileSync2(file, join9(dir2, name));
|
|
22508
22560
|
names.set(file, `assets/${name}`);
|
|
22509
22561
|
}
|
|
22510
22562
|
return names.get(file);
|
|
@@ -22541,51 +22593,60 @@ function headTop(work, duration3) {
|
|
|
22541
22593
|
return tops.length ? tops[Math.floor(tops.length / 2)] : void 0;
|
|
22542
22594
|
}
|
|
22543
22595
|
function ensureCutout(work, duration3) {
|
|
22544
|
-
const footage =
|
|
22596
|
+
const footage = join9(work, "footage.mp4"), cut = join9(work, "cut.webm"), stamp = join9(work, "cut.json"), still = join9(work, "assets", "still.jpg");
|
|
22545
22597
|
const st = statSync(footage);
|
|
22546
22598
|
const key = { size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
22547
22599
|
let cached2 = null;
|
|
22548
22600
|
try {
|
|
22549
|
-
cached2 = JSON.parse(
|
|
22601
|
+
cached2 = JSON.parse(readFileSync8(stamp, "utf8"));
|
|
22550
22602
|
} catch {
|
|
22551
22603
|
}
|
|
22552
22604
|
let made_s = 0;
|
|
22553
|
-
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime &&
|
|
22605
|
+
if (!(cached2 && cached2.size === key.size && cached2.mtime === key.mtime && existsSync7(cut))) {
|
|
22554
22606
|
const started = Date.now();
|
|
22555
22607
|
const r = sh("npx", ["--yes", HF2, "remove-background", "footage.mp4", "-o", "cut.webm", "--json"], work);
|
|
22556
|
-
if (r.status !== 0 || !
|
|
22608
|
+
if (r.status !== 0 || !existsSync7(cut)) throw new Error(`Couldn't cut you out of the background. Run cutroom_doctor.
|
|
22557
22609
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22558
22610
|
made_s = Math.round((Date.now() - started) / 100) / 10;
|
|
22559
22611
|
cached2 = { ...key, head: headTop(work, duration3), made_s };
|
|
22560
|
-
|
|
22612
|
+
writeFileSync7(stamp, JSON.stringify(cached2));
|
|
22561
22613
|
}
|
|
22562
|
-
if (!
|
|
22563
|
-
|
|
22614
|
+
if (!existsSync7(still)) {
|
|
22615
|
+
mkdirSync7(join9(work, "assets"), { recursive: true });
|
|
22564
22616
|
sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", (duration3 / 3).toFixed(2), "-i", "footage.mp4", "-frames:v", "1", "-q:v", "3", still], work);
|
|
22565
22617
|
}
|
|
22566
22618
|
return { head: cached2.head, made_s };
|
|
22567
22619
|
}
|
|
22568
|
-
var planFile = (folder) =>
|
|
22620
|
+
var planFile = (folder) => join9(folder, ".cutroom", "plan.json");
|
|
22569
22621
|
async function composition(input) {
|
|
22570
22622
|
const pj = resolveProject(input.project);
|
|
22571
|
-
const work =
|
|
22623
|
+
const work = join9(pj.folder, ".cutroom");
|
|
22572
22624
|
const words = applyFixes(pj.words, input.fixes);
|
|
22573
|
-
const
|
|
22625
|
+
const all = input.effects ?? [];
|
|
22626
|
+
const local2 = localImages(work, input.images, all.filter((f) => !isCustom(f)));
|
|
22627
|
+
const brand = getBrand();
|
|
22628
|
+
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 };
|
|
22629
|
+
const slots = all.filter(isCustom);
|
|
22630
|
+
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 });
|
|
22631
|
+
const custom = slots.length ? await ensureCustom(work, slots, { words, layout: input.layout ?? "full-frame", brand, about: input.about }) : { slots: [], made: 0 };
|
|
22574
22632
|
const cutout = await wantsCutout(input.layout, local2.effects) ? ensureCutout(work, pj.duration) : null;
|
|
22575
|
-
const { html } = await call("compose", {
|
|
22633
|
+
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
22634
|
await ensureFonts(work);
|
|
22577
|
-
|
|
22578
|
-
|
|
22635
|
+
writeFileSync7(join9(work, "meta.json"), JSON.stringify({ id: pj.name, name: pj.name }));
|
|
22636
|
+
let k = 0;
|
|
22637
|
+
const effects = all.map((f) => isCustom(f) ? custom.slots[k++] : f);
|
|
22638
|
+
return { pj, work, html, cutout, words, effects, made: custom.made };
|
|
22579
22639
|
}
|
|
22580
22640
|
var fmtT = (t) => `${t.toFixed(1)}s`;
|
|
22581
22641
|
var SHOWN = ["word", "text", "title", "number", "label", "eyebrow", "keyword", "items", "lines", "labels", "steps", "message", "before", "after", "note"];
|
|
22582
22642
|
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
22643
|
async function planReel(input) {
|
|
22584
|
-
const { pj, work, html, cutout, words } = await composition(input);
|
|
22585
|
-
const effects = [...
|
|
22644
|
+
const { pj, work, html, cutout, words, effects: filled, made } = await composition(input);
|
|
22645
|
+
const effects = [...filled].sort((a, b) => Number(a.start) - Number(b.start));
|
|
22646
|
+
const durOf = (f) => isCustom(f) ? customDur(f) : Math.min(Math.max(Number(f.dur) || 2.5, 1.2), 5);
|
|
22586
22647
|
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));
|
|
22648
|
+
const d = durOf(f);
|
|
22649
|
+
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
22650
|
return Math.min(pj.duration - 0.1, Number(f.start) + at);
|
|
22590
22651
|
};
|
|
22591
22652
|
const plainAt = !effects.length ? Math.min(1.2, pj.duration / 2) : Number(effects[0].start) > 2 ? 1.2 : null;
|
|
@@ -22616,39 +22677,42 @@ async function planReel(input) {
|
|
|
22616
22677
|
const cur = sentences.filter((x) => x.t <= from).slice(-1);
|
|
22617
22678
|
return cur.length ? `(\u2026${cur[0].text.join(" ")})` : "";
|
|
22618
22679
|
};
|
|
22619
|
-
const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) +
|
|
22680
|
+
const ends = effects.map((f, k) => Math.min(effects[k + 1] ? Number(effects[k + 1].start) : pj.duration, Number(f.start) + durOf(f)));
|
|
22620
22681
|
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 }] : [],
|
|
22682
|
+
...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
22683
|
...effects.map((f, k) => {
|
|
22684
|
+
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
22685
|
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) };
|
|
22686
|
+
return { t: Number(f.start), end: ends[k], what: `${name}${fxLine(f) ? `: ${fxLine(f)}` : ""}`, tag: name, at: peak(f), custom: false };
|
|
22625
22687
|
})
|
|
22626
22688
|
];
|
|
22627
|
-
const shots =
|
|
22689
|
+
const shots = join9(work, "storyboard");
|
|
22628
22690
|
rmSync5(shots, { recursive: true, force: true });
|
|
22629
|
-
|
|
22691
|
+
writeFileSync7(join9(work, "index.html"), html);
|
|
22630
22692
|
let r;
|
|
22631
22693
|
try {
|
|
22632
22694
|
r = sh("npx", ["--yes", HF2, "snapshot", "--at", moments.map((m) => m.at.toFixed(2)).join(","), "--no-end", "--describe", "false", "-o", "storyboard", "."], work);
|
|
22633
22695
|
} finally {
|
|
22634
|
-
rmSync5(
|
|
22696
|
+
rmSync5(join9(work, "index.html"), { force: true });
|
|
22635
22697
|
}
|
|
22636
|
-
const pngs =
|
|
22698
|
+
const pngs = existsSync7(shots) ? readdirSync2(shots).filter((f) => f.endsWith(".png")).sort() : [];
|
|
22637
22699
|
if (r.status !== 0 || pngs.length < moments.length) throw new Error(`Couldn't make the storyboard. Run cutroom_doctor.
|
|
22638
22700
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22639
|
-
const board =
|
|
22701
|
+
const board = join9(pj.folder, "storyboard.jpg");
|
|
22640
22702
|
const cols = Math.min(4, moments.length);
|
|
22641
22703
|
const label = (s) => s.replace(/[\\':%,]/g, " ").slice(0, 42);
|
|
22642
|
-
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i",
|
|
22643
|
-
const
|
|
22704
|
+
const inputs = pngs.slice(0, moments.length).flatMap((f) => ["-i", join9(shots, f)]);
|
|
22705
|
+
const mark = getBrand()?.highlight?.replace(/^#?/, "#") ?? "#8B8EF5";
|
|
22706
|
+
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`;
|
|
22707
|
+
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
22708
|
const layoutXY = moments.map((_, k) => `${k % cols * 270}_${Math.floor(k / cols) * 540}`).join("|");
|
|
22645
22709
|
const stack2 = moments.length === 1 ? `[c0]copy[out]` : `${moments.map((_, k) => `[c${k}]`).join("")}xstack=inputs=${moments.length}:layout=${layoutXY}:fill=#0A0A0A[out]`;
|
|
22646
22710
|
const t = sh("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", `${cells};${stack2}`, "-map", "[out]", "-frames:v", "1", "-q:v", "3", board]);
|
|
22647
22711
|
if (t.status !== 0) throw new Error(`Couldn't tile the storyboard.
|
|
22648
22712
|
${tail(t.stderr)}`);
|
|
22649
22713
|
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
|
-
|
|
22714
|
+
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 };
|
|
22715
|
+
writeFileSync7(planFile(pj.folder), JSON.stringify(plan, null, 2));
|
|
22652
22716
|
const text = [
|
|
22653
22717
|
`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
22718
|
"",
|
|
@@ -22659,27 +22723,29 @@ ${tail(t.stderr)}`);
|
|
|
22659
22723
|
`Storyboard: ${board}`
|
|
22660
22724
|
].join("\n");
|
|
22661
22725
|
setStatus2(pj.folder, "plan ready (waiting for your go)", text.replace(board, "storyboard.jpg"));
|
|
22662
|
-
|
|
22726
|
+
const nCustom = effects.filter(isCustom).length;
|
|
22727
|
+
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, plan_reel again with that slot's request (e.g. request:'a phone with the feed'); to go back to a menu effect, put that effect in the slot instead.` : "";
|
|
22728
|
+
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 new choice (keep the custom slots as plan_reel returned them, with their scene ids, so unchanged ones are reused). On a go, render_reel with plan:true and draft:true.` };
|
|
22663
22729
|
}
|
|
22664
22730
|
async function renderReel(input) {
|
|
22665
22731
|
if (input.plan) {
|
|
22666
22732
|
const folder = resolveProject(input.project).folder;
|
|
22667
|
-
if (!
|
|
22668
|
-
input = { ...JSON.parse(
|
|
22733
|
+
if (!existsSync7(planFile(folder))) throw new Error("No plan yet for this project. Make one with plan_reel first.");
|
|
22734
|
+
input = { ...JSON.parse(readFileSync8(planFile(folder), "utf8")), project: input.project, draft: input.draft };
|
|
22669
22735
|
}
|
|
22670
22736
|
const { pj, work, html, cutout } = await composition(input);
|
|
22671
|
-
|
|
22672
|
-
const out =
|
|
22673
|
-
const tmp =
|
|
22674
|
-
|
|
22737
|
+
mkdirSync7(join9(pj.folder, "renders"), { recursive: true });
|
|
22738
|
+
const out = join9(pj.folder, "renders", `${pj.name}${input.draft ? "-draft" : ""}.mp4`);
|
|
22739
|
+
const tmp = join9(work, "render.mp4");
|
|
22740
|
+
writeFileSync7(join9(work, "index.html"), html);
|
|
22675
22741
|
const started = Date.now();
|
|
22676
22742
|
let r;
|
|
22677
22743
|
try {
|
|
22678
22744
|
r = sh("npx", ["--yes", HF2, "render", "-q", input.draft ? "draft" : "delivery", "-o", "render.mp4", "."], work);
|
|
22679
22745
|
} finally {
|
|
22680
|
-
rmSync5(
|
|
22746
|
+
rmSync5(join9(work, "index.html"), { force: true });
|
|
22681
22747
|
}
|
|
22682
|
-
if (r.status !== 0 || !
|
|
22748
|
+
if (r.status !== 0 || !existsSync7(tmp)) throw new Error(`Render failed. Run cutroom_doctor.
|
|
22683
22749
|
${tail(r.stderr) || tail(r.stdout)}`);
|
|
22684
22750
|
renameSync4(tmp, out);
|
|
22685
22751
|
const render_s = Math.round((Date.now() - started) / 100) / 10;
|
|
@@ -22690,10 +22756,10 @@ ${tail(r.stderr) || tail(r.stdout)}`);
|
|
|
22690
22756
|
}
|
|
22691
22757
|
async function qaVideo(input) {
|
|
22692
22758
|
const file = resolve3(input.video.replace(/^~/, homedir3()));
|
|
22693
|
-
if (!
|
|
22759
|
+
if (!existsSync7(file)) throw new Error(`No such file: ${file}`);
|
|
22694
22760
|
const tech = technicalChecks(file);
|
|
22695
|
-
const
|
|
22696
|
-
const { frames } = sampleFrames(file,
|
|
22761
|
+
const dir2 = mkdtempSync(join9(tmpdir(), "cutroom-qa-"));
|
|
22762
|
+
const { frames } = sampleFrames(file, dir2, tech.duration, Math.max(1, Math.round(tech.duration / 20 * 10) / 10), 20);
|
|
22697
22763
|
let flags = [];
|
|
22698
22764
|
let judged = 0, judge_error = null;
|
|
22699
22765
|
try {
|
|
@@ -22703,7 +22769,7 @@ async function qaVideo(input) {
|
|
|
22703
22769
|
} catch (e) {
|
|
22704
22770
|
judge_error = e.message;
|
|
22705
22771
|
}
|
|
22706
|
-
rmSync5(
|
|
22772
|
+
rmSync5(dir2, { recursive: true, force: true });
|
|
22707
22773
|
const look = flags.map((f) => `${f.t.toFixed(1)}s: ${f.text} (${Math.round(f.p_yes * 100)}%)`);
|
|
22708
22774
|
const passed = !tech.issues.length && !flags.length && !judge_error;
|
|
22709
22775
|
const report = {
|
|
@@ -22733,18 +22799,24 @@ var MEMBER_CAROUSEL = false;
|
|
|
22733
22799
|
var METHODS = {
|
|
22734
22800
|
hooks: {
|
|
22735
22801
|
title: "Video hooks",
|
|
22736
|
-
text: `Write hooks for a short video (Reel, Short, TikTok) that
|
|
22802
|
+
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
22803
|
|
|
22738
|
-
1.
|
|
22739
|
-
2.
|
|
22740
|
-
|
|
22741
|
-
|
|
22742
|
-
|
|
22743
|
-
4.
|
|
22744
|
-
|
|
22745
|
-
|
|
22804
|
+
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.
|
|
22805
|
+
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.
|
|
22806
|
+
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.
|
|
22807
|
+
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).
|
|
22808
|
+
Angles: shocking number, contrarian belief, personal confession, result transformation, industry death, secret system, time compression, cost of inaction, insider leak, story in motion.
|
|
22809
|
+
4. Rules:
|
|
22810
|
+
- Topic obvious in line one, under 15 words. Pause there: could a friend say what the video is about?
|
|
22811
|
+
- About the viewer (their problem, want or belief), not the user, their tool, team or routine. The user's story comes later, as proof.
|
|
22812
|
+
- Line two is proof: results for others beat their own results beat effort.
|
|
22813
|
+
- 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].
|
|
22814
|
+
- Definitive, one breath, contractions, no em dashes, no clich\xE9s, no view promises.
|
|
22815
|
+
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.
|
|
22816
|
+
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?
|
|
22817
|
+
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
22818
|
|
|
22747
|
-
Output: top 3
|
|
22819
|
+
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
22820
|
},
|
|
22749
22821
|
script: {
|
|
22750
22822
|
title: "Short script (8 lines)",
|
|
@@ -22753,15 +22825,15 @@ Output: top 3 with their angle, the winner, the second sentence, and any number
|
|
|
22753
22825
|
1. Thesis: "This video will prove that ___." One video, one idea.
|
|
22754
22826
|
2. Payoff first: "By the end, viewers understand ___." This is the ending; write it before any hook.
|
|
22755
22827
|
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 (
|
|
22828
|
+
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.
|
|
22829
|
+
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
22830
|
6. Question test after every line: what question does it create? No question and no useful answer: delete it.
|
|
22759
22831
|
7. Cut pass: remove repeats, long intros, generic claims, extra CTAs. Aloud pass: most sentences under 12 words, contractions, 70 to 100 words.
|
|
22760
22832
|
8. Send the script to the score_script tool. Below its pass mark, revise the weakest two lines once and score again.
|
|
22761
22833
|
|
|
22762
|
-
Rules: definitive statements, no em dashes, name the outcome not the tool, one CTA at most, every number provable and
|
|
22834
|
+
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
22835
|
|
|
22764
|
-
Output: thesis, payoff, spine, chosen hook, the numbered script with word count, the score, and numbers to verify before filming.`
|
|
22836
|
+
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
22837
|
},
|
|
22766
22838
|
caption: {
|
|
22767
22839
|
title: "Post caption and comment CTA",
|
|
@@ -22780,15 +22852,16 @@ Output: the caption ready to paste, and the keyword with what gets sent.`
|
|
|
22780
22852
|
|
|
22781
22853
|
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
22854
|
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.
|
|
22855
|
+
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.
|
|
22856
|
+
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.
|
|
22857
|
+
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.
|
|
22858
|
+
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
22859
|
- 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
22860
|
- 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
|
-
|
|
22861
|
+
7. On a go: render_reel with plan:true and draft:true. Tell them where the file is.
|
|
22862
|
+
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.
|
|
22863
|
+
9. They approve the draft: render_reel with plan:true (no draft) for the final. After changes: plan_reel, then render again.
|
|
22864
|
+
10. Offer the post caption next (get_method('caption')).`
|
|
22792
22865
|
},
|
|
22793
22866
|
edit_plan: {
|
|
22794
22867
|
title: "Planning the edit scene by scene",
|
|
@@ -22812,9 +22885,10 @@ Output: the caption ready to paste, and the keyword with what gets sent.`
|
|
|
22812
22885
|
- pointing at something on screen: Freeze and label or Doodle
|
|
22813
22886
|
- the brand or style: Brand kit card
|
|
22814
22887
|
- 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).
|
|
22888
|
+
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
22889
|
4. Do not repeat the same effect in two scenes in a row.
|
|
22817
22890
|
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.
|
|
22891
|
+
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
22892
|
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
22893
|
7. Show the plan as a table, plain words only, never effect codes:
|
|
22820
22894
|
| Time | What you say | What shows on screen |
|
|
@@ -22986,15 +23060,15 @@ var PROMPTS = [
|
|
|
22986
23060
|
].filter((p) => MEMBER_CAROUSEL || p.name !== "copy-carousel");
|
|
22987
23061
|
|
|
22988
23062
|
// src/community/local/carousel.ts
|
|
22989
|
-
import { existsSync as
|
|
23063
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
22990
23064
|
import { homedir as homedir5 } from "node:os";
|
|
22991
|
-
import { isAbsolute as isAbsolute3, join as
|
|
23065
|
+
import { isAbsolute as isAbsolute3, join as join11, resolve as resolve5 } from "node:path";
|
|
22992
23066
|
|
|
22993
23067
|
// src/carousel.ts
|
|
22994
23068
|
import { execFileSync as execFileSync2, spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
|
|
22995
|
-
import { copyFileSync as copyFileSync3, existsSync as
|
|
23069
|
+
import { copyFileSync as copyFileSync3, existsSync as existsSync8, mkdirSync as mkdirSync8, readdirSync as readdirSync3, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
|
|
22996
23070
|
import { homedir as homedir4 } from "node:os";
|
|
22997
|
-
import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as
|
|
23071
|
+
import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join10, resolve as resolve4 } from "node:path";
|
|
22998
23072
|
|
|
22999
23073
|
// src/scrapecreators.ts
|
|
23000
23074
|
import { createWriteStream as createWriteStream2 } from "node:fs";
|
|
@@ -23043,18 +23117,18 @@ function paletteFromRgb(px, k = 6) {
|
|
|
23043
23117
|
function tileSheet(files, out, cell = 324) {
|
|
23044
23118
|
if (!files.length) return null;
|
|
23045
23119
|
const tmp = `${out}.parts`;
|
|
23046
|
-
|
|
23120
|
+
mkdirSync8(tmp, { recursive: true });
|
|
23047
23121
|
try {
|
|
23048
23122
|
const h = Math.round(cell * 1.25 / 2) * 2;
|
|
23049
23123
|
const parts = files.map((f, i) => {
|
|
23050
|
-
const part =
|
|
23124
|
+
const part = join10(tmp, `${String(i).padStart(3, "0")}.jpg`);
|
|
23051
23125
|
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
23126
|
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
23127
|
if (labelled.status !== 0) execFileSync2("ffmpeg", ["-v", "error", "-y", "-i", f, "-vf", fit, part]);
|
|
23054
23128
|
return part;
|
|
23055
23129
|
});
|
|
23056
|
-
const list =
|
|
23057
|
-
|
|
23130
|
+
const list = join10(tmp, "list.txt");
|
|
23131
|
+
writeFileSync8(list, parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
|
|
23058
23132
|
const cols = Math.min(5, parts.length), rows = Math.ceil(parts.length / cols);
|
|
23059
23133
|
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
23134
|
return out;
|
|
@@ -23065,30 +23139,30 @@ function tileSheet(files, out, cell = 324) {
|
|
|
23065
23139
|
}
|
|
23066
23140
|
}
|
|
23067
23141
|
var IMAGE_EXT = /\.(jpe?g|png|webp|heic)$/i;
|
|
23068
|
-
async function saveCarousel(meta2,
|
|
23069
|
-
const slidesDir =
|
|
23070
|
-
|
|
23142
|
+
async function saveCarousel(meta2, dir2, opts = {}) {
|
|
23143
|
+
const slidesDir = join10(dir2, "slides");
|
|
23144
|
+
mkdirSync8(slidesDir, { recursive: true });
|
|
23071
23145
|
const files = [];
|
|
23072
23146
|
if (meta2) {
|
|
23073
23147
|
for (const [i, img] of meta2.images.entries()) {
|
|
23074
|
-
const target =
|
|
23075
|
-
if (!
|
|
23148
|
+
const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}.jpg`);
|
|
23149
|
+
if (!existsSync8(target)) await download2(img.url, target, opts.fetchImpl);
|
|
23076
23150
|
files.push(target);
|
|
23077
23151
|
}
|
|
23078
23152
|
} else if (opts.folder) {
|
|
23079
23153
|
const names = readdirSync3(opts.folder).filter((n) => IMAGE_EXT.test(n)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
|
|
23080
23154
|
if (!names.length) throw new Error(`No images in ${opts.folder} (jpg, png, webp).`);
|
|
23081
23155
|
for (const [i, n] of names.entries()) {
|
|
23082
|
-
const target =
|
|
23083
|
-
copyFileSync3(
|
|
23156
|
+
const target = join10(slidesDir, `${String(i + 1).padStart(2, "0")}${extname2(n).toLowerCase()}`);
|
|
23157
|
+
copyFileSync3(join10(opts.folder, n), target);
|
|
23084
23158
|
files.push(target);
|
|
23085
23159
|
}
|
|
23086
23160
|
}
|
|
23087
23161
|
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)
|
|
23162
|
+
const sheet = tileSheet(files, join10(dir2, "sheet.jpg"));
|
|
23163
|
+
if (meta2?.caption) writeFileSync8(join10(dir2, "caption.txt"), meta2.caption);
|
|
23090
23164
|
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
|
-
|
|
23165
|
+
writeFileSync8(join10(dir2, "meta.json"), JSON.stringify({ ...info, slides }, null, 2));
|
|
23092
23166
|
const md = [
|
|
23093
23167
|
"# Carousel breakdown",
|
|
23094
23168
|
"",
|
|
@@ -23105,8 +23179,8 @@ async function saveCarousel(meta2, dir, opts = {}) {
|
|
|
23105
23179
|
meta2?.caption ?? "(none)",
|
|
23106
23180
|
""
|
|
23107
23181
|
].join("\n");
|
|
23108
|
-
|
|
23109
|
-
return { folder:
|
|
23182
|
+
writeFileSync8(join10(dir2, "breakdown.md"), md);
|
|
23183
|
+
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
23184
|
}
|
|
23111
23185
|
function imageSize(file) {
|
|
23112
23186
|
const r = spawnSync4("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file], { encoding: "utf8" });
|
|
@@ -23203,8 +23277,8 @@ function resolveStyle(spec, opts = {}) {
|
|
|
23203
23277
|
return mergeStyle({ ...BASE, name: ref }, preset.style);
|
|
23204
23278
|
}
|
|
23205
23279
|
const file = isAbsolute2(ref) ? ref : resolve4(opts.baseDir ?? ".", ref.replace(/^~/, homedir4()));
|
|
23206
|
-
if (!
|
|
23207
|
-
const j = JSON.parse(
|
|
23280
|
+
if (!existsSync8(file)) throw new Error(`No style called ${ref} (presets: ${Object.keys(STYLE_PRESETS).join(", ")}; or a style.json path).`);
|
|
23281
|
+
const j = JSON.parse(readFileSync9(file, "utf8"));
|
|
23208
23282
|
return j.preset || j.path ? resolveStyle(j, { ...opts, baseDir: dirname2(file) }) : mergeStyle({ ...BASE, name: basename3(file, ".json") }, j);
|
|
23209
23283
|
};
|
|
23210
23284
|
if (typeof spec === "string") return load(spec);
|
|
@@ -23440,12 +23514,12 @@ function findChrome(env = process.env) {
|
|
|
23440
23514
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
23441
23515
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
23442
23516
|
].filter(Boolean);
|
|
23443
|
-
for (const c of cands) if (
|
|
23444
|
-
const pw =
|
|
23445
|
-
if (
|
|
23517
|
+
for (const c of cands) if (existsSync8(c)) return c;
|
|
23518
|
+
const pw = join10(homedir4(), "Library", "Caches", "ms-playwright");
|
|
23519
|
+
if (existsSync8(pw)) {
|
|
23446
23520
|
for (const d of readdirSync3(pw).filter((n) => /^chromium-\d+$/.test(n)).sort().reverse()) {
|
|
23447
23521
|
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 (
|
|
23522
|
+
if (existsSync8(join10(pw, d, sub))) return join10(pw, d, sub);
|
|
23449
23523
|
}
|
|
23450
23524
|
}
|
|
23451
23525
|
}
|
|
@@ -23477,9 +23551,9 @@ function contrast(a, b) {
|
|
|
23477
23551
|
return Math.round((x + 0.05) / (y + 0.05) * 100) / 100;
|
|
23478
23552
|
}
|
|
23479
23553
|
async function renderDeck(folder, opts = {}) {
|
|
23480
|
-
const deckFile =
|
|
23481
|
-
if (!
|
|
23482
|
-
const deck = validateDeck(JSON.parse(
|
|
23554
|
+
const deckFile = join10(folder, "deck.json");
|
|
23555
|
+
if (!existsSync8(deckFile)) throw new Error(`No deck.json in ${folder}.`);
|
|
23556
|
+
const deck = validateDeck(JSON.parse(readFileSync9(deckFile, "utf8")));
|
|
23483
23557
|
const bin = findChrome();
|
|
23484
23558
|
if (!bin) throw new Error("Rendering slides needs Google Chrome (or Chromium) on this computer. Install Chrome, or set CHROME_PATH to it.");
|
|
23485
23559
|
let style = resolveStyle(deck.style, { baseDir: folder, internal: opts.internal });
|
|
@@ -23488,24 +23562,24 @@ async function renderDeck(folder, opts = {}) {
|
|
|
23488
23562
|
style = applyBrand(style, brand);
|
|
23489
23563
|
const total = deck.slides.length;
|
|
23490
23564
|
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])
|
|
23565
|
+
const renders = join10(folder, "renders"), html = join10(folder, "html"), history = join10(renders, "history");
|
|
23566
|
+
for (const d of [renders, html]) mkdirSync8(d, { recursive: true });
|
|
23493
23567
|
const queue = [...targets];
|
|
23494
23568
|
const results = [];
|
|
23495
23569
|
const worker = async () => {
|
|
23496
23570
|
for (let n = queue.shift(); n !== void 0; n = queue.shift()) {
|
|
23497
23571
|
const name = String(n).padStart(2, "0");
|
|
23498
|
-
const page2 =
|
|
23499
|
-
|
|
23500
|
-
if (
|
|
23501
|
-
|
|
23572
|
+
const page2 = join10(html, `${name}.html`), png = join10(renders, `${name}.png`);
|
|
23573
|
+
writeFileSync8(page2, slideHtml(deck.slides[n - 1], style, n, total, { baseDir: folder, handle: deck.handle }));
|
|
23574
|
+
if (existsSync8(png)) {
|
|
23575
|
+
mkdirSync8(history, { recursive: true });
|
|
23502
23576
|
let v = 1;
|
|
23503
|
-
while (
|
|
23504
|
-
copyFileSync3(png,
|
|
23577
|
+
while (existsSync8(join10(history, `${name}-v${v}.png`))) v++;
|
|
23578
|
+
copyFileSync3(png, join10(history, `${name}-v${v}.png`));
|
|
23505
23579
|
}
|
|
23506
23580
|
const [W, H] = style.size;
|
|
23507
23581
|
const shot = await chrome(bin, [`--window-size=${W},${H}`, `--screenshot=${png}`, fileUrl(page2)]);
|
|
23508
|
-
if (!
|
|
23582
|
+
if (!existsSync8(png) || shot.code !== 0) throw new Error(`Chrome could not render slide ${n}.`);
|
|
23509
23583
|
const dom = await chrome(bin, [`--window-size=${W},${H}`, "--dump-dom", fileUrl(page2)]);
|
|
23510
23584
|
let qa = {};
|
|
23511
23585
|
try {
|
|
@@ -23519,15 +23593,15 @@ async function renderDeck(folder, opts = {}) {
|
|
|
23519
23593
|
...qa.shrunk >= 25 ? [`text shrunk ${qa.shrunk}% to fit: consider fewer words`] : [],
|
|
23520
23594
|
...qa.missing_fonts?.length ? [`font not loaded (fallback used): ${qa.missing_fonts.join(", ")}`] : [],
|
|
23521
23595
|
...ratio !== void 0 && ratio < 4.5 ? [`low contrast ${ratio}:1 (text on background)`] : [],
|
|
23522
|
-
...s.image && !/^(https?:|data:)/.test(s.image.src) && !
|
|
23596
|
+
...s.image && !/^(https?:|data:)/.test(s.image.src) && !existsSync8(resolve4(folder, s.image.src.replace(/^~/, homedir4()))) ? [`image missing: ${s.image.src}`] : []
|
|
23523
23597
|
];
|
|
23524
23598
|
results.push({ n, png, shrunk: qa.shrunk, overflow: qa.overflow, missing_fonts: qa.missing_fonts, contrast: ratio, flags });
|
|
23525
23599
|
}
|
|
23526
23600
|
};
|
|
23527
23601
|
await Promise.all(Array.from({ length: Math.min(opts.concurrency ?? 4, targets.length) }, worker));
|
|
23528
23602
|
results.sort((a, b) => a.n - b.n);
|
|
23529
|
-
const all = deck.slides.map((_, i) =>
|
|
23530
|
-
const preview = tileSheet(all,
|
|
23603
|
+
const all = deck.slides.map((_, i) => join10(renders, `${String(i + 1).padStart(2, "0")}.png`)).filter(existsSync8);
|
|
23604
|
+
const preview = tileSheet(all, join10(folder, "preview.jpg"), 360);
|
|
23531
23605
|
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
23606
|
}
|
|
23533
23607
|
function applyEdits(deck, edits) {
|
|
@@ -23547,21 +23621,21 @@ function applyEdits(deck, edits) {
|
|
|
23547
23621
|
}
|
|
23548
23622
|
async function renderCarousel(input, opts = {}) {
|
|
23549
23623
|
const folder = resolve4(input.folder.replace(/^~/, homedir4()));
|
|
23550
|
-
|
|
23551
|
-
const file =
|
|
23624
|
+
mkdirSync8(folder, { recursive: true });
|
|
23625
|
+
const file = join10(folder, "deck.json");
|
|
23552
23626
|
let only = input.only;
|
|
23553
23627
|
if (input.deck) {
|
|
23554
|
-
|
|
23628
|
+
writeFileSync8(file, JSON.stringify(validateDeck(input.deck), null, 2));
|
|
23555
23629
|
} else {
|
|
23556
|
-
if (!
|
|
23557
|
-
let deck = validateDeck(JSON.parse(
|
|
23630
|
+
if (!existsSync8(file)) throw new Error(`No deck.json in ${folder}: pass deck first.`);
|
|
23631
|
+
let deck = validateDeck(JSON.parse(readFileSync9(file, "utf8")));
|
|
23558
23632
|
if (input.style !== void 0) deck.style = input.style;
|
|
23559
23633
|
if (input.edits?.length) {
|
|
23560
23634
|
const r = applyEdits(deck, input.edits);
|
|
23561
23635
|
deck = r.deck;
|
|
23562
23636
|
if (!only?.length && input.style === void 0) only = r.touched;
|
|
23563
23637
|
}
|
|
23564
|
-
|
|
23638
|
+
writeFileSync8(file, JSON.stringify(deck, null, 2));
|
|
23565
23639
|
}
|
|
23566
23640
|
return renderDeck(folder, { only, internal: opts.internal, brand: opts.brand });
|
|
23567
23641
|
}
|
|
@@ -23571,28 +23645,28 @@ var home = (p) => resolve5(p.replace(/^~/, homedir5()));
|
|
|
23571
23645
|
async function breakdownCarousel(input) {
|
|
23572
23646
|
ensureStudio();
|
|
23573
23647
|
const src = input.source.trim();
|
|
23574
|
-
const base =
|
|
23648
|
+
const base = join11(studio.references(), "carousels");
|
|
23575
23649
|
const next = "Open sheet.jpg and every slide, then follow get_method('copy_carousel').";
|
|
23576
23650
|
if (/^https?:\/\//i.test(src)) {
|
|
23577
23651
|
const { meta: meta2 } = await call("carousel", { url: src }).catch((e) => {
|
|
23578
23652
|
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
23653
|
});
|
|
23580
|
-
const
|
|
23581
|
-
return { ...await saveCarousel(meta2,
|
|
23654
|
+
const dir2 = join11(base, `${day()}-${slug(meta2.owner ?? meta2.platform)}-${meta2.code ?? "post"}`);
|
|
23655
|
+
return { ...await saveCarousel(meta2, dir2), next };
|
|
23582
23656
|
}
|
|
23583
23657
|
const folder = home(src);
|
|
23584
|
-
if (!
|
|
23585
|
-
return { ...await saveCarousel(null,
|
|
23658
|
+
if (!existsSync9(folder)) throw new Error(`No such folder: ${folder}`);
|
|
23659
|
+
return { ...await saveCarousel(null, join11(base, `${day()}-${slug(folder.split(/[\\/]/).pop() ?? "carousel")}`), { folder }), next };
|
|
23586
23660
|
}
|
|
23587
23661
|
function renderCarouselLocal(input) {
|
|
23588
23662
|
ensureStudio();
|
|
23589
|
-
const root =
|
|
23590
|
-
const folder = input.folder ? isAbsolute3(input.folder) || input.folder.startsWith("~") ? home(input.folder) :
|
|
23663
|
+
const root = join11(HOME(), "carousels");
|
|
23664
|
+
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
23665
|
return renderCarousel({ folder, deck: input.deck, edits: input.edits, style: input.style, only: input.only }, { brand: (ref) => ref === "mine" ? brandFromJson(getBrand()) : null });
|
|
23592
23666
|
}
|
|
23593
23667
|
|
|
23594
23668
|
// src/community/local/server.ts
|
|
23595
|
-
process.env.PATH = (WIN ? [process.env.PATH,
|
|
23669
|
+
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
23670
|
useTools();
|
|
23597
23671
|
var result = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
23598
23672
|
var failure2 = (error2) => ({ isError: true, content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }] });
|
|
@@ -23651,7 +23725,7 @@ await serveStdio(() => {
|
|
|
23651
23725
|
${m.text}`;
|
|
23652
23726
|
}));
|
|
23653
23727
|
server.registerTool("score_hooks", {
|
|
23654
|
-
description: "Score and rank 1 to 20
|
|
23728
|
+
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
23729
|
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
23730
|
}, async ({ hooks, audience }) => safe(() => call("score", { kind: "hook", texts: hooks, audience })));
|
|
23657
23731
|
server.registerTool("score_script", {
|
|
@@ -23713,12 +23787,13 @@ ${m.text}`;
|
|
|
23713
23787
|
})
|
|
23714
23788
|
}, async (input) => local(() => prepareFootage(input)));
|
|
23715
23789
|
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.",
|
|
23790
|
+
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
23791
|
inputSchema: object({
|
|
23718
23792
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
23719
23793
|
layout: string2().default("full-frame"),
|
|
23720
23794
|
caption: string2().default("two-tier"),
|
|
23721
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(
|
|
23795
|
+
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)}. plan_reel returns each custom slot with its scene id: pass it back unchanged to reuse the 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."),
|
|
23796
|
+
about: string2().optional().describe("What the video is about, in a sentence or two (for custom scenes)."),
|
|
23722
23797
|
images: array(string2()).max(8).optional().describe("Pictures for the layout, full paths on this computer."),
|
|
23723
23798
|
emphasis: array(string2()).optional(),
|
|
23724
23799
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word.")
|
|
@@ -23729,7 +23804,7 @@ ${m.text}`;
|
|
|
23729
23804
|
const r = await planReel(input);
|
|
23730
23805
|
return { content: [...note ? [{ type: "text", text: note }] : [], { type: "text", text: `${r.text}
|
|
23731
23806
|
|
|
23732
|
-
${r.next}` }, { type: "image", data:
|
|
23807
|
+
${r.next}` }, { type: "image", data: readFileSync10(r.storyboard).toString("base64"), mimeType: "image/jpeg" }] };
|
|
23733
23808
|
} catch (e) {
|
|
23734
23809
|
return failure2(e);
|
|
23735
23810
|
}
|
|
@@ -23740,10 +23815,11 @@ ${r.next}` }, { type: "image", data: readFileSync9(r.storyboard).toString("base6
|
|
|
23740
23815
|
project: string2().min(1).describe("The project folder returned by prepare_footage."),
|
|
23741
23816
|
layout: string2().default("full-frame"),
|
|
23742
23817
|
caption: string2().default("two-tier"),
|
|
23743
|
-
effects: array(object({ slug: string2(), start: number2().min(0), dur: number2().min(1.2).max(
|
|
23818
|
+
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
23819
|
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
23820
|
emphasis: array(string2()).optional().describe("Words to highlight in captions (otherwise numbers, then the longest word of each phrase)."),
|
|
23746
23821
|
fixes: array(object({ from: string2(), to: string2() })).optional().describe("Transcript corrections; to:'' drops the word."),
|
|
23822
|
+
about: string2().optional().describe("What the video is about (for custom scenes)."),
|
|
23747
23823
|
draft: boolean2().default(false),
|
|
23748
23824
|
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
23825
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepylabs/cutroom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": {
|