@aicopycoders/exodus 2026.9.200 → 2026.9.1100-beta.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 +11 -0
- package/assets/docs/CLAUDE.md +3 -1
- package/assets/docs/PIPELINES.md +22 -0
- package/assets/skills/exodus/SKILL.md +3 -1
- package/assets/skills/exodus-ads/SKILL.md +165 -0
- package/assets/skills/exodus-video/SKILL.md +233 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +469 -0
- package/assets/skills/exodus-winners/SKILL.md +65 -17
- package/dist/bin/exodus.js +5 -0
- package/dist/commands/ads.d.ts +50 -0
- package/dist/commands/ads.js +414 -0
- package/dist/commands/comments.d.ts +28 -0
- package/dist/commands/comments.js +198 -0
- package/dist/commands/video.d.ts +213 -0
- package/dist/commands/video.js +1038 -0
- package/dist/commands/winners.d.ts +50 -0
- package/dist/commands/winners.js +175 -2
- package/dist/lib/meta-ads.d.ts +108 -0
- package/dist/lib/meta-ads.js +156 -0
- package/package.json +1 -1
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A plain first cut from a folder written by `exodus video pull`: the A-roll
|
|
3
|
+
// scenes in the manifest's order make the spine, each cutaway lays over the
|
|
4
|
+
// spine at the moment its cued line is spoken, the music bed goes underneath,
|
|
5
|
+
// and one MP4 comes out ready to upload.
|
|
6
|
+
//
|
|
7
|
+
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
|
|
8
|
+
//
|
|
9
|
+
// A scene with a clip contributes the clip. A clip with no audio stream gets
|
|
10
|
+
// the scene's voice track, or silence. A scene with no clip but a keyframe
|
|
11
|
+
// becomes a still for the length of its voice track, else the storyboard's
|
|
12
|
+
// planned duration, with the voice if one was delivered. --skip drops scenes.
|
|
13
|
+
//
|
|
14
|
+
// After the render it writes the whole ad's word times beside the MP4, as
|
|
15
|
+
// <out>.words.json and <out>.srt, for captioning in post.
|
|
16
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
const USAGE = "usage: first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]";
|
|
22
|
+
const WIDTH = 1080;
|
|
23
|
+
const HEIGHT = 1920;
|
|
24
|
+
const FPS = 24;
|
|
25
|
+
const norm = (label) =>
|
|
26
|
+
`[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
|
|
27
|
+
`pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
|
|
28
|
+
const stereo = "aresample=48000,aformat=channel_layouts=stereo";
|
|
29
|
+
|
|
30
|
+
/** The word rule the planner sized every line with (scout models.ts wordCount). */
|
|
31
|
+
export function wordCount(text) {
|
|
32
|
+
const trimmed = (text ?? "").trim();
|
|
33
|
+
return trimmed ? trimmed.split(/\s+/).length : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function lineWordCounts(storyboard) {
|
|
37
|
+
const counts = new Map();
|
|
38
|
+
for (const line of storyboard?.script ?? []) {
|
|
39
|
+
if (typeof line?.lineId === "string") counts.set(line.lineId, wordCount(line.text));
|
|
40
|
+
}
|
|
41
|
+
return counts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
|
|
45
|
+
|
|
46
|
+
export function scenePlans(storyboard) {
|
|
47
|
+
const plans = new Map();
|
|
48
|
+
for (const scene of storyboard?.scenes ?? []) {
|
|
49
|
+
if (typeof scene?.sceneIndex !== "number") continue;
|
|
50
|
+
plans.set(scene.sceneIndex, {
|
|
51
|
+
cutaway: CUTAWAY_KINDS.has(scene.kind),
|
|
52
|
+
lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
|
|
53
|
+
cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
|
|
54
|
+
durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return plans;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function cueOffsetOnSpine(spine, counts, cueLineId) {
|
|
61
|
+
for (const seg of spine) {
|
|
62
|
+
const idx = seg.lineIds.indexOf(cueLineId);
|
|
63
|
+
if (idx === -1) continue;
|
|
64
|
+
let precedingWords = 0;
|
|
65
|
+
for (let i = 0; i < idx; i++) precedingWords += counts.get(seg.lineIds[i]) ?? 0;
|
|
66
|
+
let totalWords = 0;
|
|
67
|
+
for (const id of seg.lineIds) totalWords += counts.get(id) ?? 0;
|
|
68
|
+
|
|
69
|
+
let within = 0;
|
|
70
|
+
let method = "proportion";
|
|
71
|
+
if (seg.words && seg.words.length > 0) {
|
|
72
|
+
within = Math.max(0, seg.words[Math.min(precedingWords, seg.words.length - 1)]?.s ?? 0);
|
|
73
|
+
method = "words";
|
|
74
|
+
} else if (totalWords > 0) {
|
|
75
|
+
within = (precedingWords / totalWords) * seg.seconds;
|
|
76
|
+
}
|
|
77
|
+
within = Math.min(within, seg.seconds);
|
|
78
|
+
return { startSec: seg.startSec + within, method, spineSceneIndex: seg.sceneIndex };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
|
|
84
|
+
const plans = scenePlans(storyboard);
|
|
85
|
+
const counts = lineWordCounts(storyboard);
|
|
86
|
+
const spine = [];
|
|
87
|
+
const cutaways = [];
|
|
88
|
+
const waiting = [];
|
|
89
|
+
const dropped = [];
|
|
90
|
+
let startSec = 0;
|
|
91
|
+
|
|
92
|
+
for (const scene of manifest?.scenes ?? []) {
|
|
93
|
+
const n = scene.sceneIndex;
|
|
94
|
+
const plan = plans.get(n) ?? { cutaway: false, lineIds: [], cueLineId: null, durationSec: null };
|
|
95
|
+
if (skip.has(n)) {
|
|
96
|
+
dropped.push({ n, reason: `scene ${n}: skipped by --skip` });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
|
|
100
|
+
|
|
101
|
+
if (plan.cutaway) {
|
|
102
|
+
if (exists(scene.clip)) {
|
|
103
|
+
waiting.push({
|
|
104
|
+
sceneIndex: n, source: "clip", file: scene.clip,
|
|
105
|
+
seconds: probe(scene.clip).seconds, cueLineId: plan.cueLineId,
|
|
106
|
+
});
|
|
107
|
+
} else if (exists(scene.keyframe)) {
|
|
108
|
+
waiting.push({
|
|
109
|
+
sceneIndex: n, source: "still", file: scene.keyframe,
|
|
110
|
+
seconds: plan.durationSec ?? 4, cueLineId: plan.cueLineId,
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (exists(scene.clip)) {
|
|
119
|
+
const clip = probe(scene.clip);
|
|
120
|
+
const voice = clip.hasAudio || !exists(scene.voice) ? null : scene.voice;
|
|
121
|
+
const flag = scene.flagged ? " (flagged)" : "";
|
|
122
|
+
const silent = clip.hasAudio ? "" : voice ? `, silent clip + ${voice}` : ", silent clip";
|
|
123
|
+
spine.push({
|
|
124
|
+
sceneIndex: n, source: "clip", file: scene.clip, seconds: clip.seconds,
|
|
125
|
+
hasAudio: clip.hasAudio, voice, startSec, lineIds: plan.lineIds,
|
|
126
|
+
words: readWords(scene.words), note: `${flag}${silent}`,
|
|
127
|
+
});
|
|
128
|
+
startSec += clip.seconds;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (exists(scene.keyframe)) {
|
|
132
|
+
const voice = exists(scene.voice) ? scene.voice : null;
|
|
133
|
+
const seconds = voice ? probe(voice).seconds : (plan.durationSec ?? 4);
|
|
134
|
+
spine.push({
|
|
135
|
+
sceneIndex: n, source: "still", file: scene.keyframe, seconds,
|
|
136
|
+
hasAudio: false, voice, startSec, lineIds: plan.lineIds,
|
|
137
|
+
words: null,
|
|
138
|
+
note: `${voice ? ` + ${voice}` : ", silent"} (${seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
|
|
139
|
+
});
|
|
140
|
+
startSec += seconds;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
dropped.push({ n, reason: `scene ${n}: ${nothing}` });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const c of waiting) {
|
|
147
|
+
const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
|
|
148
|
+
if (!cue) {
|
|
149
|
+
const why = c.cueLineId
|
|
150
|
+
? `${c.cueLineId} is spoken by no scene in the cut`
|
|
151
|
+
: "the storyboard names no cue line";
|
|
152
|
+
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${why}` });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
cutaways.push({ ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex });
|
|
156
|
+
}
|
|
157
|
+
cutaways.sort((a, b) => a.startSec - b.startSec);
|
|
158
|
+
for (let i = 0; i < cutaways.length; i++) {
|
|
159
|
+
const c = cutaways[i];
|
|
160
|
+
const next = cutaways[i + 1];
|
|
161
|
+
const end = Math.min(startSec, next ? next.startSec : Infinity);
|
|
162
|
+
if (c.startSec + c.seconds > end) {
|
|
163
|
+
c.seconds = Math.max(0, end - c.startSec);
|
|
164
|
+
c.cutShortBy = next && end === next.startSec ? `cutaway ${next.sceneIndex} starts` : "the ad ends";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
spine,
|
|
170
|
+
cutaways,
|
|
171
|
+
left: dropped.sort((a, b) => a.n - b.n).map((d) => d.reason),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sec(n) {
|
|
176
|
+
return (Number.isFinite(n) ? Math.max(0, n) : 0).toFixed(3);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
180
|
+
const inputs = [];
|
|
181
|
+
let count = 0;
|
|
182
|
+
const addInput = (...args) => {
|
|
183
|
+
inputs.push(...args);
|
|
184
|
+
return count++;
|
|
185
|
+
};
|
|
186
|
+
const filters = [];
|
|
187
|
+
const pairs = [];
|
|
188
|
+
|
|
189
|
+
for (const seg of timeline.spine) {
|
|
190
|
+
const i =
|
|
191
|
+
seg.source === "clip"
|
|
192
|
+
? addInput("-i", resolve(seg.file))
|
|
193
|
+
: addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
|
|
194
|
+
filters.push(`${norm(`${i}:v`)}[v${i}]`);
|
|
195
|
+
if (seg.hasAudio) {
|
|
196
|
+
filters.push(`[${i}:a]${stereo}[a${i}]`);
|
|
197
|
+
} else if (seg.voice) {
|
|
198
|
+
const v = addInput("-i", resolve(seg.voice));
|
|
199
|
+
filters.push(`[${v}:a]${stereo},apad,atrim=0:${seg.seconds}[a${i}]`);
|
|
200
|
+
} else {
|
|
201
|
+
filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${seg.seconds}[a${i}]`);
|
|
202
|
+
}
|
|
203
|
+
pairs.push(`[v${i}][a${i}]`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A clip whose audio ends before its video would otherwise end the whole cut
|
|
207
|
+
// there under -shortest; padding lets the video decide the length.
|
|
208
|
+
filters.push(`${pairs.join("")}concat=n=${pairs.length}:v=1:a=1[vcat][voicecat]`);
|
|
209
|
+
filters.push(`[voicecat]apad[voice]`);
|
|
210
|
+
|
|
211
|
+
let videoLabel = "vcat";
|
|
212
|
+
timeline.cutaways.forEach((c, j) => {
|
|
213
|
+
const i =
|
|
214
|
+
c.source === "clip"
|
|
215
|
+
? addInput("-i", resolve(c.file))
|
|
216
|
+
: addInput("-loop", "1", "-t", String(c.seconds), "-i", resolve(c.file));
|
|
217
|
+
const end = c.startSec + c.seconds;
|
|
218
|
+
filters.push(
|
|
219
|
+
`${norm(`${i}:v`)},trim=0:${sec(c.seconds)},setpts=PTS-STARTPTS+${sec(c.startSec)}/TB[cv${j}]`,
|
|
220
|
+
);
|
|
221
|
+
// eof_action=pass keeps the spine showing once the cutaway's frames end.
|
|
222
|
+
filters.push(
|
|
223
|
+
`[${videoLabel}][cv${j}]overlay=enable=between(t\\,${sec(c.startSec)}\\,${sec(end)}):eof_action=pass[ov${j}]`,
|
|
224
|
+
);
|
|
225
|
+
videoLabel = `ov${j}`;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
let audioLabel = "voice";
|
|
229
|
+
if (music) {
|
|
230
|
+
const m = addInput("-stream_loop", "-1", "-i", music);
|
|
231
|
+
filters.push(`[${m}:a]${stereo},volume=0.18[bed]`);
|
|
232
|
+
filters.push(`[voice][bed]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[mix]`);
|
|
233
|
+
audioLabel = "mix";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return [
|
|
237
|
+
"-y", "-hide_banner", "-loglevel", "error",
|
|
238
|
+
...inputs,
|
|
239
|
+
"-filter_complex", filters.join(";"),
|
|
240
|
+
"-map", `[${videoLabel}]`, "-map", `[${audioLabel}]`,
|
|
241
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-r", String(FPS),
|
|
242
|
+
"-c:a", "aac", "-b:a", "192k",
|
|
243
|
+
"-movflags", "+faststart", "-shortest",
|
|
244
|
+
out,
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function wordRows(timeline) {
|
|
249
|
+
const ms = (n) => Math.round(n * 1000) / 1000;
|
|
250
|
+
const rows = [];
|
|
251
|
+
for (const seg of timeline.spine) {
|
|
252
|
+
for (const w of seg.words ?? []) {
|
|
253
|
+
const text = (w.w ?? "").trim();
|
|
254
|
+
const start = seg.startSec + w.s;
|
|
255
|
+
const end = seg.startSec + w.e;
|
|
256
|
+
if (text && end > start) {
|
|
257
|
+
rows.push({ text, start: ms(start), end: ms(end), scene: seg.sceneIndex });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return rows;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const SRT_MAX_WORDS = 5;
|
|
265
|
+
const SRT_GAP_SEC = 1.0;
|
|
266
|
+
|
|
267
|
+
export function srtCues(rows) {
|
|
268
|
+
const cues = [];
|
|
269
|
+
let cue = [];
|
|
270
|
+
for (let i = 0; i < rows.length; i++) {
|
|
271
|
+
cue.push(rows[i]);
|
|
272
|
+
const next = rows[i + 1];
|
|
273
|
+
const ends =
|
|
274
|
+
!next ||
|
|
275
|
+
/[.!?,;:]$/.test(rows[i].text) ||
|
|
276
|
+
cue.length >= SRT_MAX_WORDS ||
|
|
277
|
+
next.scene !== rows[i].scene ||
|
|
278
|
+
next.start - rows[i].end > SRT_GAP_SEC;
|
|
279
|
+
if (ends) {
|
|
280
|
+
cues.push(cue);
|
|
281
|
+
cue = [];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return cues;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function srtTime(seconds) {
|
|
288
|
+
const ms = Math.max(0, Math.round(seconds * 1000));
|
|
289
|
+
const pad = (n, width) => String(n).padStart(width, "0");
|
|
290
|
+
return (
|
|
291
|
+
`${pad(Math.floor(ms / 3600000), 2)}:${pad(Math.floor(ms / 60000) % 60, 2)}:` +
|
|
292
|
+
`${pad(Math.floor(ms / 1000) % 60, 2)},${pad(ms % 1000, 3)}`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function toSrt(rows) {
|
|
297
|
+
return srtCues(rows)
|
|
298
|
+
.map((cue, i) => {
|
|
299
|
+
const text = cue.map((r) => r.text).join(" ");
|
|
300
|
+
return `${i + 1}\n${srtTime(cue[0].start)} --> ${srtTime(cue[cue.length - 1].end)}\n${text}\n`;
|
|
301
|
+
})
|
|
302
|
+
.join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function sidecarPaths(outPath) {
|
|
306
|
+
const dir = path.dirname(outPath);
|
|
307
|
+
const base = path.basename(outPath, path.extname(outPath));
|
|
308
|
+
return { words: path.join(dir, `${base}.words.json`), srt: path.join(dir, `${base}.srt`) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function inTheCut(timeline) {
|
|
312
|
+
const rows = [];
|
|
313
|
+
for (const seg of timeline.spine) {
|
|
314
|
+
const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
|
|
315
|
+
rows.push({ n: seg.sceneIndex, line: `scene ${seg.sceneIndex}: ${label}${seg.note}` });
|
|
316
|
+
}
|
|
317
|
+
for (const c of timeline.cutaways) {
|
|
318
|
+
const label = c.source === "clip" ? c.file : `still ${c.file}`;
|
|
319
|
+
const how =
|
|
320
|
+
c.method === "words"
|
|
321
|
+
? "word times"
|
|
322
|
+
: `proportion: scene ${c.spineSceneIndex} has no word times`;
|
|
323
|
+
const cut = c.cutShortBy ? `, cut short where ${c.cutShortBy}` : "";
|
|
324
|
+
rows.push({
|
|
325
|
+
n: c.sceneIndex,
|
|
326
|
+
line:
|
|
327
|
+
`cutaway ${c.sceneIndex}: ${label} at ${c.startSec.toFixed(2)}s for ${c.seconds.toFixed(1)}s, ` +
|
|
328
|
+
`cued to ${c.cueLineId} in scene ${c.spineSceneIndex} (${how})${cut}`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return rows.sort((a, b) => a.n - b.n).map((r) => r.line);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function noWordTimes(timeline) {
|
|
335
|
+
const rows = timeline.spine
|
|
336
|
+
.filter((seg) => !seg.words || seg.words.length === 0)
|
|
337
|
+
.map((seg) => `scene ${seg.sceneIndex} (no words.json)`);
|
|
338
|
+
for (const c of timeline.cutaways) rows.push(`scene ${c.sceneIndex} (cutaway)`);
|
|
339
|
+
return rows;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function fail(code, message) {
|
|
343
|
+
console.error(message);
|
|
344
|
+
process.exit(code);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parseArgs(argv) {
|
|
348
|
+
const opts = { dir: undefined, out: undefined, skip: new Set(), music: true };
|
|
349
|
+
for (let i = 0; i < argv.length; i++) {
|
|
350
|
+
const a = argv[i];
|
|
351
|
+
if (a === "--out") opts.out = argv[++i];
|
|
352
|
+
else if (a === "--skip") opts.skip = new Set((argv[++i] ?? "").split(",").filter(Boolean).map(Number));
|
|
353
|
+
else if (a === "--no-music") opts.music = false;
|
|
354
|
+
else if (a.startsWith("--")) fail(2, `unknown flag ${a}\n${USAGE}`);
|
|
355
|
+
else if (opts.dir === undefined) opts.dir = a;
|
|
356
|
+
else fail(2, `unexpected argument ${a}\n${USAGE}`);
|
|
357
|
+
}
|
|
358
|
+
if (!opts.dir) fail(2, USAGE);
|
|
359
|
+
return opts;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function main() {
|
|
363
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
364
|
+
|
|
365
|
+
for (const bin of ["ffmpeg", "ffprobe"]) {
|
|
366
|
+
if (spawnSync(bin, ["-version"]).status !== 0) {
|
|
367
|
+
fail(2, `${bin} is not installed. Install ffmpeg (brew install ffmpeg / apt install ffmpeg).`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const encoders = spawnSync("ffmpeg", ["-hide_banner", "-encoders"]).stdout?.toString() ?? "";
|
|
371
|
+
if (!/\blibx264\b/.test(encoders)) {
|
|
372
|
+
fail(2, "This ffmpeg has no libx264 encoder (H.264). Install a full ffmpeg build: brew install ffmpeg / apt install ffmpeg.");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const dir = opts.dir;
|
|
376
|
+
const manifestPath = path.join(dir, "manifest.json");
|
|
377
|
+
if (!existsSync(manifestPath)) {
|
|
378
|
+
fail(2, `${manifestPath} not found. Run: exodus video pull <runId> --out ${dir}`);
|
|
379
|
+
}
|
|
380
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
381
|
+
const out = path.resolve(opts.out ?? path.join(dir, "cut.mp4"));
|
|
382
|
+
const here = (file) => path.join(dir, file);
|
|
383
|
+
const exists = (file) => Boolean(file) && existsSync(here(file));
|
|
384
|
+
|
|
385
|
+
const readJson = (file) => {
|
|
386
|
+
if (!exists(file)) return null;
|
|
387
|
+
try {
|
|
388
|
+
return JSON.parse(readFileSync(here(file), "utf8"));
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
const probe = (file) => {
|
|
394
|
+
const raw = execFileSync("ffprobe", [
|
|
395
|
+
"-v", "error", "-show_entries", "stream=codec_type:format=duration", "-of", "json", here(file),
|
|
396
|
+
]).toString();
|
|
397
|
+
const info = JSON.parse(raw);
|
|
398
|
+
return {
|
|
399
|
+
seconds: Number(info.format?.duration ?? 0),
|
|
400
|
+
hasAudio: (info.streams ?? []).some((s) => s.codec_type === "audio"),
|
|
401
|
+
};
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const storyboard = readJson(manifest.storyboard);
|
|
405
|
+
if (!storyboard) {
|
|
406
|
+
console.error(`Warning: ${manifest.storyboard ?? "storyboard.json"} is missing or unreadable, so every scene is cut as A-roll and no cutaway is placed.`);
|
|
407
|
+
}
|
|
408
|
+
const timeline = buildTimeline({
|
|
409
|
+
manifest,
|
|
410
|
+
storyboard,
|
|
411
|
+
skip: opts.skip,
|
|
412
|
+
probe,
|
|
413
|
+
exists,
|
|
414
|
+
readWords: (file) => {
|
|
415
|
+
const rows = readJson(file);
|
|
416
|
+
return Array.isArray(rows) && rows.length > 0 ? rows : null;
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
if (timeline.spine.length === 0) {
|
|
420
|
+
fail(1, `No scene has a clip or a keyframe. Check: exodus video status ${manifest.runId}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const music = opts.music && exists(manifest.music) ? manifest.music : null;
|
|
424
|
+
const ffmpegArgs = buildFfmpegArgs(timeline, {
|
|
425
|
+
resolve: here,
|
|
426
|
+
music: music ? here(music) : null,
|
|
427
|
+
out,
|
|
428
|
+
});
|
|
429
|
+
const res = spawnSync("ffmpeg", ffmpegArgs, { stdio: "inherit" });
|
|
430
|
+
if (res.status !== 0) {
|
|
431
|
+
const quoted = ffmpegArgs.map((a) => (/[\s\[\];]/.test(a) ? `'${a}'` : a)).join(" ");
|
|
432
|
+
fail(1, `ffmpeg failed. The command it ran:\n ffmpeg ${quoted}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const rows = wordRows(timeline);
|
|
436
|
+
const sidecars = sidecarPaths(out);
|
|
437
|
+
if (rows.length > 0) {
|
|
438
|
+
writeFileSync(sidecars.words, `${JSON.stringify(rows, null, 2)}\n`);
|
|
439
|
+
writeFileSync(sidecars.srt, toSrt(rows));
|
|
440
|
+
} else {
|
|
441
|
+
rmSync(sidecars.words, { force: true });
|
|
442
|
+
rmSync(sidecars.srt, { force: true });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const duration = execFileSync("ffprobe", [
|
|
446
|
+
"-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", out,
|
|
447
|
+
]).toString().trim();
|
|
448
|
+
console.log(`Wrote ${out} (${Number(duration).toFixed(1)}s, ${WIDTH}x${HEIGHT})`);
|
|
449
|
+
console.log("In the cut:");
|
|
450
|
+
for (const line of inTheCut(timeline)) console.log(" " + line);
|
|
451
|
+
if (timeline.left.length > 0) {
|
|
452
|
+
console.log("Left out:");
|
|
453
|
+
for (const line of timeline.left) console.log(" " + line);
|
|
454
|
+
}
|
|
455
|
+
if (rows.length > 0) {
|
|
456
|
+
const scenes = [...new Set(rows.map((r) => r.scene))].join(", ");
|
|
457
|
+
console.log(
|
|
458
|
+
`Words: ${rows.length} words from scenes ${scenes} -> ${path.basename(sidecars.words)}, ${path.basename(sidecars.srt)}`,
|
|
459
|
+
);
|
|
460
|
+
} else {
|
|
461
|
+
console.log("Words: none delivered, no caption files written");
|
|
462
|
+
}
|
|
463
|
+
const silent = noWordTimes(timeline);
|
|
464
|
+
if (silent.length > 0) console.log(`No word times: ${silent.join(", ")}`);
|
|
465
|
+
console.log(`Music bed: ${music ?? "none"}`);
|
|
466
|
+
console.log(`Upload it: exodus video upload ${manifest.runId} --file ${out}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: exodus-winners
|
|
3
|
-
description: Mine the member's OWN Meta ad account for its winning ads and import them into Exodus as generative fuel.
|
|
3
|
+
description: Mine the member's OWN Meta ad account for its winning ads and import them into Exodus as generative fuel. There are two ways in and the skill checks which first: a brand whose Meta ad account is CONNECTED on the dashboard needs no Meta MCP at all — its ads, numbers, comments and winner definition are already synced into Exodus and read back with `npx @aicopycoders/exodus ads`, `comments` and `winners definition`; a brand without an integration uses the official Meta Ads MCP (their Facebook login, their machine) alongside the Exodus CLI. Either way this skill orchestrates the whole journey — pick one ad account, settle what a winner means (read the confirmed definition from the server, or run a NO-NUMBERS interview), gather the account's ads, confirm each pick visually, then push a winner-package JSON with `npx @aicopycoders/exodus winners import` and run the two-phase video gap-filler. Only invoke when the user has explicitly invoked Exodus: they said "exodus" in the request ("exodus, import my winning ads", "exodus winners", "exodus, mine my ad account for winners", "exodus, which of my own ads are winners"), named this skill or /exodus-winners, ran an `npx @aicopycoders/exodus winners` command, or the `exodus` hub skill routed here. Never claim generic asks ("what are my best ads", "pull my Meta ads", "analyze my Facebook ads") without Exodus context — in shared folders those may belong to the user's other tools; if the user did not say exodus, this skill is not for them. This is the member's OWN account's winners, NOT competitor swipes — competitor swipe mining lives on the dashboard's Swipe Mining surfaces, not here. The bare word "Genesis" without "exodus" refers to the member's own Genesis API key and personal bot recipes, NOT to Exodus.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
```operator-guide
|
|
@@ -8,6 +8,7 @@ Subcommands:
|
|
|
8
8
|
exodus winners import <file.json | -> [--dry-run] [--no-wait] [--json] Push a winner package (- reads stdin)
|
|
9
9
|
exodus winners status <importId> [--json] Re-poll an import later
|
|
10
10
|
exodus winners list [--json] Winners Exodus already holds
|
|
11
|
+
exodus winners definition [--account act_…] [--json] What THIS brand means by "a winner"
|
|
11
12
|
|
|
12
13
|
Key flags:
|
|
13
14
|
--dry-run Local schema check + server dry-run: would-create vs would-update per winner,
|
|
@@ -23,27 +24,49 @@ Facts baked into the CLI:
|
|
|
23
24
|
upgrade in place; winners absent from a re-push are untouched. It DOES re-run the
|
|
24
25
|
billed match scrape + enrichment — push updates, don't push as a retry reflex.
|
|
25
26
|
|
|
26
|
-
Meta side
|
|
27
|
-
|
|
27
|
+
Meta side — CONDITIONAL (§0), check before you plan the journey:
|
|
28
|
+
• Brand WITH a Meta integration (`exodus winners definition` answers, or `exodus ads list`
|
|
29
|
+
returns ads): NO MCP. Ads + numbers come from `exodus ads list` / `ads show`, comments from
|
|
30
|
+
`exodus comments list`, the definition from `exodus winners definition` (read-only — the
|
|
31
|
+
definition lives on the server, never in state/own-brand-winners.json).
|
|
32
|
+
• Brand WITHOUT one: the official Meta Ads MCP (https://mcp.facebook.com/ads) supplies all
|
|
33
|
+
account data, exactly as §1–§5 describe.
|
|
34
|
+
Either way this skill NEVER calls the Meta Graph API directly and NEVER calls ads_library_search.
|
|
28
35
|
```
|
|
29
36
|
|
|
30
37
|
# Winners — Mine Your Own Ad Account and Import the Champions
|
|
31
38
|
|
|
32
|
-
This is the agent-side journey for **Own-Brand Winners**: turn the member's real Meta ad account into a set of designated winners that Exodus stores as generative fuel.
|
|
39
|
+
This is the agent-side journey for **Own-Brand Winners**: turn the member's real Meta ad account into a set of designated winners that Exodus stores as generative fuel. Where the account data comes from depends on the brand (§0): a **connected** brand's ads are already synced into Exodus and read with the `exodus ads` family; an **unconnected** brand's come from the member's **own Meta Ads MCP** (their Facebook OAuth) sitting alongside the Exodus CLI.
|
|
33
40
|
|
|
34
41
|
The member is a **creative strategist, not a media buyer**. This entire journey is data *collection and preparation* — the analysis happens later, server-side, on the stored winners. So the interview **never asks a single numeric question**: no budgets, no ROAS targets, no thresholds, no "top how many?". You infer the mechanics and confirm in plain language. Make that rule impossible to break — if you catch yourself about to ask for a number, stop and infer it instead.
|
|
35
42
|
|
|
36
|
-
The arc, in order: **
|
|
43
|
+
The arc, in order: **integration check (§0) → pick one account → settle the winner definition → gather the account's ads → 30k-foot view → visual confirmation → write the package → dry-run → import → video gap-filler.** A connected brand reads the definition and the ads off the server and skips the MCP mechanics in §3 entirely; an unconnected one runs the interview and mines via the MCP. Either way, re-running re-asks nothing already settled.
|
|
37
44
|
|
|
38
|
-
## 0.
|
|
45
|
+
## 0. First: does this brand have a Meta integration?
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
There are now **two ways in**, and the first question is which one this brand is on. Ask the server, not the user:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx @aicopycoders/exodus winners definition
|
|
51
|
+
npx @aicopycoders/exodus ads list --limit 5
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**If either answers with real data — a definition, or ads — this brand has a Meta integration. Skip the MCP entirely.** The dashboard already syncs this account daily, so everything §1–§5 mines by hand is already sitting in Exodus:
|
|
55
|
+
|
|
56
|
+
- **Ads and numbers** come from `npx @aicopycoders/exodus ads list` / `ads show <adId>` (the `exodus-ads` skill has the full surface). No `ads_get_ad_entities` paging, no campaign-by-campaign loop, no 500-avoidance mechanics — none of §3 applies.
|
|
57
|
+
- **The winner definition** comes from `npx @aicopycoders/exodus winners definition`. Do **not** re-run the interview: a human already answered it on the dashboard, and asking again invites two different answers for one account.
|
|
58
|
+
- **Comments** come from `npx @aicopycoders/exodus comments list`.
|
|
59
|
+
- The Meta Ads MCP may still sit alongside for extras (a live preview image, today's numbers before tomorrow's sync). It is **not required**, and you must not ask the user to connect it just to read their own ads.
|
|
60
|
+
|
|
61
|
+
**If neither answers — no integration — this brand keeps the MCP path below, exactly as written.** Everything here reads from the **official Meta Ads MCP**: a remote MCP server at `https://mcp.facebook.com/ads`, OAuth'd in the user's browser with their own Facebook login. We don't own that flow; there is exactly one setup pointer and no troubleshooting beyond it.
|
|
41
62
|
|
|
42
63
|
**If the `ads_*` Meta tools are not present in this session, stop.** Tell the user to add `https://mcp.facebook.com/ads` as a remote MCP server and complete the browser OAuth, then come back. Do not attempt any workaround, any Graph API call, or any other connection method.
|
|
43
64
|
|
|
44
65
|
## 1. Account selection — the required first step, pinned forever
|
|
45
66
|
|
|
46
|
-
|
|
67
|
+
**Connected brand (§0):** the accounts are already known — `npx @aicopycoders/exodus ads list` names the one it used, and lists the others when there is more than one. Pick from that list with `--account`, and skip the rest of this section.
|
|
68
|
+
|
|
69
|
+
**Unconnected brand:** call `ads_get_ad_accounts`. It returns **every** account the login can touch — often a dozen-plus across several businesses. For each: `ad_account_id`, `ad_account_name`, `business_name`, `is_queryable`, `not_queryable_reason`, currency.
|
|
47
70
|
|
|
48
71
|
- Show only **queryable** accounts (`is_queryable: true`). For any flagged one, surface its `not_queryable_reason` so the user understands why it's absent — don't silently drop it.
|
|
49
72
|
- **Account names can be empty strings** — fall back to the id and business name so the list is still legible.
|
|
@@ -55,7 +78,15 @@ You are not collecting preferences here so much as confirming inferences. Four t
|
|
|
55
78
|
|
|
56
79
|
**a) Objective → metric is invisible.** Ads group by their **self-labeled result type** (within objective — §2c); each group's success metric is the account's own label — Meta hands it to you already labeled (`results: {"value": "98 (Website appointments scheduled)"}`, `cost_per_result: {"value": "$91.64 USD (Website appointments scheduled)"}` — the string lives under `.value`). **Never ask the user to pick a metric.** The account already declared it. Offer one free-text escape hatch only — *"If your definition of a winner is different from 'the ads that drove the most results,' tell me in your own words."* — and record whatever they say **verbatim** as `customDefinition`, applied agent-side. Do not turn their words into a number.
|
|
57
80
|
|
|
58
|
-
**b)
|
|
81
|
+
**b) Campaign roles are infer-then-confirm.** Propose, per campaign, what the campaign is **for** — one of exactly three roles:
|
|
82
|
+
|
|
83
|
+
- **testing** — where new creative goes to be tried. Small budgets, many near-duplicate ads, short lives.
|
|
84
|
+
- **scaling** — where proven creative goes to be spent behind. Fewer ads, bigger budgets, longer lives.
|
|
85
|
+
- **other** — retargeting, retention, brand, anything that is neither of the above.
|
|
86
|
+
|
|
87
|
+
Infer each one from the campaign objective plus the campaign/adset **names** (naming is read-only creative context; see §6). Present the whole proposed map **once** for a single confirm/adjust pass, then save it. On a re-mine, only **new** campaigns get proposed; already-confirmed ones stay put.
|
|
88
|
+
|
|
89
|
+
Why roles and not awareness levels: a testing campaign and a scaling campaign are **not comparable**, and the winner rule has to know which is which before it ranks anything — an ad that "won" a test at 30 results is not the same object as an ad carrying a scaling budget. This is also the exact question the dashboard's winner setup asks, in the same three words, so the CLI and the dashboard can never propose different winners for one account. **A brand with a Meta integration has already answered it** — read the confirmed map from `npx @aicopycoders/exodus winners definition` (§0) instead of asking again.
|
|
59
90
|
|
|
60
91
|
**c) The winner rule is fixed — volume over efficiency, all-time, no caps.** State it, don't negotiate it:
|
|
61
92
|
|
|
@@ -72,7 +103,9 @@ The defaults behind this rule (`window: "maximum"`, `resultsFloor: 10`, `contrib
|
|
|
72
103
|
|
|
73
104
|
## 3. Mining mechanics — the MCP facts that make or break this
|
|
74
105
|
|
|
75
|
-
|
|
106
|
+
**Connected brand (§0): skip this whole section.** The daily sync already did it. `npx @aicopycoders/exodus ads list --sort spend --limit 200 --json` gives you every synced ad with its copy, format, campaign/ad set, and both windows' numbers in one call — no paging, no 500s, no compact-tuple discipline to hold. Apply §2c's rule to those rows and go to §4.
|
|
107
|
+
|
|
108
|
+
**Unconnected brand:** mine on `ads_get_ad_entities`. These are hard-won live-probe facts; encode them into how you call, not into a doc you can look up later:
|
|
76
109
|
|
|
77
110
|
- **Never mine account-wide — scope every metrics pull to one campaign.** Account-wide ad-level pulls that include `results` **500 consistently** on real accounts (mixed objectives, thousands of near-duplicate test ads) — no limit, no sort tweak, and no retry unsticks them, and `results` 500s at campaign level too. Enumerate campaigns first (attributes, `objective`, and spend all work fine at campaign level), then pull ads **campaign-by-campaign** with `results` over `date_preset: "maximum"` and aggregate agent-side into result-type groups (ads carry `campaign_id`; join `objective` from the campaign pull).
|
|
78
111
|
- **Sort server-side, tally compactly — never hold full blobs, and never floor raw rows.** Metric *filtering* on `ads_get_ad_entities` consistently **500s** — never filter by a metric. Metric *sorting* works. Per campaign: sort by **results descending**, page through, and retain one **compact tuple per result-bearing ad** — ad id, ad name, campaign id, creative id, created time, results, spend — discarding zero-result rows (they can't move any sum) and every other field. **The 10-result floor does NOT apply while paging**: sub-floor instances of one post can sum to a winner (§2c), so no row is ever dropped for being small. A creative-testing account runs 100+ near-duplicate ads per campaign; compact tuples keep the whole account to a few hundred short lines, where retaining or printing whole per-campaign blobs burns the session's context before the winner rule ever runs.
|
|
@@ -104,7 +137,7 @@ The instance-to-creative collapse, the format skew, the count of winners, the sh
|
|
|
104
137
|
|
|
105
138
|
## 5. Visual confirmation — the human gate
|
|
106
139
|
|
|
107
|
-
This is a HITL gate; treat it like one. For each proposed winner, render it with **`ads_get_ad_preview`** (an actual in-context creative image)
|
|
140
|
+
This is a HITL gate; treat it like one. For each proposed winner, show the actual creative: on a **connected** brand, `npx @aicopycoders/exodus ads show <adId>` prints fetchable image/video/poster links (fetch them, don't save the URL — they expire); on an **unconnected** one, render it with **`ads_get_ad_preview`** (an actual in-context creative image). The strategist:
|
|
108
141
|
|
|
109
142
|
- **strikes** picks that don't belong,
|
|
110
143
|
- **adds** any obvious winner the rule missed,
|
|
@@ -114,7 +147,7 @@ Their edits win over the algorithm. Don't argue the rule against their eyes —
|
|
|
114
147
|
|
|
115
148
|
## 6. Naming is read-only
|
|
116
149
|
|
|
117
|
-
Campaign / adset / ad names feed two things: your **
|
|
150
|
+
Campaign / adset / ad names feed two things: your **campaign-role inference** (§2b) and the winner's `sourceNames` as creative context. That's all. **No naming-conventions guidance** — nobody is asked to rename anything, and the account's naming hygiene is not your concern.
|
|
118
151
|
|
|
119
152
|
## 7. The verdict snapshot & the winner-package JSON
|
|
120
153
|
|
|
@@ -152,7 +185,7 @@ Every winner carries a **verdict snapshot** composed to the schema below. Single
|
|
|
152
185
|
"costPerResult": "$18.20",
|
|
153
186
|
"asOf": "2026-07-14",
|
|
154
187
|
"objective": "OUTCOME_SALES",
|
|
155
|
-
"awarenessLevel": "product-aware", //
|
|
188
|
+
"awarenessLevel": "product-aware", // optional — read off the AD'S OWN COPY, or omit
|
|
156
189
|
"selectionBasis": ["overall", "top-video"], // which cut(s) it cleared
|
|
157
190
|
"strategistNote": "…" // optional, from §5
|
|
158
191
|
},
|
|
@@ -202,12 +235,25 @@ Any winner the user can't supply a file for **stays amber and visible** in `exod
|
|
|
202
235
|
|
|
203
236
|
**5. Re-push safety** (from the CLI itself): re-pushing never duplicates rows — verdict snapshots replace wholesale, gap-filled rows upgrade in place, winners absent from a re-push are left untouched. But it is not free: every push re-uploads referenced assets, re-runs enrichment, and re-fires the Scrape Creators match scrape (billed to the member) — so re-push when there's something to update, not as a retry reflex.
|
|
204
237
|
|
|
205
|
-
## 9. Definition persistence
|
|
238
|
+
## 9. Definition persistence — server first, local file only without an integration
|
|
239
|
+
|
|
240
|
+
**Which of the two applies is settled in §0**, and the two must never both hold a definition for the same account.
|
|
241
|
+
|
|
242
|
+
**Brand WITH a Meta integration — the definition lives on the server. Full stop.**
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
npx @aicopycoders/exodus winners definition # one connected account
|
|
246
|
+
npx @aicopycoders/exodus winners definition --account act_1234567890
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
That command reads the confirmed answers a human gave on the dashboard: the pinned account, the campaign-role map (§2b), the rule variant, the dials, and the last run's summary. It is **read-only, and there is no CLI write** — you do not create, patch, or mirror `state/own-brand-winners.json` for these brands, and you never re-run the §2 interview to "fill in" anything. Changes are made on the dashboard (Settings → Meta → winner setup); a second copy in a local file is exactly how the CLI and the dashboard would drift into two different answers for one account.
|
|
250
|
+
|
|
251
|
+
If it answers with a list of accounts instead of a definition, the brand has several connected — ask which one the member means and pass `--account`. If it says no definition exists yet, point them at the dashboard's winner setup; don't improvise one locally.
|
|
206
252
|
|
|
207
|
-
Persist the interview to `state/own-brand-winners.json`, keyed by **brand slug** (`state/` survives skill refreshes). It holds:
|
|
253
|
+
**Brand WITHOUT an integration — the local file, exactly as before.** Persist the interview to `state/own-brand-winners.json`, keyed by **brand slug** (`state/` survives skill refreshes). It holds:
|
|
208
254
|
|
|
209
255
|
- pinned `adAccountId` + name (§1),
|
|
210
|
-
- `
|
|
256
|
+
- `campaignRoleMap` (§2b — `testing` / `scaling` / `other` per campaign id),
|
|
211
257
|
- editable `defaults` — `{ "window": "maximum", "resultsFloor": 10, "contributionLine": 0.80 }`,
|
|
212
258
|
- `customDefinition` (the §2a escape-hatch text, verbatim),
|
|
213
259
|
- `lastMinedAt`.
|
|
@@ -216,7 +262,9 @@ Re-running re-asks **nothing already pinned** — only genuinely new campaigns g
|
|
|
216
262
|
|
|
217
263
|
## Failure handling
|
|
218
264
|
|
|
219
|
-
- **Meta `ads_*` tools not in the session** — stop and point at `https://mcp.facebook.com/ads
|
|
265
|
+
- **Meta `ads_*` tools not in the session** — first check §0: if the brand has a Meta integration you don't need them at all. Only a brand WITHOUT one needs the MCP; then stop and point at `https://mcp.facebook.com/ads`. No other workaround exists.
|
|
266
|
+
- **`exodus winners definition` lists ad accounts instead of a definition** — the brand has several connected. Ask which one, then re-run with `--account act_…`. Never pick for them silently.
|
|
267
|
+
- **`exodus winners definition` says no definition exists** — nobody has finished winner setup for that account. Point at the dashboard (Settings → Meta → winner setup); do not build a local one for an integrated brand (§9).
|
|
220
268
|
- **`ads_get_ad_entities` 500 with a metric `filtering`** — that's the known filter bug, not a transient. Drop the filter, sort instead, threshold agent-side (§3).
|
|
221
269
|
- **`ads_get_ad_entities` 500 on an account-wide (or campaign-level) `results` pull** — structural, not transient; retries never help. Scope to one campaign per call and aggregate agent-side (§3).
|
|
222
270
|
- **Any tool 500s once on a scoped call** — retry the identical call once before reporting it.
|