@vibeframe/cli 0.52.1 → 0.54.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/LICENSE +21 -0
- package/dist/agent/tools/index.d.ts +1 -0
- package/dist/agent/tools/index.d.ts.map +1 -1
- package/dist/agent/tools/index.js +3 -0
- package/dist/agent/tools/index.js.map +1 -1
- package/dist/agent/tools/scene.d.ts +17 -0
- package/dist/agent/tools/scene.d.ts.map +1 -0
- package/dist/agent/tools/scene.js +263 -0
- package/dist/agent/tools/scene.js.map +1 -0
- package/dist/agent/tools/scene.test.d.ts +2 -0
- package/dist/agent/tools/scene.test.d.ts.map +1 -0
- package/dist/agent/tools/scene.test.js +139 -0
- package/dist/agent/tools/scene.test.js.map +1 -0
- package/dist/commands/_shared/scene-html-emit.d.ts +120 -0
- package/dist/commands/_shared/scene-html-emit.d.ts.map +1 -0
- package/dist/commands/_shared/scene-html-emit.js +422 -0
- package/dist/commands/_shared/scene-html-emit.js.map +1 -0
- package/dist/commands/_shared/scene-html-emit.test.d.ts +2 -0
- package/dist/commands/_shared/scene-html-emit.test.d.ts.map +1 -0
- package/dist/commands/_shared/scene-html-emit.test.js +396 -0
- package/dist/commands/_shared/scene-html-emit.test.js.map +1 -0
- package/dist/commands/_shared/scene-lint.d.ts +110 -0
- package/dist/commands/_shared/scene-lint.d.ts.map +1 -0
- package/dist/commands/_shared/scene-lint.js +207 -0
- package/dist/commands/_shared/scene-lint.js.map +1 -0
- package/dist/commands/_shared/scene-lint.test.d.ts +2 -0
- package/dist/commands/_shared/scene-lint.test.d.ts.map +1 -0
- package/dist/commands/_shared/scene-lint.test.js +208 -0
- package/dist/commands/_shared/scene-lint.test.js.map +1 -0
- package/dist/commands/_shared/scene-project.d.ts +101 -0
- package/dist/commands/_shared/scene-project.d.ts.map +1 -0
- package/dist/commands/_shared/scene-project.js +281 -0
- package/dist/commands/_shared/scene-project.js.map +1 -0
- package/dist/commands/_shared/scene-project.test.d.ts +2 -0
- package/dist/commands/_shared/scene-project.test.d.ts.map +1 -0
- package/dist/commands/_shared/scene-project.test.js +203 -0
- package/dist/commands/_shared/scene-project.test.js.map +1 -0
- package/dist/commands/_shared/scene-render.d.ts +79 -0
- package/dist/commands/_shared/scene-render.d.ts.map +1 -0
- package/dist/commands/_shared/scene-render.js +138 -0
- package/dist/commands/_shared/scene-render.js.map +1 -0
- package/dist/commands/_shared/scene-render.test.d.ts +2 -0
- package/dist/commands/_shared/scene-render.test.d.ts.map +1 -0
- package/dist/commands/_shared/scene-render.test.js +145 -0
- package/dist/commands/_shared/scene-render.test.js.map +1 -0
- package/dist/commands/_shared/segments-to-scenes.d.ts +85 -0
- package/dist/commands/_shared/segments-to-scenes.d.ts.map +1 -0
- package/dist/commands/_shared/segments-to-scenes.js +202 -0
- package/dist/commands/_shared/segments-to-scenes.js.map +1 -0
- package/dist/commands/_shared/segments-to-scenes.test.d.ts +2 -0
- package/dist/commands/_shared/segments-to-scenes.test.d.ts.map +1 -0
- package/dist/commands/_shared/segments-to-scenes.test.js +219 -0
- package/dist/commands/_shared/segments-to-scenes.test.js.map +1 -0
- package/dist/commands/_shared/tts-resolve.d.ts +75 -0
- package/dist/commands/_shared/tts-resolve.d.ts.map +1 -0
- package/dist/commands/_shared/tts-resolve.js +89 -0
- package/dist/commands/_shared/tts-resolve.js.map +1 -0
- package/dist/commands/_shared/tts-resolve.test.d.ts +6 -0
- package/dist/commands/_shared/tts-resolve.test.d.ts.map +1 -0
- package/dist/commands/_shared/tts-resolve.test.js +147 -0
- package/dist/commands/_shared/tts-resolve.test.js.map +1 -0
- package/dist/commands/ai-script-pipeline-cli.d.ts.map +1 -1
- package/dist/commands/ai-script-pipeline-cli.js +49 -0
- package/dist/commands/ai-script-pipeline-cli.js.map +1 -1
- package/dist/commands/ai-script-pipeline.d.ts +16 -0
- package/dist/commands/ai-script-pipeline.d.ts.map +1 -1
- package/dist/commands/ai-script-pipeline.js +28 -0
- package/dist/commands/ai-script-pipeline.js.map +1 -1
- package/dist/commands/ai.test.js +4 -1
- package/dist/commands/ai.test.js.map +1 -1
- package/dist/commands/scene.d.ts +86 -0
- package/dist/commands/scene.d.ts.map +1 -0
- package/dist/commands/scene.js +719 -0
- package/dist/commands/scene.js.map +1 -0
- package/dist/commands/scene.test.d.ts +2 -0
- package/dist/commands/scene.test.d.ts.map +1 -0
- package/dist/commands/scene.test.js +219 -0
- package/dist/commands/scene.test.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/renderers/chrome.d.ts +26 -0
- package/dist/pipeline/renderers/chrome.d.ts.map +1 -0
- package/dist/pipeline/renderers/chrome.js +48 -0
- package/dist/pipeline/renderers/chrome.js.map +1 -0
- package/dist/pipeline/renderers/hyperframes.d.ts.map +1 -1
- package/dist/pipeline/renderers/hyperframes.js +2 -32
- package/dist/pipeline/renderers/hyperframes.js.map +1 -1
- package/dist/utils/provider-resolver.d.ts.map +1 -1
- package/dist/utils/provider-resolver.js +6 -2
- package/dist/utils/provider-resolver.js.map +1 -1
- package/package.json +32 -13
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module scene
|
|
3
|
+
*
|
|
4
|
+
* `vibe scene <sub>` — author, lint, and render per-scene HTML projects that
|
|
5
|
+
* target the Hyperframes render backend. A scene project is bilingual: it's
|
|
6
|
+
* also a valid Hyperframes project (hyperframes.json + meta.json +
|
|
7
|
+
* index.html + compositions/). Users and AI agents can hand-author rich per-
|
|
8
|
+
* scene animation instead of relying on flat YAML steps or opaque MP4s.
|
|
9
|
+
*
|
|
10
|
+
* Subcommands land incrementally across MVP 1:
|
|
11
|
+
* - init [C1] — scaffold project directory
|
|
12
|
+
* - add [C2] — author one scene (template + assets)
|
|
13
|
+
* - lint [C3] — in-process Hyperframes lint + --fix
|
|
14
|
+
* - render [C4, this commit] — render scene project to MP4/WebM/MOV
|
|
15
|
+
*/
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import { basename, resolve, relative, dirname } from "node:path";
|
|
18
|
+
import { mkdir, readFile, writeFile, access, copyFile } from "node:fs/promises";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import chalk from "chalk";
|
|
21
|
+
import ora from "ora";
|
|
22
|
+
import { parse as yamlParse } from "yaml";
|
|
23
|
+
import { GeminiProvider, OpenAIImageProvider, WhisperProvider, } from "@vibeframe/ai-providers";
|
|
24
|
+
import { resolveTtsProvider, TtsKeyMissingError, parseTtsProviderName, } from "./_shared/tts-resolve.js";
|
|
25
|
+
import { scaffoldSceneProject, aspectToDims, } from "./_shared/scene-project.js";
|
|
26
|
+
import { emitSceneHtml, insertClipIntoRoot, nextSceneStart, readRootDims, slugifySceneName, SCENE_PRESETS, } from "./_shared/scene-html-emit.js";
|
|
27
|
+
import { runProjectLint, rootExists, } from "./_shared/scene-lint.js";
|
|
28
|
+
import { executeSceneRender, } from "./_shared/scene-render.js";
|
|
29
|
+
import { exitWithError, generalError, usageError, outputResult, isJsonMode, } from "./output.js";
|
|
30
|
+
import { getApiKey } from "../utils/api-key.js";
|
|
31
|
+
import { getAudioDuration } from "../utils/audio.js";
|
|
32
|
+
const VALID_ASPECTS = ["16:9", "9:16", "1:1", "4:5"];
|
|
33
|
+
function validateAspect(value) {
|
|
34
|
+
if (!VALID_ASPECTS.includes(value)) {
|
|
35
|
+
exitWithError(usageError(`Invalid aspect ratio: ${value}`, `Valid: ${VALID_ASPECTS.join(", ")}`));
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function validateDuration(value) {
|
|
40
|
+
const n = parseFloat(value);
|
|
41
|
+
if (!Number.isFinite(n) || n <= 0 || n > 3600) {
|
|
42
|
+
exitWithError(usageError(`Invalid duration: ${value}`, "Duration must be a positive number of seconds (≤3600)"));
|
|
43
|
+
}
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
function validatePreset(value) {
|
|
47
|
+
if (!SCENE_PRESETS.includes(value)) {
|
|
48
|
+
exitWithError(usageError(`Invalid style: ${value}`, `Valid: ${SCENE_PRESETS.join(", ")}`));
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
export const sceneCommand = new Command("scene")
|
|
53
|
+
.description("Author and render per-scene HTML compositions (Hyperframes backend)")
|
|
54
|
+
.addHelpText("after", `
|
|
55
|
+
Examples:
|
|
56
|
+
$ vibe scene init my-video # Scaffold a new project
|
|
57
|
+
$ vibe scene init my-video -r 9:16 -d 30 # Vertical 30s project
|
|
58
|
+
$ vibe scene add intro --style announcement \\
|
|
59
|
+
--headline "Welcome to VibeFrame" # Headline-only scene
|
|
60
|
+
$ vibe scene add overview --narration "VibeFrame turns scripts into video." \\
|
|
61
|
+
--visuals "studio desk, soft lighting" # AI narration + image
|
|
62
|
+
$ vibe scene lint # Validate every scene against Hyperframes rules
|
|
63
|
+
$ vibe scene lint --fix # Auto-fix mechanical issues (e.g. missing class="clip")
|
|
64
|
+
$ vibe scene lint --json # Structured output for agent loops
|
|
65
|
+
$ vibe scene render # Render to renders/<name>-<timestamp>.mp4
|
|
66
|
+
$ vibe scene render -o demo.mp4 --quality high # Custom output path + quality
|
|
67
|
+
$ vibe scene render --fps 60 --format webm # 60fps WebM render
|
|
68
|
+
|
|
69
|
+
A scene project is bilingual: it works with both \`vibe\` and \`npx hyperframes\`.
|
|
70
|
+
Run 'vibe schema scene.<command>' for structured parameter info.`);
|
|
71
|
+
sceneCommand
|
|
72
|
+
.command("init")
|
|
73
|
+
.description("Scaffold a new scene project (or safely augment an existing Hyperframes project)")
|
|
74
|
+
.argument("<dir>", "Project directory (created if it doesn't exist)")
|
|
75
|
+
.option("-n, --name <name>", "Project name (defaults to directory basename)")
|
|
76
|
+
.option("-r, --ratio <ratio>", "Aspect ratio: 16:9, 9:16, 1:1, 4:5", "16:9")
|
|
77
|
+
.option("-d, --duration <sec>", "Default root composition duration (seconds)", "10")
|
|
78
|
+
.option("--dry-run", "Preview parameters without writing files")
|
|
79
|
+
.action(async (dir, options) => {
|
|
80
|
+
const aspect = validateAspect(options.ratio);
|
|
81
|
+
const duration = validateDuration(options.duration);
|
|
82
|
+
const name = options.name ?? basename(dir.replace(/\/+$/, ""));
|
|
83
|
+
if (options.dryRun) {
|
|
84
|
+
outputResult({
|
|
85
|
+
dryRun: true,
|
|
86
|
+
command: "scene init",
|
|
87
|
+
params: { dir, name, aspect, duration },
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const spinner = isJsonMode() ? null : ora(`Scaffolding scene project at ${dir}...`).start();
|
|
92
|
+
try {
|
|
93
|
+
const result = await scaffoldSceneProject({ dir, name, aspect, duration });
|
|
94
|
+
if (isJsonMode()) {
|
|
95
|
+
outputResult({
|
|
96
|
+
success: true,
|
|
97
|
+
command: "scene init",
|
|
98
|
+
dir,
|
|
99
|
+
name,
|
|
100
|
+
aspect,
|
|
101
|
+
duration,
|
|
102
|
+
created: result.created,
|
|
103
|
+
merged: result.merged,
|
|
104
|
+
skipped: result.skipped,
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
spinner?.succeed(chalk.green(`Scene project ready: ${dir}`));
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(chalk.bold.cyan("Files"));
|
|
111
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
112
|
+
for (const f of result.created)
|
|
113
|
+
console.log(chalk.green(" +"), f);
|
|
114
|
+
for (const f of result.merged)
|
|
115
|
+
console.log(chalk.yellow(" ~"), f, chalk.dim("(merged)"));
|
|
116
|
+
for (const f of result.skipped)
|
|
117
|
+
console.log(chalk.dim(" ·"), f, chalk.dim("(kept existing)"));
|
|
118
|
+
console.log();
|
|
119
|
+
console.log(chalk.bold.cyan("Next steps"));
|
|
120
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
121
|
+
console.log(` ${chalk.cyan("vibe scene add")} <name> ${chalk.dim("# author a scene via AI")}`);
|
|
122
|
+
console.log(` ${chalk.cyan("vibe scene lint")} ${chalk.dim("# validate HTML")}`);
|
|
123
|
+
console.log(` ${chalk.cyan("vibe scene render")} ${chalk.dim("# render to MP4")}`);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
spinner?.fail("Failed to scaffold scene project");
|
|
127
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
128
|
+
exitWithError(generalError(`Failed to scaffold: ${msg}`));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// `vibe scene add`
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
sceneCommand
|
|
135
|
+
.command("add")
|
|
136
|
+
.description("Add a new scene to a project: AI narration + image + per-scene HTML")
|
|
137
|
+
.argument("<name>", "Scene name (slugified into the composition id)")
|
|
138
|
+
.option("--style <preset>", `Style preset: ${SCENE_PRESETS.join(", ")}`, "simple")
|
|
139
|
+
.option("--narration <text>", "Narration text (or path to a .txt file). Drives TTS + scene duration.")
|
|
140
|
+
.option("--narration-file <path>", "Existing narration audio file (.wav/.mp3). Skips TTS — useful with hyperframes tts, Mac say, or other external tools.")
|
|
141
|
+
.option("-d, --duration <sec>", "Explicit scene duration in seconds (overrides narration audio)")
|
|
142
|
+
.option("--visuals <prompt>", "Image prompt — generates assets/scene-<id>.png via the configured image provider")
|
|
143
|
+
.option("--headline <text>", "Visible headline (defaults to the humanised scene name)")
|
|
144
|
+
.option("--kicker <text>", "Small label above the headline (explainer / product-shot)")
|
|
145
|
+
.option("--insert-into <path>", "Root composition file to update", "index.html")
|
|
146
|
+
.option("--project <dir>", "Project directory", ".")
|
|
147
|
+
.option("--image-provider <name>", "Image provider: gemini, openai", "gemini")
|
|
148
|
+
.option("--tts <provider>", "TTS provider: auto, elevenlabs, kokoro (default auto — picks ElevenLabs when key set, else Kokoro local)", "auto")
|
|
149
|
+
.option("--voice <id>", "Voice id (ElevenLabs name/id, or Kokoro id like af_heart, am_michael)")
|
|
150
|
+
.option("--no-audio", "Skip TTS even when --narration is provided (useful for tests/agent dry runs)")
|
|
151
|
+
.option("--no-image", "Skip image generation even when --visuals is provided")
|
|
152
|
+
.option("--no-transcribe", "Skip Whisper word-level transcribe step (no transcript-<id>.json emitted)")
|
|
153
|
+
.option("--transcribe-language <code>", "BCP-47 language code passed to Whisper (e.g. en, ko)")
|
|
154
|
+
.option("--force", "Overwrite an existing compositions/scene-<id>.html")
|
|
155
|
+
.option("--dry-run", "Preview parameters without writing files or calling APIs")
|
|
156
|
+
.action(async (name, options) => {
|
|
157
|
+
if (options.style)
|
|
158
|
+
options.style = validatePreset(options.style);
|
|
159
|
+
if (options.duration !== undefined)
|
|
160
|
+
options.duration = validateDuration(options.duration);
|
|
161
|
+
let tts;
|
|
162
|
+
try {
|
|
163
|
+
tts = parseTtsProviderName(options.tts);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
exitWithError(usageError(error instanceof Error ? error.message : String(error)));
|
|
167
|
+
}
|
|
168
|
+
if (options.dryRun) {
|
|
169
|
+
const id = slugifySceneName(name);
|
|
170
|
+
outputResult({
|
|
171
|
+
dryRun: true,
|
|
172
|
+
command: "scene add",
|
|
173
|
+
params: {
|
|
174
|
+
name,
|
|
175
|
+
id,
|
|
176
|
+
preset: options.style,
|
|
177
|
+
narration: !!options.narration,
|
|
178
|
+
visuals: !!options.visuals,
|
|
179
|
+
duration: options.duration,
|
|
180
|
+
headline: options.headline,
|
|
181
|
+
kicker: options.kicker,
|
|
182
|
+
project: options.project,
|
|
183
|
+
insertInto: options.insertInto,
|
|
184
|
+
imageProvider: options.imageProvider,
|
|
185
|
+
tts,
|
|
186
|
+
audio: options.audio, // commander sets `audio: false` when --no-audio is passed
|
|
187
|
+
image: options.image,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const spinner = isJsonMode() ? null : ora(`Adding scene "${name}"...`).start();
|
|
193
|
+
try {
|
|
194
|
+
const result = await executeSceneAdd({
|
|
195
|
+
name,
|
|
196
|
+
preset: options.style,
|
|
197
|
+
narration: options.narration,
|
|
198
|
+
narrationFile: options.narrationFile,
|
|
199
|
+
duration: options.duration,
|
|
200
|
+
visuals: options.visuals,
|
|
201
|
+
headline: options.headline,
|
|
202
|
+
kicker: options.kicker,
|
|
203
|
+
projectDir: options.project,
|
|
204
|
+
insertInto: options.insertInto,
|
|
205
|
+
imageProvider: options.imageProvider,
|
|
206
|
+
tts,
|
|
207
|
+
voice: options.voice,
|
|
208
|
+
skipAudio: options.audio === false,
|
|
209
|
+
skipImage: options.image === false,
|
|
210
|
+
skipTranscribe: options.transcribe === false,
|
|
211
|
+
transcribeLanguage: options.transcribeLanguage,
|
|
212
|
+
force: !!options.force,
|
|
213
|
+
onProgress: (msg) => {
|
|
214
|
+
if (spinner)
|
|
215
|
+
spinner.text = msg;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
if (!result.success) {
|
|
219
|
+
spinner?.fail(`Failed to add scene "${name}"`);
|
|
220
|
+
exitWithError(generalError(result.error ?? "Scene add failed"));
|
|
221
|
+
}
|
|
222
|
+
if (isJsonMode()) {
|
|
223
|
+
outputResult({
|
|
224
|
+
command: "scene add",
|
|
225
|
+
...result,
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
spinner?.succeed(chalk.green(`Scene added: ${result.id}`));
|
|
230
|
+
console.log();
|
|
231
|
+
console.log(chalk.bold.cyan("Files"));
|
|
232
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
233
|
+
console.log(chalk.green(" +"), result.scenePath);
|
|
234
|
+
if (result.audioPath)
|
|
235
|
+
console.log(chalk.green(" +"), result.audioPath);
|
|
236
|
+
if (result.imagePath)
|
|
237
|
+
console.log(chalk.green(" +"), result.imagePath);
|
|
238
|
+
if (result.transcriptPath)
|
|
239
|
+
console.log(chalk.green(" +"), result.transcriptPath);
|
|
240
|
+
console.log(chalk.yellow(" ~"), result.rootPath, chalk.dim("(updated)"));
|
|
241
|
+
console.log();
|
|
242
|
+
console.log(chalk.bold.cyan("Composition"));
|
|
243
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
244
|
+
console.log(` id ${chalk.bold(result.id)}`);
|
|
245
|
+
console.log(` preset ${result.preset}`);
|
|
246
|
+
console.log(` start ${result.start.toFixed(2)}s`);
|
|
247
|
+
console.log(` duration ${result.duration.toFixed(2)}s`);
|
|
248
|
+
console.log();
|
|
249
|
+
console.log(chalk.bold.cyan("Next steps"));
|
|
250
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
251
|
+
console.log(` ${chalk.cyan("vibe scene lint")} ${chalk.dim("# validate HTML")}`);
|
|
252
|
+
console.log(` ${chalk.cyan("vibe scene render")} ${chalk.dim("# render to MP4")}`);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
spinner?.fail(`Failed to add scene "${name}"`);
|
|
256
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
257
|
+
exitWithError(generalError(msg));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
async function pathExists(p) {
|
|
261
|
+
try {
|
|
262
|
+
await access(p);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function loadVibeProjectConfig(projectDir) {
|
|
270
|
+
const cfgPath = resolve(projectDir, "vibe.project.yaml");
|
|
271
|
+
if (!(await pathExists(cfgPath)))
|
|
272
|
+
return null;
|
|
273
|
+
const raw = await readFile(cfgPath, "utf-8");
|
|
274
|
+
return yamlParse(raw);
|
|
275
|
+
}
|
|
276
|
+
/** Resolve narration text — value may be inline text or a path to a `.txt`/`.md` file. */
|
|
277
|
+
async function resolveNarrationText(value) {
|
|
278
|
+
if (!value)
|
|
279
|
+
return undefined;
|
|
280
|
+
// Treat as a file path only when it actually exists AND looks like one. This
|
|
281
|
+
// avoids accidentally interpreting a sentence with a slash as a path.
|
|
282
|
+
const looksLikePath = /\.[a-z]{2,4}$/i.test(value) || value.includes("/") || value.includes("\\");
|
|
283
|
+
if (looksLikePath && existsSync(value)) {
|
|
284
|
+
return (await readFile(value, "utf-8")).trim();
|
|
285
|
+
}
|
|
286
|
+
return value.trim();
|
|
287
|
+
}
|
|
288
|
+
/** Map a project aspect to the OpenAI image API size string. */
|
|
289
|
+
function openAiSizeForAspect(width, height) {
|
|
290
|
+
if (width === height)
|
|
291
|
+
return "1024x1024";
|
|
292
|
+
return width > height ? "1536x1024" : "1024x1536";
|
|
293
|
+
}
|
|
294
|
+
/** Best-effort aspect-ratio string for the image provider. */
|
|
295
|
+
function aspectStringFromDims(width, height) {
|
|
296
|
+
if (width === height)
|
|
297
|
+
return "1:1";
|
|
298
|
+
if (width === 1920 && height === 1080)
|
|
299
|
+
return "16:9";
|
|
300
|
+
if (width === 1080 && height === 1920)
|
|
301
|
+
return "9:16";
|
|
302
|
+
if (width === 1080 && height === 1350)
|
|
303
|
+
return "4:5";
|
|
304
|
+
return width > height ? "16:9" : "9:16";
|
|
305
|
+
}
|
|
306
|
+
export async function executeSceneAdd(opts) {
|
|
307
|
+
const projectDir = resolve(opts.projectDir ?? ".");
|
|
308
|
+
const rootRel = opts.insertInto ?? "index.html";
|
|
309
|
+
const rootPath = resolve(projectDir, rootRel);
|
|
310
|
+
const id = slugifySceneName(opts.name);
|
|
311
|
+
const sceneRel = `compositions/scene-${id}.html`;
|
|
312
|
+
const scenePath = resolve(projectDir, sceneRel);
|
|
313
|
+
// -- Validate project ----------------------------------------------------
|
|
314
|
+
const errResult = (error) => ({
|
|
315
|
+
success: false,
|
|
316
|
+
id,
|
|
317
|
+
preset: opts.preset,
|
|
318
|
+
start: 0,
|
|
319
|
+
duration: 0,
|
|
320
|
+
scenePath,
|
|
321
|
+
rootPath,
|
|
322
|
+
error,
|
|
323
|
+
});
|
|
324
|
+
if (!(await pathExists(rootPath))) {
|
|
325
|
+
return errResult(`Root composition not found: ${rootPath}. Run \`vibe scene init\` first.`);
|
|
326
|
+
}
|
|
327
|
+
if (!opts.force && (await pathExists(scenePath))) {
|
|
328
|
+
return errResult(`Scene already exists: ${sceneRel}. Re-run with --force to overwrite.`);
|
|
329
|
+
}
|
|
330
|
+
// -- Resolve canvas dims (root takes precedence over project config) -----
|
|
331
|
+
const rootHtmlBefore = await readFile(rootPath, "utf-8");
|
|
332
|
+
let dims = readRootDims(rootHtmlBefore);
|
|
333
|
+
if (!dims) {
|
|
334
|
+
const cfg = await loadVibeProjectConfig(projectDir);
|
|
335
|
+
if (cfg?.aspect)
|
|
336
|
+
dims = aspectToDims(cfg.aspect);
|
|
337
|
+
}
|
|
338
|
+
if (!dims) {
|
|
339
|
+
return errResult("Could not determine canvas dimensions from index.html or vibe.project.yaml.");
|
|
340
|
+
}
|
|
341
|
+
// -- Resolve narration text ---------------------------------------------
|
|
342
|
+
const narrationText = await resolveNarrationText(opts.narration);
|
|
343
|
+
// -- Asset generation: narration audio -----------------------------------
|
|
344
|
+
let audioRelPath;
|
|
345
|
+
let audioAbsPath;
|
|
346
|
+
let narrationDuration;
|
|
347
|
+
if (opts.narrationFile && !opts.skipAudio) {
|
|
348
|
+
// External wav/mp3: skip TTS, copy file into the project's assets/ dir.
|
|
349
|
+
const sourceAbs = resolve(opts.narrationFile);
|
|
350
|
+
if (!(await pathExists(sourceAbs))) {
|
|
351
|
+
return errResult(`Narration file not found: ${sourceAbs}`);
|
|
352
|
+
}
|
|
353
|
+
const ext = (sourceAbs.match(/\.([a-z0-9]+)$/i)?.[1] ?? "wav").toLowerCase();
|
|
354
|
+
if (ext !== "wav" && ext !== "mp3") {
|
|
355
|
+
return errResult(`Unsupported narration file extension: .${ext}. Use .wav or .mp3.`);
|
|
356
|
+
}
|
|
357
|
+
audioRelPath = `assets/narration-${id}.${ext}`;
|
|
358
|
+
audioAbsPath = resolve(projectDir, audioRelPath);
|
|
359
|
+
await mkdir(dirname(audioAbsPath), { recursive: true });
|
|
360
|
+
await copyFile(sourceAbs, audioAbsPath);
|
|
361
|
+
try {
|
|
362
|
+
narrationDuration = await getAudioDuration(audioAbsPath);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
narrationDuration = undefined;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else if (narrationText && !opts.skipAudio) {
|
|
369
|
+
let resolution;
|
|
370
|
+
try {
|
|
371
|
+
resolution = await resolveTtsProvider(opts.tts ?? "auto");
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (error instanceof TtsKeyMissingError) {
|
|
375
|
+
return errResult(error.message);
|
|
376
|
+
}
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
opts.onProgress?.(resolution.provider === "kokoro"
|
|
380
|
+
? "Generating narration with Kokoro (local — first run downloads ~330MB)..."
|
|
381
|
+
: "Generating narration with ElevenLabs...");
|
|
382
|
+
const tts = await resolution.call(narrationText, {
|
|
383
|
+
voice: opts.voice,
|
|
384
|
+
onProgress: (event) => {
|
|
385
|
+
if (event.status === "progress" && typeof event.progress === "number") {
|
|
386
|
+
opts.onProgress?.(`Kokoro model: ${event.file ?? ""} ${Math.round(event.progress)}%`);
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
if (!tts.success || !tts.audioBuffer) {
|
|
391
|
+
return errResult(`${resolution.provider} TTS failed: ${tts.error ?? "unknown error"}`);
|
|
392
|
+
}
|
|
393
|
+
audioRelPath = `assets/narration-${id}.${resolution.audioExtension}`;
|
|
394
|
+
audioAbsPath = resolve(projectDir, audioRelPath);
|
|
395
|
+
await mkdir(dirname(audioAbsPath), { recursive: true });
|
|
396
|
+
await writeFile(audioAbsPath, tts.audioBuffer);
|
|
397
|
+
try {
|
|
398
|
+
narrationDuration = await getAudioDuration(audioAbsPath);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
narrationDuration = undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// -- Whisper word-level transcribe --------------------------------------
|
|
405
|
+
// Auto-runs whenever we have audio + an OpenAI key. The transcript JSON
|
|
406
|
+
// mirrors the Hyperframes shape so a future emitSceneHtml (C5) can drive
|
|
407
|
+
// GSAP word-sync from it. Failure is non-fatal — narration still plays,
|
|
408
|
+
// we just lose word-level animation timing.
|
|
409
|
+
let transcriptRelPath;
|
|
410
|
+
let transcriptWordCount;
|
|
411
|
+
let transcriptWords;
|
|
412
|
+
if (audioAbsPath && !opts.skipTranscribe) {
|
|
413
|
+
const whisperKey = await getApiKey("OPENAI_API_KEY", "OpenAI");
|
|
414
|
+
if (!whisperKey) {
|
|
415
|
+
opts.onProgress?.("Skipping transcribe (OPENAI_API_KEY not set — narration plays but word-sync unavailable)");
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
opts.onProgress?.("Transcribing narration (Whisper word-level)...");
|
|
419
|
+
try {
|
|
420
|
+
const whisper = new WhisperProvider();
|
|
421
|
+
await whisper.initialize({ apiKey: whisperKey });
|
|
422
|
+
const audioBytes = await readFile(audioAbsPath);
|
|
423
|
+
const audioBlob = new Blob([new Uint8Array(audioBytes)]);
|
|
424
|
+
const transcript = await whisper.transcribe(audioBlob, undefined, {
|
|
425
|
+
granularity: "word",
|
|
426
|
+
language: opts.transcribeLanguage,
|
|
427
|
+
});
|
|
428
|
+
if (transcript.status === "completed" && transcript.words?.length) {
|
|
429
|
+
transcriptRelPath = `assets/transcript-${id}.json`;
|
|
430
|
+
const transcriptAbs = resolve(projectDir, transcriptRelPath);
|
|
431
|
+
await writeFile(transcriptAbs, JSON.stringify(transcript.words, null, 2), "utf-8");
|
|
432
|
+
transcriptWordCount = transcript.words.length;
|
|
433
|
+
transcriptWords = transcript.words.map((w) => ({ text: w.text, start: w.start, end: w.end }));
|
|
434
|
+
}
|
|
435
|
+
else if (transcript.status === "failed") {
|
|
436
|
+
opts.onProgress?.(`Transcribe failed: ${transcript.error ?? "unknown error"}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
441
|
+
opts.onProgress?.(`Transcribe failed: ${msg}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// -- Asset generation: backdrop image -----------------------------------
|
|
446
|
+
let imageRelPath;
|
|
447
|
+
let imageAbsPath;
|
|
448
|
+
if (opts.visuals && !opts.skipImage) {
|
|
449
|
+
const provider = (opts.imageProvider ?? "gemini").toLowerCase();
|
|
450
|
+
if (provider !== "gemini" && provider !== "openai") {
|
|
451
|
+
return errResult(`Unsupported --image-provider: ${provider}. Valid: gemini, openai.`);
|
|
452
|
+
}
|
|
453
|
+
opts.onProgress?.(`Generating image with ${provider}...`);
|
|
454
|
+
if (provider === "openai") {
|
|
455
|
+
const openaiKey = await getApiKey("OPENAI_API_KEY", "OpenAI");
|
|
456
|
+
if (!openaiKey) {
|
|
457
|
+
return errResult("OpenAI API key required for --visuals --image-provider openai. Set OPENAI_API_KEY or pass --no-image.");
|
|
458
|
+
}
|
|
459
|
+
const openai = new OpenAIImageProvider();
|
|
460
|
+
await openai.initialize({ apiKey: openaiKey });
|
|
461
|
+
const imageResult = await openai.generateImage(opts.visuals, {
|
|
462
|
+
size: openAiSizeForAspect(dims.width, dims.height),
|
|
463
|
+
quality: "standard",
|
|
464
|
+
});
|
|
465
|
+
if (!imageResult.success || !imageResult.images?.[0]) {
|
|
466
|
+
return errResult(`OpenAI image generation failed: ${imageResult.error ?? "unknown error"}`);
|
|
467
|
+
}
|
|
468
|
+
const img = imageResult.images[0];
|
|
469
|
+
imageRelPath = `assets/scene-${id}.png`;
|
|
470
|
+
imageAbsPath = resolve(projectDir, imageRelPath);
|
|
471
|
+
await mkdir(dirname(imageAbsPath), { recursive: true });
|
|
472
|
+
let buffer;
|
|
473
|
+
if (img.base64) {
|
|
474
|
+
buffer = Buffer.from(img.base64, "base64");
|
|
475
|
+
}
|
|
476
|
+
else if (img.url) {
|
|
477
|
+
const response = await fetch(img.url);
|
|
478
|
+
buffer = Buffer.from(await response.arrayBuffer());
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
return errResult("OpenAI returned no image data");
|
|
482
|
+
}
|
|
483
|
+
await writeFile(imageAbsPath, buffer);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
const googleKey = await getApiKey("GOOGLE_API_KEY", "Google");
|
|
487
|
+
if (!googleKey) {
|
|
488
|
+
return errResult("Google API key required for Gemini image generation. Set GOOGLE_API_KEY or pass --no-image.");
|
|
489
|
+
}
|
|
490
|
+
const gemini = new GeminiProvider();
|
|
491
|
+
await gemini.initialize({ apiKey: googleKey });
|
|
492
|
+
const aspectRatio = aspectStringFromDims(dims.width, dims.height);
|
|
493
|
+
const imageResult = await gemini.generateImage(opts.visuals, { aspectRatio });
|
|
494
|
+
if (!imageResult.success || !imageResult.images?.[0]?.base64) {
|
|
495
|
+
return errResult(`Gemini image generation failed: ${imageResult.error ?? "unknown error"}`);
|
|
496
|
+
}
|
|
497
|
+
imageRelPath = `assets/scene-${id}.png`;
|
|
498
|
+
imageAbsPath = resolve(projectDir, imageRelPath);
|
|
499
|
+
await mkdir(dirname(imageAbsPath), { recursive: true });
|
|
500
|
+
const buffer = Buffer.from(imageResult.images[0].base64, "base64");
|
|
501
|
+
await writeFile(imageAbsPath, buffer);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// -- Decide scene duration -----------------------------------------------
|
|
505
|
+
const cfg = await loadVibeProjectConfig(projectDir);
|
|
506
|
+
const fallback = cfg?.defaultSceneDuration ?? 5;
|
|
507
|
+
const duration = opts.duration ?? narrationDuration ?? fallback;
|
|
508
|
+
// -- Emit scene HTML -----------------------------------------------------
|
|
509
|
+
opts.onProgress?.("Emitting scene HTML...");
|
|
510
|
+
const sceneHtml = emitSceneHtml({
|
|
511
|
+
id,
|
|
512
|
+
preset: opts.preset,
|
|
513
|
+
width: dims.width,
|
|
514
|
+
height: dims.height,
|
|
515
|
+
duration,
|
|
516
|
+
headline: opts.headline,
|
|
517
|
+
subhead: narrationText,
|
|
518
|
+
kicker: opts.kicker,
|
|
519
|
+
imagePath: imageRelPath,
|
|
520
|
+
audioPath: audioRelPath,
|
|
521
|
+
transcript: transcriptWords,
|
|
522
|
+
});
|
|
523
|
+
await mkdir(dirname(scenePath), { recursive: true });
|
|
524
|
+
await writeFile(scenePath, sceneHtml, "utf-8");
|
|
525
|
+
// -- Update root index.html ---------------------------------------------
|
|
526
|
+
opts.onProgress?.("Updating root composition...");
|
|
527
|
+
const start = nextSceneStart(rootHtmlBefore);
|
|
528
|
+
const updated = insertClipIntoRoot(rootHtmlBefore, { id, start, duration });
|
|
529
|
+
await writeFile(rootPath, updated, "utf-8");
|
|
530
|
+
const transcriptAbsPath = transcriptRelPath ? resolve(projectDir, transcriptRelPath) : undefined;
|
|
531
|
+
return {
|
|
532
|
+
success: true,
|
|
533
|
+
id,
|
|
534
|
+
preset: opts.preset,
|
|
535
|
+
start,
|
|
536
|
+
duration,
|
|
537
|
+
scenePath: relative(process.cwd(), scenePath) || scenePath,
|
|
538
|
+
rootPath: relative(process.cwd(), rootPath) || rootPath,
|
|
539
|
+
audioPath: audioAbsPath ? (relative(process.cwd(), audioAbsPath) || audioAbsPath) : undefined,
|
|
540
|
+
imagePath: imageAbsPath ? (relative(process.cwd(), imageAbsPath) || imageAbsPath) : undefined,
|
|
541
|
+
transcriptPath: transcriptAbsPath ? (relative(process.cwd(), transcriptAbsPath) || transcriptAbsPath) : undefined,
|
|
542
|
+
transcriptWordCount,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// `vibe scene lint`
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
sceneCommand
|
|
549
|
+
.command("lint")
|
|
550
|
+
.description("Validate scene HTML against Hyperframes rules (in-process, no Chrome required)")
|
|
551
|
+
.argument("[root]", "Root composition file relative to --project", "index.html")
|
|
552
|
+
.option("--project <dir>", "Project directory", ".")
|
|
553
|
+
.option("--fix", "Apply mechanical auto-fixes (currently: missing class=\"clip\")")
|
|
554
|
+
.action(async (root, options) => {
|
|
555
|
+
const projectDir = resolve(options.project);
|
|
556
|
+
if (!(await rootExists(projectDir, root))) {
|
|
557
|
+
exitWithError(generalError(`Root composition not found: ${resolve(projectDir, root)}`, "Run `vibe scene init` first, or pass --project <dir>."));
|
|
558
|
+
}
|
|
559
|
+
const spinner = isJsonMode() ? null : ora("Linting scenes...").start();
|
|
560
|
+
let result;
|
|
561
|
+
try {
|
|
562
|
+
result = await runProjectLint({ projectDir, rootRel: root, fix: !!options.fix });
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
spinner?.fail("Lint failed");
|
|
566
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
567
|
+
exitWithError(generalError(`Lint failed: ${msg}`));
|
|
568
|
+
}
|
|
569
|
+
if (isJsonMode()) {
|
|
570
|
+
outputResult({
|
|
571
|
+
command: "scene lint",
|
|
572
|
+
...result,
|
|
573
|
+
});
|
|
574
|
+
if (!result.ok)
|
|
575
|
+
process.exit(1);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (result.ok && result.warningCount === 0 && result.infoCount === 0) {
|
|
579
|
+
spinner?.succeed(chalk.green(`Lint clean — ${result.files.length} file(s) checked`));
|
|
580
|
+
}
|
|
581
|
+
else if (result.ok) {
|
|
582
|
+
spinner?.warn(chalk.yellow(`${result.warningCount} warning(s), ${result.infoCount} info — ${result.errorCount} error(s)`));
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
spinner?.fail(chalk.red(`${result.errorCount} error(s), ${result.warningCount} warning(s), ${result.infoCount} info`));
|
|
586
|
+
}
|
|
587
|
+
for (const file of result.files) {
|
|
588
|
+
if (file.findings.length === 0)
|
|
589
|
+
continue;
|
|
590
|
+
console.log();
|
|
591
|
+
console.log(chalk.bold.cyan(file.file));
|
|
592
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
593
|
+
for (const f of file.findings) {
|
|
594
|
+
const tag = severityTag(f.severity);
|
|
595
|
+
const loc = f.elementId ? chalk.dim(` #${f.elementId}`) : f.selector ? chalk.dim(` ${f.selector}`) : "";
|
|
596
|
+
console.log(` ${tag} ${chalk.dim(`[${f.code}]`)}${loc} ${f.message}`);
|
|
597
|
+
if (f.fixHint)
|
|
598
|
+
console.log(` ${chalk.dim("→ " + f.fixHint)}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (result.fixed.length > 0) {
|
|
602
|
+
console.log();
|
|
603
|
+
console.log(chalk.bold.cyan("Auto-fixed"));
|
|
604
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
605
|
+
for (const fx of result.fixed) {
|
|
606
|
+
console.log(` ${chalk.green("✔")} ${fx.file} ${chalk.dim(fx.codes.join(", "))}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (!result.ok)
|
|
610
|
+
process.exit(1);
|
|
611
|
+
});
|
|
612
|
+
function severityTag(severity) {
|
|
613
|
+
if (severity === "error")
|
|
614
|
+
return chalk.red("✘ error ");
|
|
615
|
+
if (severity === "warning")
|
|
616
|
+
return chalk.yellow("⚠ warn ");
|
|
617
|
+
return chalk.blue("ℹ info ");
|
|
618
|
+
}
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// `vibe scene render`
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
const VALID_FPS = [24, 30, 60];
|
|
623
|
+
const VALID_QUALITIES = ["draft", "standard", "high"];
|
|
624
|
+
const VALID_FORMATS = ["mp4", "webm", "mov"];
|
|
625
|
+
function validateFps(value) {
|
|
626
|
+
const n = parseInt(value, 10);
|
|
627
|
+
if (!VALID_FPS.includes(n)) {
|
|
628
|
+
exitWithError(usageError(`Invalid --fps: ${value}`, `Valid: ${VALID_FPS.join(", ")}`));
|
|
629
|
+
}
|
|
630
|
+
return n;
|
|
631
|
+
}
|
|
632
|
+
function validateQuality(value) {
|
|
633
|
+
if (!VALID_QUALITIES.includes(value)) {
|
|
634
|
+
exitWithError(usageError(`Invalid --quality: ${value}`, `Valid: ${VALID_QUALITIES.join(", ")}`));
|
|
635
|
+
}
|
|
636
|
+
return value;
|
|
637
|
+
}
|
|
638
|
+
function validateFormat(value) {
|
|
639
|
+
if (!VALID_FORMATS.includes(value)) {
|
|
640
|
+
exitWithError(usageError(`Invalid --format: ${value}`, `Valid: ${VALID_FORMATS.join(", ")}`));
|
|
641
|
+
}
|
|
642
|
+
return value;
|
|
643
|
+
}
|
|
644
|
+
function validateWorkers(value) {
|
|
645
|
+
const n = parseInt(value, 10);
|
|
646
|
+
if (!Number.isFinite(n) || n < 1 || n > 16) {
|
|
647
|
+
exitWithError(usageError(`Invalid --workers: ${value}`, "Must be an integer between 1 and 16"));
|
|
648
|
+
}
|
|
649
|
+
return n;
|
|
650
|
+
}
|
|
651
|
+
sceneCommand
|
|
652
|
+
.command("render")
|
|
653
|
+
.description("Render a scene project to MP4/WebM/MOV via the Hyperframes producer (requires Chrome)")
|
|
654
|
+
.argument("[root]", "Root composition file relative to --project", "index.html")
|
|
655
|
+
.option("--project <dir>", "Project directory", ".")
|
|
656
|
+
.option("-o, --out <path>", "Output file (default: renders/<name>-<timestamp>.<format>)")
|
|
657
|
+
.option("--fps <n>", `Frames per second: ${VALID_FPS.join("|")}`, "30")
|
|
658
|
+
.option("--quality <q>", `Quality preset: ${VALID_QUALITIES.join("|")}`, "standard")
|
|
659
|
+
.option("--format <f>", `Output container: ${VALID_FORMATS.join("|")}`, "mp4")
|
|
660
|
+
.option("--workers <n>", "Capture workers (1-16, default 1)", "1")
|
|
661
|
+
.option("--dry-run", "Preview parameters without rendering")
|
|
662
|
+
.action(async (root, options) => {
|
|
663
|
+
const fps = validateFps(options.fps);
|
|
664
|
+
const quality = validateQuality(options.quality);
|
|
665
|
+
const format = validateFormat(options.format);
|
|
666
|
+
const workers = validateWorkers(options.workers);
|
|
667
|
+
const projectDir = resolve(options.project);
|
|
668
|
+
if (options.dryRun) {
|
|
669
|
+
outputResult({
|
|
670
|
+
dryRun: true,
|
|
671
|
+
command: "scene render",
|
|
672
|
+
params: {
|
|
673
|
+
projectDir,
|
|
674
|
+
root,
|
|
675
|
+
output: options.out,
|
|
676
|
+
fps,
|
|
677
|
+
quality,
|
|
678
|
+
format,
|
|
679
|
+
workers,
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const spinner = isJsonMode() ? null : ora("Rendering scene project...").start();
|
|
685
|
+
const result = await executeSceneRender({
|
|
686
|
+
projectDir,
|
|
687
|
+
root,
|
|
688
|
+
output: options.out,
|
|
689
|
+
fps,
|
|
690
|
+
quality,
|
|
691
|
+
format,
|
|
692
|
+
workers,
|
|
693
|
+
onProgress: (pct, stage) => {
|
|
694
|
+
if (spinner)
|
|
695
|
+
spinner.text = `Rendering [${Math.round(pct * 100)}%] ${stage}`;
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
if (!result.success) {
|
|
699
|
+
spinner?.fail("Render failed");
|
|
700
|
+
if (isJsonMode()) {
|
|
701
|
+
outputResult({ command: "scene render", ...result });
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
exitWithError(generalError(result.error ?? "Render failed"));
|
|
705
|
+
}
|
|
706
|
+
if (isJsonMode()) {
|
|
707
|
+
outputResult({ command: "scene render", ...result });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
spinner?.succeed(chalk.green(`Render complete: ${result.outputPath}`));
|
|
711
|
+
console.log();
|
|
712
|
+
console.log(chalk.bold.cyan("Render"));
|
|
713
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
714
|
+
console.log(` output ${chalk.bold(result.outputPath)}`);
|
|
715
|
+
console.log(` duration ${(((result.durationMs ?? 0) / 1000)).toFixed(1)}s`);
|
|
716
|
+
console.log(` frames ${result.framesRendered ?? "?"}${result.totalFrames ? ` / ${result.totalFrames}` : ""}`);
|
|
717
|
+
console.log(` config ${result.fps}fps · ${result.quality} · ${result.format}`);
|
|
718
|
+
});
|
|
719
|
+
//# sourceMappingURL=scene.js.map
|