weftens 0.0.1 → 0.1.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/README.md +178 -12
- package/agent-cores/amazon-ppc/package.json +17 -0
- package/agent-cores/amazon-ppc/src/cli.mjs +66 -0
- package/agent-cores/amazon-ppc/src/optimize.mjs +272 -0
- package/agent-cores/amazon-ppc/src/parse.mjs +153 -0
- package/agent-cores/amazon-ppc/test/fixtures/search-term-report.csv +8 -0
- package/agent-cores/amazon-ppc/test/fixtures/search-term-report.json +5 -0
- package/agent-cores/amazon-ppc/test/optimize.test.mjs +161 -0
- package/agent-cores/content-ops/package.json +17 -0
- package/agent-cores/content-ops/src/cli.mjs +51 -0
- package/agent-cores/content-ops/src/inventory.mjs +343 -0
- package/agent-cores/content-ops/src/parse.mjs +121 -0
- package/agent-cores/content-ops/test/fixtures/cms-export.csv +13 -0
- package/agent-cores/content-ops/test/fixtures/cms-export.json +31 -0
- package/agent-cores/content-ops/test/inventory.test.mjs +177 -0
- package/agent-cores/meta-ads/package.json +16 -0
- package/agent-cores/meta-ads/src/analyze.mjs +166 -0
- package/agent-cores/meta-ads/src/cli.mjs +39 -0
- package/agent-cores/meta-ads/src/parse.mjs +123 -0
- package/agent-cores/meta-ads/test/analyze.test.js +72 -0
- package/agent-cores/meta-ads/test/fixtures/meta-ads.csv +6 -0
- package/agent-cores/paid-search/package.json +17 -0
- package/agent-cores/paid-search/src/analyze.mjs +303 -0
- package/agent-cores/paid-search/src/cli.mjs +57 -0
- package/agent-cores/paid-search/src/parse.mjs +158 -0
- package/agent-cores/paid-search/test/analyze.test.mjs +120 -0
- package/agent-cores/paid-search/test/fixtures/campaigns.csv +7 -0
- package/agent-cores/paid-search/test/fixtures/sample.json +9 -0
- package/agent-cores/paid-search/test/fixtures/search-terms.csv +11 -0
- package/agent-cores/paid-search/test/parse.test.mjs +56 -0
- package/agent-cores/seo-content/package.json +17 -0
- package/agent-cores/seo-content/src/cli.mjs +41 -0
- package/agent-cores/seo-content/src/parse.mjs +141 -0
- package/agent-cores/seo-content/src/strategy.mjs +266 -0
- package/agent-cores/seo-content/test/fixtures/gsc-sample.csv +11 -0
- package/agent-cores/seo-content/test/parse.test.mjs +59 -0
- package/agent-cores/seo-content/test/strategy.test.mjs +108 -0
- package/agent-cores/seo-technical/package.json +17 -0
- package/agent-cores/seo-technical/src/audit.mjs +233 -0
- package/agent-cores/seo-technical/src/cli.mjs +48 -0
- package/agent-cores/seo-technical/src/fetch.mjs +51 -0
- package/agent-cores/seo-technical/test/audit.test.mjs +73 -0
- package/agent-cores/seo-technical/test/fixtures/sample.html +22 -0
- package/agent-cores/supply-verify/package.json +17 -0
- package/agent-cores/supply-verify/src/cli.mjs +44 -0
- package/agent-cores/supply-verify/src/verify.mjs +303 -0
- package/agent-cores/supply-verify/test/fixtures/placements.csv +8 -0
- package/agent-cores/supply-verify/test/fixtures/placements.json +4 -0
- package/agent-cores/supply-verify/test/verify.test.mjs +148 -0
- package/agents/agent2060.md +99 -0
- package/agents/amazon-ppc.md +42 -0
- package/agents/claims-content.md +35 -0
- package/agents/content-ops.md +46 -0
- package/agents/content-strategy.md +13 -0
- package/agents/disclosure-qc.md +13 -0
- package/agents/feed-optimizer.md +13 -0
- package/agents/growth-analyst.md +24 -0
- package/agents/hilbert.md +55 -0
- package/agents/ideator.md +13 -0
- package/agents/lippmann.md +68 -0
- package/agents/paid-search.md +35 -0
- package/agents/pio.md +34 -0
- package/agents/researcher.md +13 -0
- package/agents/richy.md +72 -0
- package/agents/script-editor.md +13 -0
- package/agents/seo-content.md +57 -0
- package/agents/seo-technical.md +53 -0
- package/agents/showrunner.md +13 -0
- package/agents/social-voice.md +38 -0
- package/agents/supply-verify.md +38 -0
- package/agents/video-editor.md +13 -0
- package/agents/writer.md +45 -0
- package/bin/weftens.js +234 -0
- package/content-org/.preflight/telemetry.jsonl +6 -0
- package/content-org/briefs/BRIEFS.md +47 -0
- package/content-org/config.mjs +78 -0
- package/content-org/cost.mjs +36 -0
- package/content-org/lead.js +64 -0
- package/content-org/metrics.mjs +166 -0
- package/content-org/orchestrator.mjs +246 -0
- package/content-org/package.json +14 -0
- package/content-org/producer.mjs +59 -0
- package/content-org/publish.mjs +34 -0
- package/content-org/review.mjs +32 -0
- package/content-org/studio/README.md +27 -0
- package/content-org/studio/assemble.sh +48 -0
- package/content-org/test.mjs +110 -0
- package/index.js +32 -0
- package/package.json +49 -10
- package/providers.json.example +9 -0
- package/src/agent-router.js +124 -0
- package/src/agent.js +123 -0
- package/src/format.js +66 -0
- package/src/invoke.js +129 -0
- package/src/pio-client.js +142 -0
- package/src/provider.js +81 -0
- package/src/registry.js +160 -0
- package/src/richy-client.js +216 -0
- package/src/router.js +34 -0
- package/src/synthesize.js +111 -0
- package/src/weftens.js +61 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// content-org — deterministic metrics module for the Growth/Analyst seat (#14).
|
|
2
|
+
// Parses a published-piece metrics file (CSV or JSON array), computes per-piece engagement
|
|
3
|
+
// rate, per-angle/format/platform rollups (joined to published/log.jsonl where slugs match),
|
|
4
|
+
// top/bottom performers, and a dataSufficiency verdict. Zero-dependency, no model calls —
|
|
5
|
+
// the growth seat gets this SUMMARY, never raw rows. Tolerant of missing columns: computes
|
|
6
|
+
// what it can and lists what it couldn't in `couldNotCompute`.
|
|
7
|
+
|
|
8
|
+
// Sufficiency boundaries: none = 0 rows; thin = < 10 pieces OR < 14-day span (or unusable dates).
|
|
9
|
+
export const THIN_MIN_PIECES = 10;
|
|
10
|
+
export const THIN_MIN_SPAN_DAYS = 14;
|
|
11
|
+
|
|
12
|
+
// Column aliases — normalize whatever header the platform export used (compared with
|
|
13
|
+
// non-alphanumerics stripped, lowercase: "Piece ID" / "piece_id" / "pieceId" all match).
|
|
14
|
+
const ALIASES = {
|
|
15
|
+
id: ["id", "piece", "pieceid", "slug", "postid"],
|
|
16
|
+
title: ["title", "name"],
|
|
17
|
+
platform: ["platform", "channel", "network"],
|
|
18
|
+
date: ["date", "published", "publishedat", "postdate", "publishdate"],
|
|
19
|
+
angle: ["angle"],
|
|
20
|
+
format: ["format"],
|
|
21
|
+
views: ["views", "plays", "videoviews", "impressions"],
|
|
22
|
+
reach: ["reach", "accountsreached"],
|
|
23
|
+
likes: ["likes", "reactions"],
|
|
24
|
+
comments: ["comments"],
|
|
25
|
+
shares: ["shares", "reposts"],
|
|
26
|
+
saves: ["saves", "bookmarks"],
|
|
27
|
+
watchTime: ["watchtime", "avgwatchtime", "watchtimesec"],
|
|
28
|
+
};
|
|
29
|
+
const NUMERIC = new Set(["views", "reach", "likes", "comments", "shares", "saves", "watchTime"]);
|
|
30
|
+
const INTERACTIONS = ["likes", "comments", "shares", "saves"];
|
|
31
|
+
|
|
32
|
+
function canonical(header) {
|
|
33
|
+
const h = String(header).trim().toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
34
|
+
for (const [field, aliases] of Object.entries(ALIASES)) if (aliases.includes(h)) return field;
|
|
35
|
+
return null; // unknown column — ignored
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function coerce(rec) {
|
|
39
|
+
for (const k of Object.keys(rec)) {
|
|
40
|
+
if (NUMERIC.has(k)) {
|
|
41
|
+
const n = Number(String(rec[k]).replace(/,/g, ""));
|
|
42
|
+
if (Number.isNaN(n)) delete rec[k]; else rec[k] = n;
|
|
43
|
+
} else rec[k] = String(rec[k]);
|
|
44
|
+
}
|
|
45
|
+
return rec;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeRecord(raw) {
|
|
49
|
+
const rec = {};
|
|
50
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
51
|
+
const field = canonical(k);
|
|
52
|
+
if (field && v !== null && v !== undefined && v !== "") rec[field] = v;
|
|
53
|
+
}
|
|
54
|
+
return coerce(rec);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Minimal CSV line split with quoted-field support ("a, b" stays one cell, "" escapes a quote).
|
|
58
|
+
function splitCsvLine(line) {
|
|
59
|
+
const cells = []; let cur = "", inQ = false;
|
|
60
|
+
for (let i = 0; i < line.length; i++) {
|
|
61
|
+
const c = line[i];
|
|
62
|
+
if (inQ) {
|
|
63
|
+
if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; }
|
|
64
|
+
else cur += c;
|
|
65
|
+
} else if (c === '"') inQ = true;
|
|
66
|
+
else if (c === ",") { cells.push(cur); cur = ""; }
|
|
67
|
+
else cur += c;
|
|
68
|
+
}
|
|
69
|
+
cells.push(cur);
|
|
70
|
+
return cells.map((s) => s.trim());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fieldsOf = (rows) => [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
|
74
|
+
const round = (n) => (n === null || n === undefined ? null : Math.round(n * 10000) / 10000);
|
|
75
|
+
|
|
76
|
+
// --- parse: CSV or JSON array -> normalized rows ---------------------------------------
|
|
77
|
+
export function parseMetrics(text) {
|
|
78
|
+
const trimmed = String(text).trim();
|
|
79
|
+
if (!trimmed) return { rows: [], fields: [] };
|
|
80
|
+
if (trimmed.startsWith("[")) {
|
|
81
|
+
const rows = JSON.parse(trimmed).map(normalizeRecord);
|
|
82
|
+
return { rows, fields: fieldsOf(rows) };
|
|
83
|
+
}
|
|
84
|
+
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
85
|
+
const header = splitCsvLine(lines[0]).map(canonical);
|
|
86
|
+
const rows = lines.slice(1).map((line) => {
|
|
87
|
+
const cells = splitCsvLine(line);
|
|
88
|
+
const rec = {};
|
|
89
|
+
header.forEach((field, i) => { if (field && cells[i] !== undefined && cells[i] !== "") rec[field] = cells[i]; });
|
|
90
|
+
return coerce(rec);
|
|
91
|
+
});
|
|
92
|
+
return { rows, fields: fieldsOf(rows) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- engagement rate: interactions / (views, else reach); null when it can't be computed
|
|
96
|
+
export function engagementRate(row) {
|
|
97
|
+
const denom = typeof row.views === "number" ? row.views : row.reach;
|
|
98
|
+
if (typeof denom !== "number" || denom <= 0) return null;
|
|
99
|
+
const present = INTERACTIONS.filter((k) => typeof row[k] === "number");
|
|
100
|
+
if (!present.length) return null;
|
|
101
|
+
return present.reduce((s, k) => s + row[k], 0) / denom;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- rollup: group rows by a field (angle/format/platform), avg engagement, sorted best-first
|
|
105
|
+
export function rollup(rows, key) {
|
|
106
|
+
const groups = new Map();
|
|
107
|
+
for (const r of rows) {
|
|
108
|
+
if (!r[key]) continue;
|
|
109
|
+
const g = groups.get(r[key]) || { [key]: r[key], pieces: 0, views: 0, ers: [] };
|
|
110
|
+
g.pieces++;
|
|
111
|
+
if (typeof r.views === "number") g.views += r.views;
|
|
112
|
+
const er = engagementRate(r);
|
|
113
|
+
if (er !== null) g.ers.push(er);
|
|
114
|
+
groups.set(r[key], g);
|
|
115
|
+
}
|
|
116
|
+
return [...groups.values()]
|
|
117
|
+
.map(({ ers, ...g }) => ({ ...g, avgEngagementRate: ers.length ? round(ers.reduce((a, b) => a + b, 0) / ers.length) : null }))
|
|
118
|
+
.sort((a, b) => (b.avgEngagementRate ?? -1) - (a.avgEngagementRate ?? -1));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- dataSufficiency: "none" | "thin" | "adequate" + plain-language note ----------------
|
|
122
|
+
export function sufficiency(rows) {
|
|
123
|
+
if (!rows.length) {
|
|
124
|
+
return { verdict: "none", note: `0 metric rows — nothing to read yet. No trend, no recommendation. Revisit once ~${THIN_MIN_PIECES} pieces have published over ${THIN_MIN_SPAN_DAYS}+ days.` };
|
|
125
|
+
}
|
|
126
|
+
const dates = rows.map((r) => Date.parse(r.date)).filter((t) => !Number.isNaN(t));
|
|
127
|
+
const spanDays = dates.length >= 2 ? Math.round((Math.max(...dates) - Math.min(...dates)) / 86400000) : null;
|
|
128
|
+
const reasons = [];
|
|
129
|
+
if (rows.length < THIN_MIN_PIECES) reasons.push(`only ${rows.length} piece(s) (want ${THIN_MIN_PIECES}+)`);
|
|
130
|
+
if (spanDays === null) reasons.push("no usable dates, so the time span is unknown");
|
|
131
|
+
else if (spanDays < THIN_MIN_SPAN_DAYS) reasons.push(`only a ${spanDays}-day span (want ${THIN_MIN_SPAN_DAYS}+)`);
|
|
132
|
+
if (reasons.length) {
|
|
133
|
+
return { verdict: "thin", note: `Data is thin: ${reasons.join("; ")}. Read directionally only — one strong piece is not a strategy; do not restack the board on this.` };
|
|
134
|
+
}
|
|
135
|
+
return { verdict: "adequate", note: `${rows.length} pieces over ${spanDays} days — enough to read angles and formats with normal care.` };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- summarize: the one entry point the orchestrator calls ------------------------------
|
|
139
|
+
// input: raw file text (CSV/JSON) or pre-parsed rows. published: entries from published/log.jsonl,
|
|
140
|
+
// joined by slug to fill format/client/tier on rows that lack them.
|
|
141
|
+
export function summarize(input, { published = [] } = {}) {
|
|
142
|
+
const { rows, fields } = typeof input === "string" ? parseMetrics(input) : { rows: input, fields: fieldsOf(input) };
|
|
143
|
+
const bySlug = new Map(published.filter((p) => p.slug).map((p) => [String(p.slug).toLowerCase(), p]));
|
|
144
|
+
for (const r of rows) {
|
|
145
|
+
const p = r.id && bySlug.get(String(r.id).toLowerCase());
|
|
146
|
+
if (p) for (const k of ["format", "client", "tier"]) if (r[k] === undefined && p[k] != null) r[k] = p[k];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const couldNotCompute = [];
|
|
150
|
+
const pieces = rows.map((r) => ({ ...r, engagementRate: round(engagementRate(r)) }));
|
|
151
|
+
const noEr = pieces.filter((p) => p.engagementRate === null).length;
|
|
152
|
+
if (noEr) couldNotCompute.push(`engagementRate for ${noEr}/${pieces.length} piece(s) — missing views/reach or all interaction columns`);
|
|
153
|
+
|
|
154
|
+
const rollups = {};
|
|
155
|
+
for (const [name, key] of [["byAngle", "angle"], ["byFormat", "format"], ["byPlatform", "platform"]]) {
|
|
156
|
+
const g = rollup(rows, key);
|
|
157
|
+
if (g.length) rollups[name] = g;
|
|
158
|
+
else couldNotCompute.push(`${name} rollup — no '${key}' on any row (and no published/log.jsonl join filled it)`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const ranked = pieces.filter((p) => p.engagementRate !== null).sort((a, b) => b.engagementRate - a.engagementRate);
|
|
162
|
+
const top = ranked.slice(0, 3);
|
|
163
|
+
const bottom = ranked.length > 3 ? ranked.slice(-3).reverse() : []; // worst first; empty when it would just repeat top
|
|
164
|
+
|
|
165
|
+
return { dataSufficiency: sufficiency(rows), pieces, rollups, top, bottom, fieldsPresent: fields, couldNotCompute };
|
|
166
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// content-org — local orchestrator (the "prep half": API-only text seats).
|
|
2
|
+
// Showrunner → Strategy → per-piece [Ideator → Researcher → Script → Editor → Feed → QC]
|
|
3
|
+
// Output: ready-to-assemble packet per piece in inbox/<date>/<slug>.json → Kayla reviews,
|
|
4
|
+
// generates clips into clips/<slug>/, then producer.mjs assembles.
|
|
5
|
+
//
|
|
6
|
+
// Run (full pipeline — "together"):
|
|
7
|
+
// node orchestrator.mjs <format> <count> [--client=acme] [--tier=growth] [--dry-run]
|
|
8
|
+
// e.g. node orchestrator.mjs scam-reveal 2 --client=acme --tier=growth
|
|
9
|
+
// --dry-run uses canned data (NO API key needed) to verify the pipeline end-to-end.
|
|
10
|
+
//
|
|
11
|
+
// Run (single seat — "separately"):
|
|
12
|
+
// node orchestrator.mjs --seat <key> --input <file-or-json-or-text> [--out <file>] [--dry-run]
|
|
13
|
+
// e.g. node orchestrator.mjs --seat ideator --input briefs/angle.json
|
|
14
|
+
// node orchestrator.mjs --seat qc --input '{"script":"..."}' --dry-run
|
|
15
|
+
// node orchestrator.mjs --seat growth --input examples/metrics-sample.csv --dry-run
|
|
16
|
+
// Seat keys: showrunner, strategy, ideator, research, script, editor, cut, feed, qc, growth.
|
|
17
|
+
// (growth runs metrics.mjs deterministically FIRST; the model sees the computed summary, never raw rows)
|
|
18
|
+
// Output: the seat's schema-shaped JSON to stdout (or --out <file>).
|
|
19
|
+
//
|
|
20
|
+
// Model tiers come from config.mjs (Haiku routine / Sonnet script+editor). Producer
|
|
21
|
+
// (clip-gen + voice + FFmpeg) is a separate step — see producer.mjs.
|
|
22
|
+
|
|
23
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { MODELS, SEAT_MODEL, RISK } from "./config.mjs";
|
|
27
|
+
import { summarize } from "./metrics.mjs";
|
|
28
|
+
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
// Flags: --k=v always works; for value-taking flags, `--k v` (space-separated) also works.
|
|
31
|
+
const VALUE_FLAGS = new Set(["seat", "input", "out", "client", "tier"]);
|
|
32
|
+
const flags = {};
|
|
33
|
+
const positional = [];
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const a = argv[i];
|
|
36
|
+
if (!a.startsWith("--")) { positional.push(a); continue; }
|
|
37
|
+
const [k, v] = a.replace(/^--/, "").split(/=(.*)/s);
|
|
38
|
+
if (v !== undefined) flags[k] = v;
|
|
39
|
+
else if (VALUE_FLAGS.has(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) flags[k] = argv[++i];
|
|
40
|
+
else flags[k] = true;
|
|
41
|
+
}
|
|
42
|
+
const format = positional[0];
|
|
43
|
+
const count = Number(positional[1] || "1");
|
|
44
|
+
const DRY = !!flags["dry-run"];
|
|
45
|
+
|
|
46
|
+
// SDK is loaded lazily so --dry-run needs no dependencies installed.
|
|
47
|
+
let _client = null;
|
|
48
|
+
async function getClient() {
|
|
49
|
+
if (!_client) {
|
|
50
|
+
const { default: Anthropic } = await import("@anthropic-ai/sdk"); // reads ANTHROPIC_API_KEY or `ant auth login`
|
|
51
|
+
_client = new Anthropic();
|
|
52
|
+
}
|
|
53
|
+
return _client;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Seats: role prompt + output JSON schema (model chosen per-seat from config) ------
|
|
57
|
+
const AUDIENCE =
|
|
58
|
+
"Primary audience: older Facebook users. Faceless, disclosed-AI-where-Kayla-chooses. " +
|
|
59
|
+
"Brand voice: 'declare what's real'. NEVER moralize disclosure — it is a per-piece choice. " +
|
|
60
|
+
"The one hard line on scam content: it is THEATRE (instant reveal, captures nothing, no real " +
|
|
61
|
+
"non-consenting person is harmed).";
|
|
62
|
+
|
|
63
|
+
const obj = (properties, required) => ({ type: "object", additionalProperties: false, properties, required });
|
|
64
|
+
const str = { type: "string" };
|
|
65
|
+
const bool = { type: "boolean" };
|
|
66
|
+
const arr = (items) => ({ type: "array", items });
|
|
67
|
+
|
|
68
|
+
const SHOWRUNNER = {
|
|
69
|
+
key: "showrunner", title: "Showrunner",
|
|
70
|
+
system: `You are the Showrunner — voice + continuity across every piece. ${AUDIENCE}`,
|
|
71
|
+
schema: obj({ voiceGuardrails: str, continuityNotes: str }, ["voiceGuardrails", "continuityNotes"]),
|
|
72
|
+
};
|
|
73
|
+
const STRATEGY = {
|
|
74
|
+
key: "strategy", title: "Content Strategy",
|
|
75
|
+
system: `You are Content Strategy. Given a format and count, produce distinct, non-repetitive angles that land with the audience. ${AUDIENCE}`,
|
|
76
|
+
schema: obj({ pieces: arr(obj({ slug: str, angle: str, why: str }, ["slug", "angle", "why"])) }, ["pieces"]),
|
|
77
|
+
};
|
|
78
|
+
const PIPELINE = [
|
|
79
|
+
{ key: "ideator", title: "Ideator",
|
|
80
|
+
system: `You are the Ideator. Turn the angle into a concept + a 3-second scroll-stopping hook + beat list. ${AUDIENCE}`,
|
|
81
|
+
schema: obj({ hook: str, concept: str, beats: arr(str) }, ["hook", "concept", "beats"]) },
|
|
82
|
+
{ key: "research", title: "Researcher",
|
|
83
|
+
system: `You are the Researcher. Source the facts/material; flag any claim that must be verified before publish. ${AUDIENCE}`,
|
|
84
|
+
schema: obj({ facts: arr(str), claimsToVerify: arr(str) }, ["facts", "claimsToVerify"]) },
|
|
85
|
+
{ key: "script", title: "Script Agent",
|
|
86
|
+
system: `You are the Script Agent. Write the full voiceover script (tight, spoken-word, older-FB cadence) + per-beat on-screen text. ${AUDIENCE}`,
|
|
87
|
+
schema: obj({ script: str, onScreenText: arr(str), voNotes: str }, ["script", "onScreenText", "voNotes"]) },
|
|
88
|
+
{ key: "editor", title: "Script Editor",
|
|
89
|
+
system: `You are the Script Editor. Tighten the script, enforce the Showrunner's voice, fact-check against research. Return the improved script and what you changed. ${AUDIENCE}`,
|
|
90
|
+
schema: obj({ script: str, changes: arr(str), voicePass: bool }, ["script", "changes", "voicePass"]) },
|
|
91
|
+
{ key: "cut", title: "Video Editor",
|
|
92
|
+
system: `You are the Video Editor. Turn the script into a concrete shot list Kayla can generate clips from: one entry per clip (5-10s each, ~4-7 clips for a ~30-45s Reel), each with a generation prompt (subject, era/setting, camera, mood — written for an AI video model), plus pacing notes and where each on-screen text lands. Number clips 01, 02… ${AUDIENCE}`,
|
|
93
|
+
schema: obj({
|
|
94
|
+
shots: arr(obj({ file: str, seconds: { type: "number" }, prompt: str, onScreenText: str }, ["file", "seconds", "prompt", "onScreenText"])),
|
|
95
|
+
pacingNotes: str,
|
|
96
|
+
}, ["shots", "pacingNotes"]) },
|
|
97
|
+
{ key: "feed", title: "Feed Optimizer",
|
|
98
|
+
system: `You are the Feed Optimizer. Nail the first 3 seconds; write the caption + hashtags + per-platform notes (Facebook, Instagram Reels). ${AUDIENCE}`,
|
|
99
|
+
schema: obj({ firstThreeSec: str, caption: str, hashtags: arr(str),
|
|
100
|
+
perPlatform: obj({ facebook: str, instagram: str }, ["facebook", "instagram"]) },
|
|
101
|
+
["firstThreeSec", "caption", "hashtags", "perPlatform"]) },
|
|
102
|
+
{ key: "qc", title: "Disclosure/QC",
|
|
103
|
+
system: `You are Disclosure/QC — the publish gate. Check: (1) every claim accurate/supported; (2) if scam-reveal or is-this-real, the THEATRE line holds (instant reveal, captures nothing, no real non-consenting person harmed); (3) recommend Kayla's per-piece disclosure call (labeled / reveal-is-the-point / neither) but DO NOT enforce always-labeling. List blockers if any check fails. ${AUDIENCE}`,
|
|
104
|
+
schema: obj({ claimsClean: bool, theatreLineOk: bool, disclosureCall: str, blockers: arr(str) },
|
|
105
|
+
["claimsClean", "theatreLineOk", "disclosureCall", "blockers"]) },
|
|
106
|
+
];
|
|
107
|
+
// Growth/Analyst (#14) — NOT in the per-piece pipeline; closes the loop after publish.
|
|
108
|
+
// Runs only via --seat growth. metrics.mjs computes the numbers deterministically first;
|
|
109
|
+
// the model reads that summary (never raw rows) and honors its dataSufficiency verdict.
|
|
110
|
+
const GROWTH = {
|
|
111
|
+
key: "growth", title: "Growth/Analyst",
|
|
112
|
+
system: `You are the Growth/Analyst. You receive a DETERMINISTIC metrics summary computed by metrics.mjs — trust its numbers, do not recompute them. Read what landed and why: attribute movement to a specific angle, hook, format, or timing, never vanity totals. One viral piece is not a strategy. Every finding must end in a concrete next-brief recommendation for Content Strategy. Copy the summary's dataSufficiency verdict/note through unchanged; if it is "none" or "thin", say so plainly, scale confidence down, and do NOT fabricate a trend. ${AUDIENCE}`,
|
|
113
|
+
schema: obj({
|
|
114
|
+
dataSufficiency: obj({ verdict: str, note: str }, ["verdict", "note"]), // verdict: "none" | "thin" | "adequate"
|
|
115
|
+
pieceReads: arr(obj({ id: str, read: str }, ["id", "read"])), // per-piece: what landed / didn't, and why
|
|
116
|
+
angleRollup: arr(obj({ angle: str, read: str }, ["angle", "read"])), // per-angle rollup read
|
|
117
|
+
recommendations: arr(str), // next-brief signal for Content Strategy
|
|
118
|
+
}, ["dataSufficiency", "pieceReads", "angleRollup", "recommendations"]),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// All 10 prompt seats, addressable by key (for --seat single-invocation and tests).
|
|
122
|
+
export const SEATS = Object.fromEntries(
|
|
123
|
+
[SHOWRUNNER, STRATEGY, ...PIPELINE, GROWTH].map((s) => [s.key, s])
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// --- dry-run: fabricate schema-shaped stub data, no API call ---------------------------
|
|
127
|
+
export function stub(schema, seatKey) {
|
|
128
|
+
if (schema.type === "string") return `[${seatKey}:sample]`;
|
|
129
|
+
if (schema.type === "boolean") return true;
|
|
130
|
+
if (schema.type === "number") return 5; // e.g. cut.shots[].seconds
|
|
131
|
+
if (schema.type === "array") return [stub(schema.items, seatKey), stub(schema.items, seatKey)];
|
|
132
|
+
if (schema.type === "object") {
|
|
133
|
+
const o = {};
|
|
134
|
+
for (const [k, v] of Object.entries(schema.properties)) o[k] = stub(v, `${seatKey}.${k}`);
|
|
135
|
+
return o;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function runSeat(seat, userContent) {
|
|
141
|
+
if (DRY) return stub(seat.schema, seat.key);
|
|
142
|
+
const model = MODELS[SEAT_MODEL[seat.key]] || MODELS.sonnet;
|
|
143
|
+
const client = await getClient();
|
|
144
|
+
const res = await client.messages.create({
|
|
145
|
+
model, max_tokens: 16000, system: seat.system,
|
|
146
|
+
output_config: { format: { type: "json_schema", schema: seat.schema } },
|
|
147
|
+
messages: [{ role: "user", content: userContent }],
|
|
148
|
+
});
|
|
149
|
+
const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
|
|
150
|
+
return JSON.parse(text);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Growth input prep: parse the metrics file text and join published/log.jsonl (if any) for context.
|
|
154
|
+
function growthSummary(raw) {
|
|
155
|
+
const logPath = join("published", "log.jsonl");
|
|
156
|
+
const published = existsSync(logPath)
|
|
157
|
+
? readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l))
|
|
158
|
+
: [];
|
|
159
|
+
return summarize(raw, { published });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- single-seat mode ("separately"): run one seat alone, JSON in -> JSON out ----------
|
|
163
|
+
async function runSingleSeat() {
|
|
164
|
+
const key = flags.seat;
|
|
165
|
+
const seat = SEATS[key];
|
|
166
|
+
if (!seat) {
|
|
167
|
+
console.error(`Unknown seat '${key}'. Seats: ${Object.keys(SEATS).join(", ")}`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
if (!flags.input || flags.input === true) {
|
|
171
|
+
console.error(`--seat ${key} needs --input <file-or-json-or-text>`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
// Input: a file path (read it), or an inline JSON/text string (use as-is).
|
|
175
|
+
let userContent = String(flags.input);
|
|
176
|
+
if (existsSync(userContent)) userContent = readFileSync(userContent, "utf8");
|
|
177
|
+
let out;
|
|
178
|
+
if (key === "growth") {
|
|
179
|
+
// Deterministic metrics module runs FIRST; the model gets the computed summary, never raw rows.
|
|
180
|
+
const summary = growthSummary(userContent);
|
|
181
|
+
console.error(`[metrics.mjs] ${summary.pieces.length} piece(s), sufficiency=${summary.dataSufficiency.verdict}`);
|
|
182
|
+
if (!DRY && summary.dataSufficiency.verdict === "none") {
|
|
183
|
+
// 0 rows: the honest read is deterministic — don't spend a model call restating it.
|
|
184
|
+
out = { dataSufficiency: summary.dataSufficiency, pieceReads: [], angleRollup: [], recommendations: [] };
|
|
185
|
+
} else {
|
|
186
|
+
out = await runSeat(seat, `Deterministic metrics summary (computed by metrics.mjs; trust these numbers):\n${JSON.stringify(summary, null, 2)}`);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
out = await runSeat(seat, userContent);
|
|
190
|
+
}
|
|
191
|
+
const json = JSON.stringify(out, null, 2);
|
|
192
|
+
if (flags.out && flags.out !== true) {
|
|
193
|
+
writeFileSync(flags.out, json);
|
|
194
|
+
console.error(`${DRY ? "[DRY-RUN] " : ""}[${seat.title}] ${SEAT_MODEL[key]} -> ${flags.out}`);
|
|
195
|
+
} else {
|
|
196
|
+
if (DRY) console.error(`[DRY-RUN] [${seat.title}] ${SEAT_MODEL[key]}`);
|
|
197
|
+
console.log(json);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function main() {
|
|
202
|
+
if (flags.seat) return runSingleSeat();
|
|
203
|
+
if (!format || !RISK[format]) {
|
|
204
|
+
console.error(`Usage: node orchestrator.mjs <format> <count> [--client=x] [--tier=y] [--dry-run]\n formats: ${Object.keys(RISK).join(", ")}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
208
|
+
const outDir = join("inbox", date);
|
|
209
|
+
mkdirSync(outDir, { recursive: true });
|
|
210
|
+
console.log(`${DRY ? "[DRY-RUN] " : ""}format=${format} count=${count} client=${flags.client || "-"} tier=${flags.tier || "-"}`);
|
|
211
|
+
|
|
212
|
+
const showrunner = await runSeat(SHOWRUNNER, `Format: ${format}. Set voice + continuity guardrails.`);
|
|
213
|
+
console.log(`[Showrunner] ${SEAT_MODEL.showrunner} ok`);
|
|
214
|
+
const board = await runSeat(STRATEGY, `Format: ${format}. Count: ${count}. Voice: ${showrunner.voiceGuardrails}. Produce ${count} distinct angles.`);
|
|
215
|
+
console.log(`[Strategy] ${SEAT_MODEL.strategy} ${board.pieces.length} angles`);
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < Math.min(count, board.pieces.length); i++) {
|
|
218
|
+
const piece = board.pieces[i];
|
|
219
|
+
const slug = DRY ? `dryrun-${i + 1}` : piece.slug;
|
|
220
|
+
console.log(`\n=== ${slug} — ${piece.angle} ===`);
|
|
221
|
+
const packet = {
|
|
222
|
+
slug, format, client: flags.client || null, tier: flags.tier || null,
|
|
223
|
+
angle: piece.angle, why: piece.why,
|
|
224
|
+
voiceGuardrails: showrunner.voiceGuardrails, continuityNotes: showrunner.continuityNotes,
|
|
225
|
+
createdAt: date,
|
|
226
|
+
};
|
|
227
|
+
let context = `Format: ${format}. Angle: ${piece.angle}. Why: ${piece.why}. Voice: ${showrunner.voiceGuardrails}.`;
|
|
228
|
+
for (const seat of PIPELINE) {
|
|
229
|
+
const out = await runSeat(seat, `${context}\n\nWork so far: ${JSON.stringify(packet)}`);
|
|
230
|
+
packet[seat.key] = out;
|
|
231
|
+
console.log(` [${seat.title}] ${SEAT_MODEL[seat.key]} ok`);
|
|
232
|
+
if (out.script) context += `\nScript: ${out.script}`;
|
|
233
|
+
}
|
|
234
|
+
const held = RISK[format] === "hold" || (packet.qc?.blockers?.length ?? 0) > 0;
|
|
235
|
+
packet.approval = held ? "hold" : "auto";
|
|
236
|
+
const file = join(outDir, `${slug}.json`);
|
|
237
|
+
writeFileSync(file, JSON.stringify(packet, null, 2));
|
|
238
|
+
console.log(` -> ${file} [${packet.approval.toUpperCase()}]`);
|
|
239
|
+
}
|
|
240
|
+
console.log(`\nDone. Packets in ${outDir}/. Next: review → generate clips → producer.mjs assembles.`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Only auto-run when executed directly (lets tests `import { SEATS }` without side effects).
|
|
244
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
245
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
|
246
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "content-org",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "AI content production org — local MVP orchestrator (text pipeline)",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"produce": "node orchestrator.mjs",
|
|
9
|
+
"test": "node test.mjs"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@anthropic-ai/sdk": "^0.68.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// content-org — Producer: turns an inbox packet + clips (+ optional voice) into a Reel.
|
|
2
|
+
// Stages clips -> runs studio/assemble.sh (FFmpeg) -> queue/<slug>/{<slug>.mp4, manifest.json}.
|
|
3
|
+
//
|
|
4
|
+
// Run: node producer.mjs <path-to-packet.json>
|
|
5
|
+
//
|
|
6
|
+
// Inputs it expects (each is its own upstream step, kept out of this file):
|
|
7
|
+
// clips/<slug>/*.mp4 — Kayla generates (flat sub) OR a clip-gen API writes them here.
|
|
8
|
+
// studio/voice/vo.mp3 — optional narration; generate with your voice step first
|
|
9
|
+
// (ElevenLabs or Kokoro) — see SERVICE_SOP.md. Absent -> silent cut.
|
|
10
|
+
//
|
|
11
|
+
// CONCURRENCY: run at most 2 producers at once (FFmpeg is the only CPU-heavy step);
|
|
12
|
+
// set FFMPEG_THREADS=3 to cap each. See CONTENT_ENGINE_TECHNICAL_PLAN.md section 5.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync, renameSync, rmSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
const packetPath = process.argv[2];
|
|
19
|
+
if (!packetPath || !existsSync(packetPath)) {
|
|
20
|
+
console.error("Usage: node producer.mjs <path-to-packet.json>");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const packet = JSON.parse(readFileSync(packetPath, "utf8"));
|
|
24
|
+
const slug = packet.slug;
|
|
25
|
+
const clipsDir = join("clips", slug);
|
|
26
|
+
const outDir = join("queue", slug);
|
|
27
|
+
mkdirSync(outDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
// Clips: expect clips/<slug>/*.mp4 (manual, or written by a clip-gen step).
|
|
30
|
+
// CLIP-GEN INTEGRATION POINT: drive a fal.ai call (Veo hero / Wan bulk) from the Video
|
|
31
|
+
// Editor's shot list, writing numbered mp4s into clipsDir. Kept manual for now.
|
|
32
|
+
const haveClips = existsSync(clipsDir) && readdirSync(clipsDir).filter((f) => f.endsWith(".mp4")).length > 0;
|
|
33
|
+
if (!haveClips) {
|
|
34
|
+
console.error("[clips] none in " + clipsDir + "/ — drop or generate clips there, then re-run.");
|
|
35
|
+
writeFileSync(join(outDir, "manifest.json"), JSON.stringify({ ...packet, status: "awaiting-clips" }, null, 2));
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
const voiceNote = existsSync("studio/voice/vo.mp3") ? "vo.mp3 present" : "no vo.mp3 (silent cut)";
|
|
39
|
+
console.log("[voice] " + voiceNote);
|
|
40
|
+
|
|
41
|
+
// Staging must hold ONLY this piece's clips — leftovers from a prior slug end up in the cut.
|
|
42
|
+
rmSync("studio/clips", { recursive: true, force: true });
|
|
43
|
+
mkdirSync("studio/clips", { recursive: true });
|
|
44
|
+
readdirSync(clipsDir).filter((f) => f.endsWith(".mp4")).sort().forEach((f) => copyFileSync(join(clipsDir, f), join("studio/clips", f)));
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
execFileSync("bash", ["studio/assemble.sh", slug], { stdio: "inherit", env: { ...process.env } });
|
|
48
|
+
const rendered = join("studio/output", slug + ".mp4");
|
|
49
|
+
if (existsSync(rendered)) renameSync(rendered, join(outDir, slug + ".mp4"));
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error("[assemble] failed: " + e.message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ready = existsSync(join(outDir, slug + ".mp4"));
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(outDir, "manifest.json"),
|
|
57
|
+
JSON.stringify({ ...packet, reel: ready ? slug + ".mp4" : null, voiceNote, status: ready ? "ready-for-approval" : "assemble-failed" }, null, 2)
|
|
58
|
+
);
|
|
59
|
+
console.log("[producer] " + slug + ": " + (ready ? "ready-for-approval" : "assemble-failed") + " (approval=" + packet.approval + ")");
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// content-org — Publish approved Reels. `node publish.mjs <slug> [<slug>...]`
|
|
2
|
+
// Moves a reviewed Reel from queue/ to a published/ append-only log. The actual post to
|
|
3
|
+
// Facebook/Instagram is a marked integration point (needs Meta Business Suite creds).
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const slugs = process.argv.slice(2);
|
|
8
|
+
if (!slugs.length) { console.error("Usage: node publish.mjs <slug> [<slug>...] (or 'auto' to publish all auto-approved)"); process.exit(1); }
|
|
9
|
+
mkdirSync("published", { recursive: true });
|
|
10
|
+
|
|
11
|
+
let list = slugs;
|
|
12
|
+
if (slugs[0] === "auto") {
|
|
13
|
+
list = readdirSync("queue").filter((s) => {
|
|
14
|
+
const mf = join("queue", s, "manifest.json");
|
|
15
|
+
if (!existsSync(mf)) return false;
|
|
16
|
+
const m = JSON.parse(readFileSync(mf, "utf8"));
|
|
17
|
+
return m.approval === "auto" && (m.qc?.blockers || []).filter(Boolean).length === 0 && m.reel;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const stampNote = "(timestamp added by publish step at runtime)";
|
|
22
|
+
for (const slug of list) {
|
|
23
|
+
const mf = join("queue", slug, "manifest.json");
|
|
24
|
+
if (!existsSync(mf)) { console.error(`skip ${slug}: no manifest`); continue; }
|
|
25
|
+
const m = JSON.parse(readFileSync(mf, "utf8"));
|
|
26
|
+
if (m.approval === "hold") { console.error(`skip ${slug}: HOLD — needs explicit approval, run with the slug to override`); }
|
|
27
|
+
|
|
28
|
+
// POSTING INTEGRATION POINT: call Meta Business Suite / Graph API here to publish
|
|
29
|
+
// queue/<slug>/<reel>.mp4 with m.feed.caption to the client's page. Left unwired.
|
|
30
|
+
const entry = { slug, client: m.client, tier: m.tier, format: m.format, reel: m.reel, caption: m.feed?.caption, note: "logged; Meta post not wired " + stampNote };
|
|
31
|
+
appendFileSync(join("published", "log.jsonl"), JSON.stringify(entry) + "\n");
|
|
32
|
+
console.log(`published (logged): ${slug} [${m.client || "-"}/${m.tier || "-"}]`);
|
|
33
|
+
}
|
|
34
|
+
console.log(`\n${list.length} logged to published/log.jsonl. Wire the Meta post at the marked integration point to go live.`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// content-org — Review queue. `node review.mjs`
|
|
2
|
+
// Lists finished Reels awaiting Kayla, showing tiered-approval status + any QC blockers.
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const Q = "queue";
|
|
7
|
+
if (!existsSync(Q)) { console.log("queue/ empty — run orchestrator.mjs then producer.mjs first."); process.exit(0); }
|
|
8
|
+
|
|
9
|
+
const rows = [];
|
|
10
|
+
for (const slug of readdirSync(Q)) {
|
|
11
|
+
const mf = join(Q, slug, "manifest.json");
|
|
12
|
+
if (!existsSync(mf)) continue;
|
|
13
|
+
const m = JSON.parse(readFileSync(mf, "utf8"));
|
|
14
|
+
rows.push({
|
|
15
|
+
slug, tier: m.tier || "-", client: m.client || "-",
|
|
16
|
+
status: m.status || "?", approval: m.approval || "?",
|
|
17
|
+
blockers: (m.qc?.blockers || []).filter(Boolean).length,
|
|
18
|
+
reel: m.reel ? "yes" : "no",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (!rows.length) { console.log("No manifests in queue/."); process.exit(0); }
|
|
22
|
+
|
|
23
|
+
console.log("slug".padEnd(22), "client".padEnd(10), "tier".padEnd(8), "approval".padEnd(9), "reel", "blockers", "status");
|
|
24
|
+
for (const r of rows) {
|
|
25
|
+
console.log(
|
|
26
|
+
r.slug.padEnd(22), String(r.client).padEnd(10), String(r.tier).padEnd(8),
|
|
27
|
+
r.approval.padEnd(9), (r.reel + " ").padEnd(4), String(r.blockers).padStart(3).padEnd(9), r.status
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const holds = rows.filter((r) => r.approval === "hold" || r.blockers > 0);
|
|
31
|
+
console.log(`\n${rows.length} in queue · ${holds.length} need your yes (hold or blocked) · rest auto-postable.`);
|
|
32
|
+
console.log("Approve → node publish.mjs <slug> [<slug>...]");
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# The Traveler — Studio (FFmpeg assembly pipeline)
|
|
2
|
+
|
|
3
|
+
**Locked stack:** Kling (clips, you) → ElevenLabs (voice, Claude) → **FFmpeg (edit, Claude)**.
|
|
4
|
+
Anti-slop = consistent character + Claude-controlled edit, never a preset.
|
|
5
|
+
|
|
6
|
+
## How it works
|
|
7
|
+
1. **You** generate clips in Kling and drop them in `clips/` — **named in order**: `01.mp4`, `02.mp4`, …
|
|
8
|
+
2. Drop the voiceover in `voice/vo.mp3` (Claude can generate this via ElevenLabs).
|
|
9
|
+
3. Drop a music bed in `music/bed.mp3` (royalty-free, e.g. Epidemic).
|
|
10
|
+
4. Captions go in `captions/captions.ass` (Claude writes these from the script).
|
|
11
|
+
5. Claude runs `assemble.sh` → finished vertical Reel lands in `output/`.
|
|
12
|
+
|
|
13
|
+
## CLIP DELIVERY RULES (critical — so it doesn't look like slop)
|
|
14
|
+
- **Silent** (no Kling auto-audio) — Claude owns the audio layer.
|
|
15
|
+
- **No baked-in captions / no auto-music / no watermark.**
|
|
16
|
+
- Vertical, highest resolution Kling allows; consistent across clips.
|
|
17
|
+
- **Same locked character reference image every clip** (the recurring face = the moat).
|
|
18
|
+
- Name strictly in play order: `01.mp4`, `02.mp4`, …
|
|
19
|
+
|
|
20
|
+
## Folders
|
|
21
|
+
- `clips/` — raw Kling footage (you)
|
|
22
|
+
- `voice/` — `vo.mp3` narrator (Claude/ElevenLabs)
|
|
23
|
+
- `music/` — `bed.mp3` (you, or Mubert)
|
|
24
|
+
- `captions/` — `captions.ass` (Claude)
|
|
25
|
+
- `output/` — finished Reels (Claude)
|
|
26
|
+
|
|
27
|
+
`assemble.sh` is v1 — tuned further once we see real Kling footage.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The Traveler — FFmpeg assembly pipeline (v1)
|
|
3
|
+
# Usage: ./assemble.sh [episode_name]
|
|
4
|
+
# Reads clips/*.mp4 (in name order) + voice/vo.mp3 + music/bed.mp3 + captions/captions.ass
|
|
5
|
+
# Outputs a vertical 1080x1920 Reel to output/<episode_name>.mp4
|
|
6
|
+
# Anti-slop layer: uniform grade + film grain + subtle vignette so heterogeneous clips read as ONE piece.
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
EP="${1:-episode}"
|
|
10
|
+
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
|
11
|
+
CLIPS="$ROOT/clips"; VOICE="$ROOT/voice/vo.mp3"; MUSIC="$ROOT/music/bed.mp3"
|
|
12
|
+
CAPS="$ROOT/captions/captions.ass"; OUT="$ROOT/output/$EP.mp4"; TMP="$ROOT/.tmp"
|
|
13
|
+
W=1080; H=1920; FPS=30
|
|
14
|
+
mkdir -p "$TMP" "$ROOT/output"
|
|
15
|
+
|
|
16
|
+
# 1) Normalize every clip to uniform res/fps/codec (reliable concat across mixed Kling outputs)
|
|
17
|
+
i=0; : > "$TMP/list.txt"
|
|
18
|
+
for f in $(ls "$CLIPS"/*.mp4 2>/dev/null | sort); do
|
|
19
|
+
i=$((i+1)); n=$(printf "%03d" "$i")
|
|
20
|
+
ffmpeg -y -i "$f" -vf "scale=$W:$H:force_original_aspect_ratio=increase,crop=$W:$H,fps=$FPS" \
|
|
21
|
+
-c:v libx264 -preset medium -crf 18 -an "$TMP/n_$n.mp4"
|
|
22
|
+
# Windows fix: write the entry relative to list.txt (concat demuxer resolves it from
|
|
23
|
+
# the list's own directory). Absolute Git-Bash paths (/c/...) break native ffmpeg.exe.
|
|
24
|
+
echo "file 'n_$n.mp4'" >> "$TMP/list.txt"
|
|
25
|
+
done
|
|
26
|
+
[ "$i" -gt 0 ] || { echo "No clips in $CLIPS (name them 01.mp4, 02.mp4 ...)"; exit 1; }
|
|
27
|
+
|
|
28
|
+
# 2) Concatenate normalized clips (video only)
|
|
29
|
+
ffmpeg -y -f concat -safe 0 -i "$TMP/list.txt" -c copy "$TMP/video.mp4"
|
|
30
|
+
|
|
31
|
+
# 3) Grade + grain + vignette (cohesion / anti-slop) and burn captions if present
|
|
32
|
+
GRADE="eq=contrast=1.06:saturation=1.05:gamma=0.98,noise=alls=8:allf=t+u,vignette=PI/5"
|
|
33
|
+
if [ -f "$CAPS" ]; then VF="$GRADE,subtitles='$CAPS'"; else VF="$GRADE"; fi
|
|
34
|
+
ffmpeg -y -i "$TMP/video.mp4" -vf "$VF" -c:v libx264 -preset medium -crf 18 -an "$TMP/graded.mp4"
|
|
35
|
+
|
|
36
|
+
# 4) Mix audio: narrator full + music bed ducked under it
|
|
37
|
+
if [ -f "$VOICE" ] && [ -f "$MUSIC" ]; then
|
|
38
|
+
ffmpeg -y -i "$TMP/graded.mp4" -i "$VOICE" -i "$MUSIC" -filter_complex \
|
|
39
|
+
"[2:a]volume=0.18[m];[1:a][m]amix=inputs=2:duration=first:dropout_transition=2[a]" \
|
|
40
|
+
-map 0:v -map "[a]" -c:v copy -c:a aac -shortest "$OUT"
|
|
41
|
+
elif [ -f "$VOICE" ]; then
|
|
42
|
+
ffmpeg -y -i "$TMP/graded.mp4" -i "$VOICE" -map 0:v -map 1:a -c:v copy -c:a aac -shortest "$OUT"
|
|
43
|
+
else
|
|
44
|
+
cp "$TMP/graded.mp4" "$OUT"; echo "(no voice/vo.mp3 — output is silent)"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
rm -rf "$TMP"
|
|
48
|
+
echo "Done -> $OUT"
|