@officexapp/vidfarm-devcli 0.21.26 → 0.21.27
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/.agents/skills/vidfarm/SKILL.md +26 -6
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +26 -0
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +27 -6
- package/.agents/skills/vidfarm/references/core-workflows.md +1 -1
- package/.agents/skills/vidfarm/references/editor-workflows.md +44 -2
- package/.agents/skills/vidfarm/references/primitives.md +28 -0
- package/.agents/skills/vidfarm-media/SKILL.md +3 -1
- package/.agents/skills/vidfarm-media/references/tts.md +1 -1
- package/SKILL.director.md +152 -15
- package/SKILL.md +6 -2
- package/clipper.md +3 -2
- package/dist/src/cli.js +409 -19
- package/dist/src/devcli/cost-mode.js +46 -18
- package/dist/src/devcli/sequence.js +619 -0
- package/dist/src/services/sequence-prompts.js +463 -0
- package/package.json +3 -1
- package/public/assets/file-directory-app.js +26 -26
- package/public/assets/homepage-client-app.js +12 -12
- package/public/serve-shells/editor.html +66 -3
- package/public/serve-shells/library-files.html +60 -3
- package/public/serve-shells/library-raws.html +60 -3
- package/public/serve-shells/tools-clipper.html +60 -3
- package/public/serve-shells/tools-image.html +1069 -483
- package/public/serve-shells/tools-video.html +140 -14
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// ── `vidfarm sequence` — storyboard-driven AI video pipeline ────────────────
|
|
2
|
+
// A resumable, file-backed pipeline for the character-card → storyboard →
|
|
3
|
+
// AI-video workflow. Every step reads and writes ONE file, `sequence.json`, so
|
|
4
|
+
// the run survives a crash, a closed laptop, or a human editing the plan by
|
|
5
|
+
// hand between steps. Rerunning a completed step is a no-op unless you pass
|
|
6
|
+
// --force, which is what makes `sequence run` safe to invoke repeatedly.
|
|
7
|
+
//
|
|
8
|
+
// Steps, in order:
|
|
9
|
+
// init write sequence.json + PLAN_TASK.md from a one-line brief
|
|
10
|
+
// plan (optional) hand the plan to a desktop agent to direct properly
|
|
11
|
+
// characters generate one identity sheet per character [cheap ~$0.05 ea]
|
|
12
|
+
// boards generate one storyboard sheet per part [cheap ~$0.05 ea]
|
|
13
|
+
// prompts compose the video prompts (free, local, deterministic)
|
|
14
|
+
// animate generate video per part [EXPENSIVE ~$1+ ea]
|
|
15
|
+
// assemble concat parts (+ optional music) into final.mp4 [free, ffmpeg]
|
|
16
|
+
//
|
|
17
|
+
// The ordering is the whole point, and it is the lesson from the public
|
|
18
|
+
// workflows this is modelled on: the board is cheap and the video is not, so
|
|
19
|
+
// you look at a board before you burn a video credit. The gates between steps
|
|
20
|
+
// exist so that a human can say no at the two moments where saying no is worth
|
|
21
|
+
// money — after the character card, and after the board.
|
|
22
|
+
//
|
|
23
|
+
// This module owns orchestration + disk state only. The prompt grammar lives in
|
|
24
|
+
// services/sequence-prompts.ts (pure), and every billed call goes through the
|
|
25
|
+
// `SequenceCloud` seam injected by cli.ts, so there is no second copy of the
|
|
26
|
+
// REST/auth plumbing here.
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { createInterface } from "node:readline/promises";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { parseArgs } from "node:util";
|
|
32
|
+
import { resolveFfmpeg, resolveFfprobe } from "../services/clip-curation/ffmpeg.js";
|
|
33
|
+
import { buildCharacterCardPrompt, buildPlanTaskMarkdown, buildShotPrompt, buildStarterPlan, buildStoryboardPrompt, slugify, validatePlan } from "../services/sequence-prompts.js";
|
|
34
|
+
const DIM = "[2m";
|
|
35
|
+
const RESET = "[0m";
|
|
36
|
+
const BOLD = "[1m";
|
|
37
|
+
const GREEN = "[32m";
|
|
38
|
+
const YELLOW = "[33m";
|
|
39
|
+
export const SEQUENCE_PLAN_FILE = "sequence.json";
|
|
40
|
+
const PLAN_TASK_FILE = "PLAN_TASK.md";
|
|
41
|
+
// ── plan file I/O ───────────────────────────────────────────────────────────
|
|
42
|
+
function planPath(dir) {
|
|
43
|
+
return path.join(dir, SEQUENCE_PLAN_FILE);
|
|
44
|
+
}
|
|
45
|
+
function readPlan(dir) {
|
|
46
|
+
const file = planPath(dir);
|
|
47
|
+
if (!existsSync(file)) {
|
|
48
|
+
throw new Error(`No ${SEQUENCE_PLAN_FILE} in ${dir}. Run \`vidfarm sequence init "<brief>" --dir ${dir}\` first.`);
|
|
49
|
+
}
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new Error(`${file} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
const check = validatePlan(parsed);
|
|
58
|
+
if (!check.ok) {
|
|
59
|
+
throw new Error(`${file} is not a usable plan:\n - ${check.errors.join("\n - ")}`);
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
/** Every mutation goes through here so the plan on disk is never half-written. */
|
|
64
|
+
function writePlan(dir, plan) {
|
|
65
|
+
writeFileSync(planPath(dir), `${JSON.stringify(plan, null, 2)}\n`, "utf8");
|
|
66
|
+
}
|
|
67
|
+
function ensureDirs(dir) {
|
|
68
|
+
for (const sub of ["", "characters", "boards", "shots", "renders"]) {
|
|
69
|
+
mkdirSync(path.join(dir, sub), { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── human gates ─────────────────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* Ask before spending the next tier of money. Auto-approves when --yes, in
|
|
75
|
+
* --json mode, or with no TTY — so the same command that walks a human through
|
|
76
|
+
* the workflow also runs unattended in CI or under an agent.
|
|
77
|
+
*/
|
|
78
|
+
async function gate(cloud, question) {
|
|
79
|
+
if (cloud.yes || cloud.json || !process.stdin.isTTY)
|
|
80
|
+
return true;
|
|
81
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
82
|
+
try {
|
|
83
|
+
const answer = (await rl.question(`${YELLOW}${question}${RESET} [Y/n] `)).trim().toLowerCase();
|
|
84
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
rl.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function say(cloud, message) {
|
|
91
|
+
if (!cloud.json)
|
|
92
|
+
console.log(message);
|
|
93
|
+
}
|
|
94
|
+
// ── step 1: init ────────────────────────────────────────────────────────────
|
|
95
|
+
async function stepInit(argv, opts, cloud, extra) {
|
|
96
|
+
const brief = (extra.brief ?? argv[0] ?? "").trim();
|
|
97
|
+
if (!brief) {
|
|
98
|
+
throw new Error('sequence init needs a brief: vidfarm sequence init "a lone surfer rides the wind across grass hills"');
|
|
99
|
+
}
|
|
100
|
+
if (existsSync(planPath(opts.dir)) && !opts.force) {
|
|
101
|
+
throw new Error(`${planPath(opts.dir)} already exists. Edit it, or pass --force to start over.`);
|
|
102
|
+
}
|
|
103
|
+
ensureDirs(opts.dir);
|
|
104
|
+
const plan = buildStarterPlan({
|
|
105
|
+
brief,
|
|
106
|
+
title: extra.title,
|
|
107
|
+
parts: extra.parts,
|
|
108
|
+
panels: extra.panels,
|
|
109
|
+
aspectRatio: extra.aspectRatio,
|
|
110
|
+
durationSeconds: extra.duration,
|
|
111
|
+
style: extra.style,
|
|
112
|
+
boardStyle: extra.boardStyle,
|
|
113
|
+
laban: extra.laban,
|
|
114
|
+
characterName: extra.character,
|
|
115
|
+
characterDescription: extra.characterDescription
|
|
116
|
+
});
|
|
117
|
+
writePlan(opts.dir, plan);
|
|
118
|
+
writeFileSync(path.join(opts.dir, PLAN_TASK_FILE), buildPlanTaskMarkdown(plan, `./${SEQUENCE_PLAN_FILE}`), "utf8");
|
|
119
|
+
say(cloud, `${GREEN}Initialized${RESET} ${opts.dir}`);
|
|
120
|
+
say(cloud, `${DIM} ${SEQUENCE_PLAN_FILE} the plan — every prompt is generated from this file`);
|
|
121
|
+
say(cloud, ` ${PLAN_TASK_FILE} hand this to a coding agent to direct the sequence properly${RESET}`);
|
|
122
|
+
say(cloud, "");
|
|
123
|
+
say(cloud, `${BOLD}The plan is a working skeleton, not direction.${RESET} Two ways forward:`);
|
|
124
|
+
say(cloud, ` ${DIM}•${RESET} Direct it yourself — open ${SEQUENCE_PLAN_FILE}, or point an agent at ${PLAN_TASK_FILE}`);
|
|
125
|
+
say(cloud, ` ${DIM}•${RESET} Run it as-is — ${BOLD}vidfarm sequence run --dir ${opts.dir}${RESET}`);
|
|
126
|
+
return plan;
|
|
127
|
+
}
|
|
128
|
+
// ── step 2: plan (agent hand-off / validation) ──────────────────────────────
|
|
129
|
+
function stepPlan(opts, cloud, check) {
|
|
130
|
+
const plan = readPlan(opts.dir);
|
|
131
|
+
const panelTotal = plan.parts.reduce((sum, p) => sum + p.panels.length, 0);
|
|
132
|
+
if (check) {
|
|
133
|
+
say(cloud, `${GREEN}Plan is structurally valid.${RESET} ${plan.parts.length} part(s), ${panelTotal} panel(s), ${plan.characters.length} character(s).`);
|
|
134
|
+
if (cloud.json)
|
|
135
|
+
console.log(JSON.stringify({ ok: true, title: plan.title, parts: plan.parts.length, panels: panelTotal }, null, 2));
|
|
136
|
+
return plan;
|
|
137
|
+
}
|
|
138
|
+
const taskFile = path.join(opts.dir, PLAN_TASK_FILE);
|
|
139
|
+
writeFileSync(taskFile, buildPlanTaskMarkdown(plan, `./${SEQUENCE_PLAN_FILE}`), "utf8");
|
|
140
|
+
say(cloud, `${GREEN}Wrote${RESET} ${taskFile}`);
|
|
141
|
+
say(cloud, "");
|
|
142
|
+
say(cloud, "Hand it to a desktop coding agent in this folder:");
|
|
143
|
+
say(cloud, ` ${BOLD}claude${RESET} ${DIM}"read ${PLAN_TASK_FILE} and do it"${RESET}`);
|
|
144
|
+
say(cloud, "");
|
|
145
|
+
say(cloud, `Then: ${BOLD}vidfarm sequence plan --dir ${opts.dir} --check${RESET}`);
|
|
146
|
+
return plan;
|
|
147
|
+
}
|
|
148
|
+
// ── step 3: character cards ─────────────────────────────────────────────────
|
|
149
|
+
async function stepCharacters(opts, cloud) {
|
|
150
|
+
let plan = readPlan(opts.dir);
|
|
151
|
+
const targets = plan.characters.filter((c) => (!opts.only || c.slug === opts.only || c.code.toLowerCase() === opts.only.toLowerCase())
|
|
152
|
+
&& (opts.force || !c.card_url));
|
|
153
|
+
if (!targets.length) {
|
|
154
|
+
say(cloud, `${DIM}Character cards already generated — nothing to do (pass --force to regenerate).${RESET}`);
|
|
155
|
+
return plan;
|
|
156
|
+
}
|
|
157
|
+
cloud.guardBilled({
|
|
158
|
+
label: `${targets.length} character card image(s)`,
|
|
159
|
+
estimate: "cheap, ~$0.01–$0.05 each",
|
|
160
|
+
freeAlternative: "reuse an existing card — drop its URL into characters[].card_url in sequence.json, or point at a saved " +
|
|
161
|
+
"sprite card from /files/characters/<slug>/character_sprite_card.png"
|
|
162
|
+
});
|
|
163
|
+
for (const character of targets) {
|
|
164
|
+
say(cloud, `${DIM}Generating character card for ${character.name} (${character.code})…${RESET}`);
|
|
165
|
+
const prompt = buildCharacterCardPrompt(character, plan);
|
|
166
|
+
const result = await cloud.generateImage({
|
|
167
|
+
prompt,
|
|
168
|
+
// Cards read best tall: more room for a turnaround row plus an expression sheet.
|
|
169
|
+
aspectRatio: "2:3",
|
|
170
|
+
refs: [],
|
|
171
|
+
provider: opts.imageProvider,
|
|
172
|
+
model: opts.imageModel
|
|
173
|
+
});
|
|
174
|
+
const dest = path.join(opts.dir, "characters", `${character.slug}.png`);
|
|
175
|
+
await cloud.download(result.url, dest);
|
|
176
|
+
// Re-read: a long generate gives a human time to edit the plan underneath us.
|
|
177
|
+
plan = readPlan(opts.dir);
|
|
178
|
+
const target = plan.characters.find((c) => c.slug === character.slug);
|
|
179
|
+
if (target) {
|
|
180
|
+
target.card_url = result.url;
|
|
181
|
+
target.card_file = path.relative(opts.dir, dest);
|
|
182
|
+
}
|
|
183
|
+
writePlan(opts.dir, plan);
|
|
184
|
+
say(cloud, `${GREEN} ${character.name}${RESET} → ${dest}`);
|
|
185
|
+
}
|
|
186
|
+
say(cloud, "");
|
|
187
|
+
say(cloud, `${BOLD}Look at the cards before continuing.${RESET} ${DIM}They are the identity authority for every shot — a wrong face here is a wrong face in every frame you pay for.${RESET}`);
|
|
188
|
+
return plan;
|
|
189
|
+
}
|
|
190
|
+
// ── step 4: storyboard sheets ───────────────────────────────────────────────
|
|
191
|
+
function partsToRun(plan, opts, done) {
|
|
192
|
+
return plan.parts.filter((p) => (!opts.only || p.id === opts.only) && (opts.force || !done(p)));
|
|
193
|
+
}
|
|
194
|
+
async function stepBoards(opts, cloud) {
|
|
195
|
+
let plan = readPlan(opts.dir);
|
|
196
|
+
const targets = partsToRun(plan, opts, (p) => Boolean(p.board_url));
|
|
197
|
+
if (!targets.length) {
|
|
198
|
+
say(cloud, `${DIM}Storyboards already generated — nothing to do (pass --force to regenerate).${RESET}`);
|
|
199
|
+
return plan;
|
|
200
|
+
}
|
|
201
|
+
cloud.guardBilled({
|
|
202
|
+
label: `${targets.length} storyboard sheet image(s)`,
|
|
203
|
+
estimate: "cheap, ~$0.01–$0.05 each",
|
|
204
|
+
freeAlternative: "draw or source the board yourself and set parts[].board_url in sequence.json — the video step only needs a URL"
|
|
205
|
+
});
|
|
206
|
+
const cardRefs = plan.characters.map((c) => c.card_url).filter((u) => Boolean(u));
|
|
207
|
+
if (!cardRefs.length) {
|
|
208
|
+
say(cloud, `${YELLOW}No character cards yet${RESET} ${DIM}— boards will be generated without an identity reference, so faces will drift. Run \`vidfarm sequence characters\` first for consistency.${RESET}`);
|
|
209
|
+
}
|
|
210
|
+
for (const part of targets) {
|
|
211
|
+
say(cloud, `${DIM}Generating storyboard for ${part.id} (${part.panels.length} panels, ${part.grid.cols}x${part.grid.rows})…${RESET}`);
|
|
212
|
+
const prompt = buildStoryboardPrompt(part, plan);
|
|
213
|
+
writeFileSync(path.join(opts.dir, "boards", `${part.id}.board-prompt.txt`), `${prompt}\n`, "utf8");
|
|
214
|
+
const result = await cloud.generateImage({
|
|
215
|
+
prompt,
|
|
216
|
+
aspectRatio: plan.aspect_ratio,
|
|
217
|
+
refs: cardRefs.slice(0, 4),
|
|
218
|
+
provider: opts.imageProvider,
|
|
219
|
+
model: opts.imageModel
|
|
220
|
+
});
|
|
221
|
+
const dest = path.join(opts.dir, "boards", `${part.id}.png`);
|
|
222
|
+
await cloud.download(result.url, dest);
|
|
223
|
+
plan = readPlan(opts.dir);
|
|
224
|
+
const target = plan.parts.find((p) => p.id === part.id);
|
|
225
|
+
if (target) {
|
|
226
|
+
target.board_url = result.url;
|
|
227
|
+
target.board_file = path.relative(opts.dir, dest);
|
|
228
|
+
}
|
|
229
|
+
writePlan(opts.dir, plan);
|
|
230
|
+
say(cloud, `${GREEN} ${part.id}${RESET} → ${dest}`);
|
|
231
|
+
}
|
|
232
|
+
say(cloud, "");
|
|
233
|
+
say(cloud, `${BOLD}This is the cheap checkpoint.${RESET} ${DIM}Read the board like an edit: is the order right, does one panel own the peak, does the geography hold? Fixing it here costs cents; fixing it after \`animate\` costs dollars.${RESET}`);
|
|
234
|
+
return plan;
|
|
235
|
+
}
|
|
236
|
+
// ── step 5: shot prompts (free, deterministic) ──────────────────────────────
|
|
237
|
+
function shotRefsFor(part, plan) {
|
|
238
|
+
const previous = part.extend_from ? plan.parts.find((p) => p.id === part.extend_from) : undefined;
|
|
239
|
+
return {
|
|
240
|
+
characterCards: plan.characters.map((c) => ({ code: c.code, name: c.name, url: c.card_url })),
|
|
241
|
+
boardUrl: part.board_url,
|
|
242
|
+
extendFromUrl: previous?.video_url
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function stepPrompts(opts, cloud, printSpec) {
|
|
246
|
+
const plan = readPlan(opts.dir);
|
|
247
|
+
// `--print <kind>:<id>` renders one prompt to stdout without touching disk —
|
|
248
|
+
// the tight loop for iterating on the plan before spending anything.
|
|
249
|
+
if (printSpec) {
|
|
250
|
+
const [kind, id] = printSpec.includes(":") ? printSpec.split(":", 2) : ["shot", printSpec];
|
|
251
|
+
if (kind === "card") {
|
|
252
|
+
const character = plan.characters.find((c) => c.slug === id || c.code.toLowerCase() === id.toLowerCase());
|
|
253
|
+
if (!character)
|
|
254
|
+
throw new Error(`No character "${id}". Have: ${plan.characters.map((c) => c.slug).join(", ")}`);
|
|
255
|
+
process.stdout.write(`${buildCharacterCardPrompt(character, plan)}\n`);
|
|
256
|
+
return plan;
|
|
257
|
+
}
|
|
258
|
+
const part = plan.parts.find((p) => p.id === id);
|
|
259
|
+
if (!part)
|
|
260
|
+
throw new Error(`No part "${id}". Have: ${plan.parts.map((p) => p.id).join(", ")}`);
|
|
261
|
+
process.stdout.write(`${kind === "board" ? buildStoryboardPrompt(part, plan) : buildShotPrompt(part, plan, shotRefsFor(part, plan))}\n`);
|
|
262
|
+
return plan;
|
|
263
|
+
}
|
|
264
|
+
for (const part of plan.parts) {
|
|
265
|
+
if (opts.only && part.id !== opts.only)
|
|
266
|
+
continue;
|
|
267
|
+
const prompt = buildShotPrompt(part, plan, shotRefsFor(part, plan));
|
|
268
|
+
const dest = path.join(opts.dir, "shots", `${part.id}.prompt.txt`);
|
|
269
|
+
writeFileSync(dest, `${prompt}\n`, "utf8");
|
|
270
|
+
part.shot_prompt_file = path.relative(opts.dir, dest);
|
|
271
|
+
say(cloud, `${GREEN} ${part.id}${RESET} → ${dest} ${DIM}(${prompt.length} chars)${RESET}`);
|
|
272
|
+
}
|
|
273
|
+
writePlan(opts.dir, plan);
|
|
274
|
+
return plan;
|
|
275
|
+
}
|
|
276
|
+
// ── step 6: animate ─────────────────────────────────────────────────────────
|
|
277
|
+
async function stepAnimate(opts, cloud) {
|
|
278
|
+
let plan = readPlan(opts.dir);
|
|
279
|
+
const targets = partsToRun(plan, opts, (p) => Boolean(p.video_url));
|
|
280
|
+
if (!targets.length) {
|
|
281
|
+
say(cloud, `${DIM}All parts already animated — nothing to do (pass --force to regenerate).${RESET}`);
|
|
282
|
+
return plan;
|
|
283
|
+
}
|
|
284
|
+
const missingBoards = targets.filter((p) => !p.board_url);
|
|
285
|
+
if (missingBoards.length) {
|
|
286
|
+
throw new Error(`No storyboard for ${missingBoards.map((p) => p.id).join(", ")}. Run \`vidfarm sequence boards --dir ${opts.dir}\` first — ` +
|
|
287
|
+
"animating without a board is how you burn video credits on coverage you haven't seen.");
|
|
288
|
+
}
|
|
289
|
+
cloud.guardBilled({
|
|
290
|
+
label: `${targets.length} AI video generation(s)`,
|
|
291
|
+
estimate: "expensive, ~$1+ each",
|
|
292
|
+
freeAlternative: "animate the storyboard panels as stills instead — `vidfarm place` each panel with --ken-burns, which costs $0 " +
|
|
293
|
+
"and is the right call whenever the motion is a camera move rather than a performance"
|
|
294
|
+
});
|
|
295
|
+
for (const part of targets) {
|
|
296
|
+
// Parts chain: a later part's `extend` needs the previous part's finished
|
|
297
|
+
// video URL, so re-read the plan each iteration rather than caching it.
|
|
298
|
+
plan = readPlan(opts.dir);
|
|
299
|
+
const fresh = plan.parts.find((p) => p.id === part.id);
|
|
300
|
+
const refs = shotRefsFor(fresh, plan);
|
|
301
|
+
const prompt = buildShotPrompt(fresh, plan, refs);
|
|
302
|
+
writeFileSync(path.join(opts.dir, "shots", `${fresh.id}.prompt.txt`), `${prompt}\n`, "utf8");
|
|
303
|
+
if (fresh.extend_from && !refs.extendFromUrl) {
|
|
304
|
+
say(cloud, `${YELLOW} ${fresh.id} extends ${fresh.extend_from}, which has no video yet${RESET} ${DIM}— generating it as a standalone shot instead.${RESET}`);
|
|
305
|
+
}
|
|
306
|
+
const inputRefs = [
|
|
307
|
+
...(refs.extendFromUrl ? [refs.extendFromUrl] : []),
|
|
308
|
+
...(fresh.board_url ? [fresh.board_url] : []),
|
|
309
|
+
...plan.characters.map((c) => c.card_url).filter((u) => Boolean(u))
|
|
310
|
+
].slice(0, 8);
|
|
311
|
+
say(cloud, `${DIM}Animating ${fresh.id} (${fresh.duration_seconds}s, ${inputRefs.length} reference(s))…${RESET}`);
|
|
312
|
+
const result = await cloud.generateVideo({
|
|
313
|
+
prompt,
|
|
314
|
+
durationSeconds: fresh.duration_seconds,
|
|
315
|
+
aspectRatio: plan.aspect_ratio,
|
|
316
|
+
resolution: opts.resolution,
|
|
317
|
+
audio: opts.audio,
|
|
318
|
+
refs: inputRefs,
|
|
319
|
+
provider: opts.provider,
|
|
320
|
+
model: opts.model
|
|
321
|
+
});
|
|
322
|
+
const dest = path.join(opts.dir, "renders", `${fresh.id}.mp4`);
|
|
323
|
+
await cloud.download(result.url, dest);
|
|
324
|
+
plan = readPlan(opts.dir);
|
|
325
|
+
const target = plan.parts.find((p) => p.id === fresh.id);
|
|
326
|
+
if (target) {
|
|
327
|
+
target.video_url = result.url;
|
|
328
|
+
target.video_file = path.relative(opts.dir, dest);
|
|
329
|
+
target.shot_prompt_file = path.relative(opts.dir, path.join(opts.dir, "shots", `${fresh.id}.prompt.txt`));
|
|
330
|
+
}
|
|
331
|
+
writePlan(opts.dir, plan);
|
|
332
|
+
say(cloud, `${GREEN} ${fresh.id}${RESET} → ${dest}`);
|
|
333
|
+
}
|
|
334
|
+
return plan;
|
|
335
|
+
}
|
|
336
|
+
// ── step 7: assemble ────────────────────────────────────────────────────────
|
|
337
|
+
function runFfmpeg(bin, args) {
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
340
|
+
let stderr = "";
|
|
341
|
+
child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
|
|
342
|
+
child.on("error", reject);
|
|
343
|
+
child.on("close", (code) => {
|
|
344
|
+
if (code === 0)
|
|
345
|
+
resolve();
|
|
346
|
+
else
|
|
347
|
+
reject(new Error(`ffmpeg exited ${code}: ${stderr.trim().split("\n").slice(-6).join("\n")}`));
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/** Read width/height/fps off a clip so the concat can normalize against it. */
|
|
352
|
+
async function probeVideoGeometry(_dir, clip) {
|
|
353
|
+
const fallback = { width: 1920, height: 1080, fps: 30 };
|
|
354
|
+
try {
|
|
355
|
+
const ffprobe = await resolveFfprobe();
|
|
356
|
+
const json = await new Promise((resolve, reject) => {
|
|
357
|
+
const child = spawn(ffprobe, [
|
|
358
|
+
"-v", "error", "-select_streams", "v:0",
|
|
359
|
+
"-show_entries", "stream=width,height,r_frame_rate",
|
|
360
|
+
"-of", "json", clip
|
|
361
|
+
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
362
|
+
let out = "";
|
|
363
|
+
child.stdout?.on("data", (chunk) => { out += String(chunk); });
|
|
364
|
+
child.on("error", reject);
|
|
365
|
+
child.on("close", () => resolve(out));
|
|
366
|
+
});
|
|
367
|
+
const stream = JSON.parse(json)?.streams?.[0];
|
|
368
|
+
const width = Number(stream?.width);
|
|
369
|
+
const height = Number(stream?.height);
|
|
370
|
+
const [num, den] = String(stream?.r_frame_rate ?? "30/1").split("/").map(Number);
|
|
371
|
+
const fps = den ? Math.round(num / den) : 30;
|
|
372
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
|
373
|
+
return fallback;
|
|
374
|
+
// libx264 needs even dimensions with yuv420p.
|
|
375
|
+
return { width: width - (width % 2), height: height - (height % 2), fps: fps > 0 ? fps : 30 };
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return fallback;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async function stepAssemble(opts, cloud) {
|
|
382
|
+
const plan = readPlan(opts.dir);
|
|
383
|
+
const clips = plan.parts
|
|
384
|
+
.map((p) => (p.video_file ? path.resolve(opts.dir, p.video_file) : null))
|
|
385
|
+
.filter((f) => Boolean(f) && existsSync(f));
|
|
386
|
+
if (!clips.length) {
|
|
387
|
+
throw new Error(`No rendered parts in ${opts.dir}/renders. Run \`vidfarm sequence animate --dir ${opts.dir}\` first.`);
|
|
388
|
+
}
|
|
389
|
+
const ffmpeg = await resolveFfmpeg();
|
|
390
|
+
const out = path.resolve(opts.dir, opts.out ?? path.join("renders", "final.mp4"));
|
|
391
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
392
|
+
// Music, when supplied, is resolved to a local file first; a URL is fetched
|
|
393
|
+
// to disk so ffmpeg never has to speak HTTP.
|
|
394
|
+
let musicPath;
|
|
395
|
+
if (opts.music) {
|
|
396
|
+
if (/^https?:\/\//i.test(opts.music)) {
|
|
397
|
+
musicPath = path.join(opts.dir, "renders", "music.mp3");
|
|
398
|
+
await cloud.download(opts.music, musicPath);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
musicPath = path.resolve(process.cwd(), opts.music);
|
|
402
|
+
if (!existsSync(musicPath))
|
|
403
|
+
throw new Error(`No such music file: ${musicPath}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Normalize every part to the first clip's geometry before concatenating.
|
|
407
|
+
// The concat FILTER requires identical width/height/SAR across inputs, and a
|
|
408
|
+
// provider that quietly hands back one part at a different size would
|
|
409
|
+
// otherwise fail the whole assemble at the last step.
|
|
410
|
+
const target = await probeVideoGeometry(opts.dir, clips[0]);
|
|
411
|
+
// Re-encode rather than stream-copy: parts come back with whatever timebase
|
|
412
|
+
// and profile the provider felt like, and concat-copy silently produces a
|
|
413
|
+
// file that only plays for the first clip.
|
|
414
|
+
const args = ["-y"];
|
|
415
|
+
for (const clip of clips)
|
|
416
|
+
args.push("-i", clip);
|
|
417
|
+
if (musicPath)
|
|
418
|
+
args.push("-i", musicPath);
|
|
419
|
+
const normalized = clips
|
|
420
|
+
.map((_clip, i) => `[${i}:v:0]scale=${target.width}:${target.height}:force_original_aspect_ratio=decrease,` +
|
|
421
|
+
`pad=${target.width}:${target.height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${target.fps},format=yuv420p[v${i}]`)
|
|
422
|
+
.join(";");
|
|
423
|
+
const chain = clips.map((_clip, i) => `[v${i}]`).join("");
|
|
424
|
+
args.push("-filter_complex", `${normalized};${chain}concat=n=${clips.length}:v=1:a=0[outv]`, "-map", "[outv]");
|
|
425
|
+
if (musicPath) {
|
|
426
|
+
args.push("-map", `${clips.length}:a:0`, "-c:a", "aac", "-b:a", "192k", "-shortest");
|
|
427
|
+
}
|
|
428
|
+
args.push("-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", out);
|
|
429
|
+
say(cloud, `${DIM}Assembling ${clips.length} part(s)${musicPath ? " + music" : ""} → ${out}${RESET}`);
|
|
430
|
+
await runFfmpeg(ffmpeg, args);
|
|
431
|
+
say(cloud, `${GREEN}Final cut:${RESET} ${out}`);
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
// ── status ──────────────────────────────────────────────────────────────────
|
|
435
|
+
function stepStatus(opts, cloud) {
|
|
436
|
+
const plan = readPlan(opts.dir);
|
|
437
|
+
const cards = plan.characters.filter((c) => c.card_url).length;
|
|
438
|
+
const boards = plan.parts.filter((p) => p.board_url).length;
|
|
439
|
+
const videos = plan.parts.filter((p) => p.video_url).length;
|
|
440
|
+
const finalPath = path.resolve(opts.dir, opts.out ?? path.join("renders", "final.mp4"));
|
|
441
|
+
if (cloud.json) {
|
|
442
|
+
console.log(JSON.stringify({
|
|
443
|
+
title: plan.title,
|
|
444
|
+
dir: opts.dir,
|
|
445
|
+
characters: { total: plan.characters.length, done: cards },
|
|
446
|
+
boards: { total: plan.parts.length, done: boards },
|
|
447
|
+
videos: { total: plan.parts.length, done: videos },
|
|
448
|
+
final: existsSync(finalPath) ? finalPath : null,
|
|
449
|
+
next: nextStep(plan, finalPath)
|
|
450
|
+
}, null, 2));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
console.log(`${BOLD}${plan.title}${RESET} ${DIM}(${opts.dir})${RESET}`);
|
|
454
|
+
console.log(` ${cards === plan.characters.length ? GREEN : DIM}characters${RESET} ${cards}/${plan.characters.length}`);
|
|
455
|
+
console.log(` ${boards === plan.parts.length ? GREEN : DIM}boards${RESET} ${boards}/${plan.parts.length}`);
|
|
456
|
+
console.log(` ${videos === plan.parts.length ? GREEN : DIM}animate${RESET} ${videos}/${plan.parts.length}`);
|
|
457
|
+
console.log(` ${existsSync(finalPath) ? GREEN : DIM}assemble${RESET} ${existsSync(finalPath) ? finalPath : "—"}`);
|
|
458
|
+
for (const part of plan.parts) {
|
|
459
|
+
const marks = [part.board_url ? "board" : null, part.video_url ? "video" : null].filter(Boolean).join(" + ") || "not started";
|
|
460
|
+
console.log(`${DIM} ${part.id} ${part.panels.length} panels, ${part.duration_seconds}s — ${marks}${RESET}`);
|
|
461
|
+
}
|
|
462
|
+
const next = nextStep(plan, finalPath);
|
|
463
|
+
if (next)
|
|
464
|
+
console.log(`\n Next: ${BOLD}vidfarm sequence ${next} --dir ${opts.dir}${RESET}`);
|
|
465
|
+
}
|
|
466
|
+
function nextStep(plan, finalPath) {
|
|
467
|
+
if (plan.characters.some((c) => !c.card_url))
|
|
468
|
+
return "characters";
|
|
469
|
+
if (plan.parts.some((p) => !p.board_url))
|
|
470
|
+
return "boards";
|
|
471
|
+
if (plan.parts.some((p) => !p.video_url))
|
|
472
|
+
return "animate";
|
|
473
|
+
if (!existsSync(finalPath))
|
|
474
|
+
return "assemble";
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
// ── run: the whole pipeline ─────────────────────────────────────────────────
|
|
478
|
+
async function stepRun(opts, cloud) {
|
|
479
|
+
const plan = readPlan(opts.dir);
|
|
480
|
+
const estimate = plan.parts.length;
|
|
481
|
+
say(cloud, `${BOLD}${plan.title}${RESET} — ${plan.characters.length} character card(s), ${estimate} board(s), ${estimate} video(s).`);
|
|
482
|
+
say(cloud, `${DIM}Cards and boards are cents; video is roughly $1+ per part. Gates before each spend (skip with --yes).${RESET}`);
|
|
483
|
+
say(cloud, "");
|
|
484
|
+
say(cloud, `${BOLD}1/5 character cards${RESET}`);
|
|
485
|
+
await stepCharacters(opts, cloud);
|
|
486
|
+
if (!(await gate(cloud, "Cards look right — generate storyboards?"))) {
|
|
487
|
+
say(cloud, `${DIM}Stopped. Resume with: vidfarm sequence run --dir ${opts.dir}${RESET}`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
say(cloud, `\n${BOLD}2/5 storyboards${RESET}`);
|
|
491
|
+
await stepBoards(opts, cloud);
|
|
492
|
+
if (!(await gate(cloud, "Board reads correctly — spend on video?"))) {
|
|
493
|
+
say(cloud, `${DIM}Stopped before the expensive step. Fix ${SEQUENCE_PLAN_FILE}, re-run \`sequence boards --force\`, then \`sequence run\` again.${RESET}`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
say(cloud, `\n${BOLD}3/5 shot prompts${RESET}`);
|
|
497
|
+
stepPrompts(opts, cloud);
|
|
498
|
+
say(cloud, `\n${BOLD}4/5 animate${RESET}`);
|
|
499
|
+
await stepAnimate(opts, cloud);
|
|
500
|
+
say(cloud, `\n${BOLD}5/5 assemble${RESET}`);
|
|
501
|
+
const out = await stepAssemble(opts, cloud);
|
|
502
|
+
say(cloud, "");
|
|
503
|
+
say(cloud, `${GREEN}Done.${RESET} ${out}`);
|
|
504
|
+
say(cloud, `${DIM}Next: vidfarm upload "${out}" — or drop it into a composition with \`vidfarm place\`.${RESET}`);
|
|
505
|
+
if (cloud.json)
|
|
506
|
+
console.log(JSON.stringify({ ok: true, final: out }, null, 2));
|
|
507
|
+
}
|
|
508
|
+
// ── entrypoint ──────────────────────────────────────────────────────────────
|
|
509
|
+
const SUBCOMMANDS = ["init", "plan", "characters", "boards", "prompts", "animate", "assemble", "status", "run"];
|
|
510
|
+
export async function runSequenceCommand(argv, cloud) {
|
|
511
|
+
const parsed = parseArgs({
|
|
512
|
+
args: argv,
|
|
513
|
+
allowPositionals: true,
|
|
514
|
+
strict: false,
|
|
515
|
+
options: {
|
|
516
|
+
dir: { type: "string" },
|
|
517
|
+
brief: { type: "string" },
|
|
518
|
+
title: { type: "string" },
|
|
519
|
+
parts: { type: "string" },
|
|
520
|
+
panels: { type: "string" },
|
|
521
|
+
"aspect-ratio": { type: "string" },
|
|
522
|
+
duration: { type: "string" },
|
|
523
|
+
style: { type: "string" },
|
|
524
|
+
"board-style": { type: "string" },
|
|
525
|
+
character: { type: "string" },
|
|
526
|
+
"character-description": { type: "string" },
|
|
527
|
+
"no-laban": { type: "boolean", default: false },
|
|
528
|
+
only: { type: "string" },
|
|
529
|
+
part: { type: "string" },
|
|
530
|
+
force: { type: "boolean", default: false },
|
|
531
|
+
check: { type: "boolean", default: false },
|
|
532
|
+
print: { type: "string" },
|
|
533
|
+
provider: { type: "string" },
|
|
534
|
+
model: { type: "string" },
|
|
535
|
+
"image-provider": { type: "string" },
|
|
536
|
+
"image-model": { type: "string" },
|
|
537
|
+
resolution: { type: "string" },
|
|
538
|
+
audio: { type: "boolean", default: false },
|
|
539
|
+
music: { type: "string" },
|
|
540
|
+
out: { type: "string" }
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
const values = parsed.values;
|
|
544
|
+
const subcommand = String(parsed.positionals[0] ?? "status");
|
|
545
|
+
if (!SUBCOMMANDS.includes(subcommand)) {
|
|
546
|
+
throw new Error(`Unknown sequence subcommand "${subcommand}". Expected one of: ${SUBCOMMANDS.join(", ")}.`);
|
|
547
|
+
}
|
|
548
|
+
const num = (key) => {
|
|
549
|
+
const raw = values[key];
|
|
550
|
+
if (raw === undefined || raw === "")
|
|
551
|
+
return undefined;
|
|
552
|
+
const n = Number(raw);
|
|
553
|
+
if (!Number.isFinite(n))
|
|
554
|
+
throw new Error(`--${key} must be a number, got "${String(raw)}".`);
|
|
555
|
+
return n;
|
|
556
|
+
};
|
|
557
|
+
const opts = {
|
|
558
|
+
// Default to a folder named after the brief on init, and to CWD otherwise —
|
|
559
|
+
// so `cd my-seq && vidfarm sequence run` needs no flags at all.
|
|
560
|
+
dir: path.resolve(process.cwd(), String(values.dir
|
|
561
|
+
?? (subcommand === "init" ? `vidfarm-sequence-${slugify(String(values.brief ?? parsed.positionals[1] ?? "sequence"))}` : "."))),
|
|
562
|
+
force: Boolean(values.force),
|
|
563
|
+
// --part is the natural word for boards/animate; --only for characters.
|
|
564
|
+
only: (values.only ?? values.part),
|
|
565
|
+
provider: values.provider,
|
|
566
|
+
model: values.model,
|
|
567
|
+
imageProvider: values["image-provider"],
|
|
568
|
+
imageModel: values["image-model"],
|
|
569
|
+
resolution: values.resolution,
|
|
570
|
+
audio: Boolean(values.audio),
|
|
571
|
+
music: values.music,
|
|
572
|
+
out: values.out
|
|
573
|
+
};
|
|
574
|
+
const boardStyle = values["board-style"];
|
|
575
|
+
if (boardStyle && boardStyle !== "rough" && boardStyle !== "final") {
|
|
576
|
+
throw new Error('--board-style must be "rough" (monochrome previz) or "final" (rendered in the final look, doubles as a style check).');
|
|
577
|
+
}
|
|
578
|
+
switch (subcommand) {
|
|
579
|
+
case "init":
|
|
580
|
+
await stepInit(parsed.positionals.slice(1), opts, cloud, {
|
|
581
|
+
brief: values.brief,
|
|
582
|
+
title: values.title,
|
|
583
|
+
parts: num("parts"),
|
|
584
|
+
panels: num("panels"),
|
|
585
|
+
aspectRatio: values["aspect-ratio"],
|
|
586
|
+
duration: num("duration"),
|
|
587
|
+
style: values.style,
|
|
588
|
+
boardStyle: boardStyle,
|
|
589
|
+
character: values.character,
|
|
590
|
+
characterDescription: values["character-description"],
|
|
591
|
+
laban: !values["no-laban"]
|
|
592
|
+
});
|
|
593
|
+
return;
|
|
594
|
+
case "plan":
|
|
595
|
+
stepPlan(opts, cloud, Boolean(values.check));
|
|
596
|
+
return;
|
|
597
|
+
case "characters":
|
|
598
|
+
await stepCharacters(opts, cloud);
|
|
599
|
+
return;
|
|
600
|
+
case "boards":
|
|
601
|
+
await stepBoards(opts, cloud);
|
|
602
|
+
return;
|
|
603
|
+
case "prompts":
|
|
604
|
+
stepPrompts(opts, cloud, values.print);
|
|
605
|
+
return;
|
|
606
|
+
case "animate":
|
|
607
|
+
await stepAnimate(opts, cloud);
|
|
608
|
+
return;
|
|
609
|
+
case "assemble":
|
|
610
|
+
await stepAssemble(opts, cloud);
|
|
611
|
+
return;
|
|
612
|
+
case "run":
|
|
613
|
+
await stepRun(opts, cloud);
|
|
614
|
+
return;
|
|
615
|
+
default:
|
|
616
|
+
stepStatus(opts, cloud);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
//# sourceMappingURL=sequence.js.map
|