@aicopycoders/exodus 2026.9.1900-beta.2 → 2026.9.2000-beta.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/assets/skills/exodus-video/SKILL.md +17 -7
- package/assets/skills/exodus-workflow/SKILL.md +1 -1
- package/dist/commands/video.d.ts +4 -1
- package/dist/commands/video.js +30 -9
- package/dist/commands/workflow.d.ts +8 -0
- package/dist/commands/workflow.js +18 -2
- package/dist/lib/runNames.d.ts +10 -0
- package/dist/lib/runNames.js +10 -0
- package/package.json +1 -1
|
@@ -147,9 +147,15 @@ minutes with "Still running after 60 minutes" and exit 0; that is not done, so
|
|
|
147
147
|
check `status` before treating it as finished. Do not poll `status` in a tight
|
|
148
148
|
loop; a full run is several minutes.
|
|
149
149
|
|
|
150
|
-
**What costs money.** Starting the run
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
**What costs money.** Starting the run writes the storyboard and then draws the
|
|
151
|
+
reference, cast and scene pictures, and only then pauses. Each picture is a few
|
|
152
|
+
cents and each scene draws a few to choose from: about 25 cents on a small test,
|
|
153
|
+
and it can approach a dollar on a longer ad. Approving releases the expensive
|
|
154
|
+
part: the voices and the clips. So a run that has not paused yet is already
|
|
155
|
+
spending a little, and a run still drawing pictures is healthy. `exodus workflow
|
|
156
|
+
status` and `exodus video status` both print "This run will pause for your
|
|
157
|
+
approval once the frames are ready" on a run that is headed for the gate. Do not
|
|
158
|
+
cancel it because the pause has not arrived. `shows`, `workflow list`, `workflow describe`, `video status`,
|
|
153
159
|
`storyboard` and `pull` are free reads; run them as often as you like. Never
|
|
154
160
|
start a second run to "retry" without the user asking; flag the storyboard (Show
|
|
155
161
|
runs) or tell them what failed instead.
|
|
@@ -157,7 +163,7 @@ runs) or tell them what failed instead.
|
|
|
157
163
|
**`--auto-approve` does not cover either video gate.** It releases Checkpoint
|
|
158
164
|
boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
|
|
159
165
|
and the storyboard gate and the final watch carry no pause reason. An unattended
|
|
160
|
-
`workflow run --auto-approve` still stops dead at the
|
|
166
|
+
`workflow run --auto-approve` still stops dead at the storyboard gate — which is the
|
|
161
167
|
safe behaviour, since nothing should approve paid rendering or ship an uncut ad
|
|
162
168
|
on its own.
|
|
163
169
|
|
|
@@ -276,8 +282,9 @@ for the user, then ask one question.
|
|
|
276
282
|
On a **workflow run** the question is approve or don't — there is no flag. If
|
|
277
283
|
the storyboard is wrong, say so plainly and let the user decide between
|
|
278
284
|
approving anyway, cancelling (`exodus workflow cancel <runId>`) and re-running
|
|
279
|
-
with a better script, or changing the workflow. Approving is
|
|
280
|
-
|
|
285
|
+
with a better script, or changing the workflow. Approving is what releases the
|
|
286
|
+
voices and the clips — the expensive part — so do not approve a storyboard you
|
|
287
|
+
have just told the user is wrong.
|
|
281
288
|
|
|
282
289
|
On a **Show run** you can also send it back: `video flag <runId> --note "…"`.
|
|
283
290
|
The note is read by the model that rewrites the storyboard, so make it concrete
|
|
@@ -457,7 +464,10 @@ changed[] the characters whose voice actually moved (set only)
|
|
|
457
464
|
canChange false once the run has left the storyboard review
|
|
458
465
|
whyNot why, in one sentence, when canChange is false
|
|
459
466
|
treatment.path which way of making voices this run froze, or null
|
|
460
|
-
treatment.kind "performance-then-conversion"
|
|
467
|
+
treatment.kind "performance-then-conversion", "voiceover-then-lipsync" or
|
|
468
|
+
"render-owns-voice"
|
|
469
|
+
treatment.pinsHeard true when the chosen ElevenLabs voices are heard on this
|
|
470
|
+
run; false only for "render-owns-voice"
|
|
461
471
|
cast[] one per character
|
|
462
472
|
characterId, name
|
|
463
473
|
voice { voiceId, label }: the voice this character's clips are
|
|
@@ -237,7 +237,7 @@ Two limits worth knowing before you treat an empty inbox as proof of anything:
|
|
|
237
237
|
|
|
238
238
|
**What `inbox` does not list is work that isn't waiting on anybody.** A queued run, a running run, and any automation that ran start-to-finish without an approval step never appear there — not because they're hidden, but because there is nothing for you to decide. Plenty of workflows are built that way on purpose; the shipped **Winner Flywheel** starter is one (brief → splitter → output, no Checkpoint box anywhere), so its run *can never* show up in `inbox`. An empty inbox after a promote means "nothing needs you", not "nothing ran".
|
|
239
239
|
|
|
240
|
-
**To see runs that aren't waiting on you, run `workflow status` with no `--id`.** That prints the brand's recent runs — newest first, one row each with the workflow name, its status, the **date** it started, and its **run id**. That bare list is how you discover a background run at all, including one that started and finished while you weren't looking; take the id from it and `status --id <runId>` for the full detail.
|
|
240
|
+
**To see runs that aren't waiting on you, run `workflow status` with no `--id`.** That prints the brand's recent runs — newest first, one row each with the run's own name (the name you gave it, else the first line of its brief, else the workflow's name), its type ("Copy run" or "Workflow"), its status, the **date** it started, and its **run id**. That bare list is how you discover a background run at all, including one that started and finished while you weren't looking; take the id from it and `status --id <runId>` for the full detail.
|
|
241
241
|
|
|
242
242
|
Two things about that list you have to know before you answer a question with it:
|
|
243
243
|
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ApiResponse } from "../lib/client.js";
|
|
2
2
|
import type { FlagOccurrence } from "../lib/args.js";
|
|
3
|
-
import { type RunProvenance } from "./workflow.js";
|
|
3
|
+
import { type RunProvenance, type WorkflowRun } from "./workflow.js";
|
|
4
4
|
export declare const helpText: string;
|
|
5
5
|
export declare const VOICES_PATH = "/api/v2/video/voices";
|
|
6
6
|
export interface ClipWord {
|
|
@@ -100,6 +100,7 @@ export interface VideoRun {
|
|
|
100
100
|
pauseReason?: BuilderPauseReason;
|
|
101
101
|
pausedNodeId?: string;
|
|
102
102
|
nodes: VideoRunNode[];
|
|
103
|
+
pauseAhead?: WorkflowRun["pauseAhead"];
|
|
103
104
|
castLock?: PullCastLock | null;
|
|
104
105
|
provenance?: RunProvenance;
|
|
105
106
|
workflowId?: string;
|
|
@@ -115,6 +116,7 @@ export interface NodeItem {
|
|
|
115
116
|
attempt?: number;
|
|
116
117
|
flagged?: boolean;
|
|
117
118
|
findings?: ClipFinding[];
|
|
119
|
+
staleClaim?: boolean;
|
|
118
120
|
artifact?: ArtifactSubset;
|
|
119
121
|
}
|
|
120
122
|
export interface ShowRow {
|
|
@@ -296,6 +298,7 @@ export interface ClipRedoTarget {
|
|
|
296
298
|
}
|
|
297
299
|
export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
|
|
298
300
|
export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
301
|
+
export declare const VOICE_PATHS_NEVER_HEARD: Set<string>;
|
|
299
302
|
export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
|
|
300
303
|
export type RevoiceTarget = ClipRedoTarget | {
|
|
301
304
|
all: true;
|
package/dist/commands/video.js
CHANGED
|
@@ -5,7 +5,7 @@ import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
|
|
|
5
5
|
import { displayRunStatus, formatApiError } from "../lib/format.js";
|
|
6
6
|
import { missingRouteLine } from "../lib/route-support.js";
|
|
7
7
|
import { hasBinary } from "../lib/preflight.js";
|
|
8
|
-
import { ASSET_UPLOAD_POLICY } from "./workflow.js";
|
|
8
|
+
import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
|
|
9
9
|
export const helpText = `
|
|
10
10
|
exodus video — make an ad from a Show, pull every piece, upload your cut
|
|
11
11
|
|
|
@@ -921,6 +921,8 @@ const ITEM_STATUS_WORD = {
|
|
|
921
921
|
function itemWord(item) {
|
|
922
922
|
if (!item)
|
|
923
923
|
return "—";
|
|
924
|
+
if (item.staleClaim === true)
|
|
925
|
+
return "stopped";
|
|
924
926
|
if (item.flagged === true)
|
|
925
927
|
return "flagged";
|
|
926
928
|
return ITEM_STATUS_WORD[item.status] ?? item.status;
|
|
@@ -974,6 +976,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
974
976
|
const lines = [
|
|
975
977
|
headline,
|
|
976
978
|
...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
|
|
979
|
+
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
977
980
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
978
981
|
];
|
|
979
982
|
if (byScene.size === 0) {
|
|
@@ -1176,6 +1179,7 @@ export function planClipRedo(run, items, target) {
|
|
|
1176
1179
|
return { ok: false, reason: found.reason };
|
|
1177
1180
|
const { row, uploadedCut } = found;
|
|
1178
1181
|
const redoable = row.status === "failed" ||
|
|
1182
|
+
row.staleClaim === true ||
|
|
1179
1183
|
(row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
|
|
1180
1184
|
if (!redoable) {
|
|
1181
1185
|
if (row.status === "done") {
|
|
@@ -1228,7 +1232,7 @@ function findClipRow(run, items, target) {
|
|
|
1228
1232
|
"and one clip can only be redone on a step that finished.",
|
|
1229
1233
|
};
|
|
1230
1234
|
}
|
|
1231
|
-
if (row.status === "running") {
|
|
1235
|
+
if (row.status === "running" && row.staleClaim !== true) {
|
|
1232
1236
|
return { reason: `Scene ${target.sceneIndex} is already being redone.` };
|
|
1233
1237
|
}
|
|
1234
1238
|
return { row, uploadedCut };
|
|
@@ -1294,20 +1298,34 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1294
1298
|
}
|
|
1295
1299
|
function clipIsStillRaw(item) {
|
|
1296
1300
|
const a = item.artifact;
|
|
1297
|
-
return (item.status === "done" &&
|
|
1301
|
+
return ((item.status === "done" || item.staleClaim === true) &&
|
|
1298
1302
|
a?.type === "video" &&
|
|
1299
1303
|
a.revoiced !== true &&
|
|
1300
1304
|
a.speechTrimmed !== true &&
|
|
1301
1305
|
a.tailTrimmed !== true);
|
|
1302
1306
|
}
|
|
1303
1307
|
const VOICE_NOT_CHANGED_CODES = new Set(["voice-unpinned", "voice-not-applied"]);
|
|
1308
|
+
export const VOICE_PATHS_NEVER_HEARD = new Set(["native-prompt", "omni-audio-ids", "gemini-direct"]);
|
|
1309
|
+
async function neverHeardRunLine(runId, deps) {
|
|
1310
|
+
const res = await deps
|
|
1311
|
+
.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
|
|
1312
|
+
.catch(() => null);
|
|
1313
|
+
if (!res?.ok)
|
|
1314
|
+
return null;
|
|
1315
|
+
const path = res.data?.treatment?.path;
|
|
1316
|
+
if (typeof path !== "string" || !VOICE_PATHS_NEVER_HEARD.has(path))
|
|
1317
|
+
return null;
|
|
1318
|
+
return (`This run's clips are not voiced by ElevenLabs — the "${path}" way settles each voice as ` +
|
|
1319
|
+
"the clip is rendered — so no clip on this run has a voice pass to redo. Nothing was queued " +
|
|
1320
|
+
"and nothing was spent. To make a clip again from scratch, redo the clip instead.");
|
|
1321
|
+
}
|
|
1304
1322
|
export function planClipRevoice(run, items, target) {
|
|
1305
1323
|
const found = findClipRow(run, items, target);
|
|
1306
1324
|
if ("reason" in found)
|
|
1307
1325
|
return { ok: false, reason: found.reason };
|
|
1308
1326
|
const { row, uploadedCut } = found;
|
|
1309
1327
|
const remake = `To make the clip again from scratch: exodus video retry-clip ${run._id} --scene ${target.sceneIndex}`;
|
|
1310
|
-
if (row.status !== "done" || row.artifact?.type !== "video") {
|
|
1328
|
+
if ((row.status !== "done" && row.staleClaim !== true) || row.artifact?.type !== "video") {
|
|
1311
1329
|
return {
|
|
1312
1330
|
ok: false,
|
|
1313
1331
|
reason: `Scene ${target.sceneIndex} has no finished clip, so there is no voice to redo. ${remake}`,
|
|
@@ -1351,10 +1369,11 @@ export async function revoiceFlow(runId, target, json, deps) {
|
|
|
1351
1369
|
.map((i) => ({ sceneIndex: i.sceneIndex, nodeId: i.nodeId }))
|
|
1352
1370
|
: [target];
|
|
1353
1371
|
if (targets.length === 0) {
|
|
1354
|
-
const
|
|
1372
|
+
const note = (await neverHeardRunLine(runId, deps)) ??
|
|
1373
|
+
"No clip on this run is waiting for a voice pass, so nothing was started.";
|
|
1355
1374
|
return {
|
|
1356
1375
|
code: 0,
|
|
1357
|
-
lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note
|
|
1376
|
+
lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note })] : [note],
|
|
1358
1377
|
};
|
|
1359
1378
|
}
|
|
1360
1379
|
const scenes = [];
|
|
@@ -1551,7 +1570,8 @@ export function voiceSheetLines(sheet) {
|
|
|
1551
1570
|
for (const row of sheet.cast) {
|
|
1552
1571
|
const voice = voiceWords(row);
|
|
1553
1572
|
lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
|
|
1554
|
-
`speaks in ${row.spokenScenes}
|
|
1573
|
+
`speaks in ${row.spokenScenes} scene${row.spokenScenes === 1 ? "" : "s"}, ` +
|
|
1574
|
+
`about ${row.spokenSeconds}s`);
|
|
1555
1575
|
if (row.description)
|
|
1556
1576
|
lines.push(` written voice: ${row.description}`);
|
|
1557
1577
|
}
|
|
@@ -1565,8 +1585,9 @@ export function voiceSheetLines(sheet) {
|
|
|
1565
1585
|
: "Nothing changed — those voices were already set. Pictures and script were not touched.");
|
|
1566
1586
|
}
|
|
1567
1587
|
lines.push(`How voices are applied: ${sheet.treatment.summary}${sheet.treatment.speedChange ? "" : " No speed change."}`);
|
|
1568
|
-
const
|
|
1569
|
-
|
|
1588
|
+
const playsPinnedVoices = sheet.treatment.kind !== "render-owns-voice";
|
|
1589
|
+
const convertsClips = playsPinnedVoices && sheet.treatment.usage.clips > 0;
|
|
1590
|
+
if (convertsClips || sheet.treatment.usesElevenLabs === undefined) {
|
|
1570
1591
|
lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
|
|
1571
1592
|
`About ${sheet.treatment.usage.seconds} seconds of speech across ` +
|
|
1572
1593
|
`${sheet.treatment.usage.clips} clips will be converted.`);
|
|
@@ -386,6 +386,9 @@ export interface WorkflowRun {
|
|
|
386
386
|
_id: string;
|
|
387
387
|
workflowId: string;
|
|
388
388
|
workflowName: string;
|
|
389
|
+
runTitle?: string;
|
|
390
|
+
title?: string;
|
|
391
|
+
moduleKey?: string;
|
|
389
392
|
status: WorkflowRunStatus;
|
|
390
393
|
error?: string;
|
|
391
394
|
counts?: WorkflowCounts;
|
|
@@ -408,7 +411,12 @@ export interface WorkflowRun {
|
|
|
408
411
|
approvedAt: number;
|
|
409
412
|
}[];
|
|
410
413
|
provenance?: RunProvenance;
|
|
414
|
+
pauseAhead?: {
|
|
415
|
+
nodeId: string;
|
|
416
|
+
after: "frames" | "storyboard";
|
|
417
|
+
};
|
|
411
418
|
}
|
|
419
|
+
export declare function pauseAheadLine(ahead: NonNullable<WorkflowRun["pauseAhead"]>): string;
|
|
412
420
|
export type WorkflowRunProjection = Omit<WorkflowRun, "nodes"> & {
|
|
413
421
|
nodes?: never;
|
|
414
422
|
};
|
|
@@ -6,6 +6,7 @@ import { formatApiError } from "../lib/format.js";
|
|
|
6
6
|
import { pollUntilDone } from "../lib/poll.js";
|
|
7
7
|
import { normalizeRunStatus, isTerminalRunStatus, storedWorkflowStatusForms, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
|
|
8
8
|
import { runVerdict } from "../lib/runVerdict.js";
|
|
9
|
+
import { runDisplayName, runHeadingWord, runTypeWord } from "../lib/runNames.js";
|
|
9
10
|
import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
|
|
10
11
|
import { missingRouteLine } from "../lib/route-support.js";
|
|
11
12
|
import { getChannel } from "../lib/channel.js";
|
|
@@ -366,6 +367,11 @@ export function formatRulesLine(provenance) {
|
|
|
366
367
|
: "";
|
|
367
368
|
return `Format rules: ${rigName}${version}${where}.${borrowed}`;
|
|
368
369
|
}
|
|
370
|
+
export function pauseAheadLine(ahead) {
|
|
371
|
+
return ahead.after === "frames"
|
|
372
|
+
? "This run will pause for your approval once the frames are ready. The reference, cast and scene pictures are made first (a few cents each). Nothing bigger is spent until you approve."
|
|
373
|
+
: "This run will pause for your approval once the storyboard is written. Nothing is spent on pictures, voices or clips until you approve.";
|
|
374
|
+
}
|
|
369
375
|
const LIST_PATH = "/api/v2/workflows";
|
|
370
376
|
const RUN_PATH = "/api/v2/workflows/run";
|
|
371
377
|
const STATUS_PATH = "/api/v2/workflow";
|
|
@@ -1114,7 +1120,13 @@ const RECENT_RUNS_PAGE = 25;
|
|
|
1114
1120
|
export function formatRecentRuns(runs) {
|
|
1115
1121
|
if (runs.length === 0)
|
|
1116
1122
|
return "No workflow runs found for the active brand.";
|
|
1117
|
-
const rows = table(["
|
|
1123
|
+
const rows = table(["run", "type", "status", "created", "id"], runs.map((r) => [
|
|
1124
|
+
runDisplayName(r),
|
|
1125
|
+
runTypeWord(r),
|
|
1126
|
+
runVerdict(r),
|
|
1127
|
+
dateOnly(r.createdAt),
|
|
1128
|
+
r._id,
|
|
1129
|
+
]));
|
|
1118
1130
|
const notes = [];
|
|
1119
1131
|
if (runs.length >= RECENT_RUNS_PAGE) {
|
|
1120
1132
|
notes.push(`Showing the ${RECENT_RUNS_PAGE} newest runs — there may be older ones this list doesn't reach.`);
|
|
@@ -1195,7 +1207,7 @@ export function formatImportSummary(result, mode = {}) {
|
|
|
1195
1207
|
export function formatWorkflowRun(run) {
|
|
1196
1208
|
const lines = [];
|
|
1197
1209
|
const counts = formatCounts(run.counts);
|
|
1198
|
-
lines.push(
|
|
1210
|
+
lines.push(`${runHeadingWord(run)} — ${runDisplayName(run)}`);
|
|
1199
1211
|
lines.push(`runId: ${run._id}`);
|
|
1200
1212
|
lines.push(`workflowId: ${run.workflowId}`);
|
|
1201
1213
|
if (run.triggerRunId)
|
|
@@ -1218,6 +1230,10 @@ export function formatWorkflowRun(run) {
|
|
|
1218
1230
|
if (run.autoApprovals && run.autoApprovals.length > 0) {
|
|
1219
1231
|
lines.push(`auto-approved: ${run.autoApprovals.length} checkpoint stop${run.autoApprovals.length === 1 ? "" : "s"} (${run.autoApprovals.map((a) => a.nodeId).join(", ")}) — launched with --auto-approve, nobody reviewed these`);
|
|
1220
1232
|
}
|
|
1233
|
+
if (run.pauseAhead) {
|
|
1234
|
+
lines.push("");
|
|
1235
|
+
lines.push(pauseAheadLine(run.pauseAhead));
|
|
1236
|
+
}
|
|
1221
1237
|
const parkedNodeId = gateParkedNodeId(run.status, run.pauseReason, run.pausedNodeId);
|
|
1222
1238
|
if (run.nodes.length > 0) {
|
|
1223
1239
|
lines.push("");
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const COPY_FACE_MODULE_KEY = "copy-face";
|
|
2
|
+
export interface RunNameFields {
|
|
3
|
+
workflowName?: string;
|
|
4
|
+
runTitle?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
moduleKey?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function runDisplayName(run: RunNameFields): string;
|
|
9
|
+
export declare function runTypeWord(run: RunNameFields): string;
|
|
10
|
+
export declare function runHeadingWord(run: RunNameFields): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const COPY_FACE_MODULE_KEY = "copy-face";
|
|
2
|
+
export function runDisplayName(run) {
|
|
3
|
+
return run.title || run.runTitle || run.workflowName || "";
|
|
4
|
+
}
|
|
5
|
+
export function runTypeWord(run) {
|
|
6
|
+
return run.moduleKey === COPY_FACE_MODULE_KEY ? "Copy run" : "Workflow";
|
|
7
|
+
}
|
|
8
|
+
export function runHeadingWord(run) {
|
|
9
|
+
return run.moduleKey === COPY_FACE_MODULE_KEY ? "Copy run" : "Workflow run";
|
|
10
|
+
}
|