@t3lnet/sceneforge 1.0.3 → 1.0.5

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.
@@ -0,0 +1,236 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+ import { checkFFmpeg, getMediaDuration, runFFmpeg } from "../utils/media.js";
4
+ import { getOutputPaths, readJson, resolveRoot } from "../utils/paths.js";
5
+ import { getFlagValue, hasFlag } from "../utils/args.js";
6
+ import { sanitizeFileSegment } from "../utils/sanitize.js";
7
+
8
+ function printHelp() {
9
+ console.log(`
10
+ Split recorded demo video into per-step clips
11
+
12
+ Usage:
13
+ sceneforge split [options]
14
+
15
+ Options:
16
+ --demo <name> Process a specific demo by name
17
+ --all Process all demos with script JSON files
18
+ --root <path> Project root (defaults to cwd)
19
+ --output-dir <path> Output directory (defaults to e2e/output or output)
20
+ --help, -h Show this help message
21
+
22
+ Examples:
23
+ sceneforge split --demo create-quote
24
+ sceneforge split --all
25
+ `);
26
+ }
27
+
28
+ async function findVideoFile(demoName, videosDir, testResultsDir) {
29
+ const possiblePaths = [
30
+ path.join(videosDir, `${demoName}.webm`),
31
+ path.join(videosDir, `${demoName}-flow.webm`),
32
+ ];
33
+
34
+ try {
35
+ const dirs = await fs.readdir(testResultsDir);
36
+ for (const dir of dirs) {
37
+ if (dir.includes(demoName) || dir.includes(demoName.replace(/-/g, ""))) {
38
+ possiblePaths.push(path.join(testResultsDir, dir, "video.webm"));
39
+ }
40
+ }
41
+ } catch {
42
+ // Ignore missing test-results directory
43
+ }
44
+
45
+ for (const videoPath of possiblePaths) {
46
+ try {
47
+ await fs.access(videoPath);
48
+ return videoPath;
49
+ } catch {
50
+ // Continue
51
+ }
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ async function splitDemo(demoName, paths) {
58
+ console.log(`\n[split] Processing: ${demoName}\n`);
59
+
60
+ const scriptPath = path.join(paths.scriptsDir, `${demoName}.json`);
61
+ let script;
62
+
63
+ try {
64
+ script = await readJson(scriptPath);
65
+ } catch {
66
+ console.error(`[split] ✗ Script not found: ${scriptPath}`);
67
+ console.error(`[split] Generate scripts before splitting video`);
68
+ return;
69
+ }
70
+
71
+ if (!script.stepBoundaries || script.stepBoundaries.length === 0) {
72
+ console.error("[split] ✗ No step boundaries found in script");
73
+ console.error("[split] Regenerate scripts with step boundaries");
74
+ return;
75
+ }
76
+
77
+ const videoPath = await findVideoFile(demoName, paths.videosDir, paths.testResultsDir);
78
+ if (!videoPath) {
79
+ console.error(`[split] ✗ Video not found for: ${demoName}`);
80
+ return;
81
+ }
82
+
83
+ console.log(`[split] Video: ${videoPath}`);
84
+ console.log(`[split] Steps: ${script.stepBoundaries.length}`);
85
+
86
+ const videoDurationSec = await getMediaDuration(videoPath);
87
+ const videoDurationMs = Math.round(videoDurationSec * 1000);
88
+
89
+ const stepClipsDir = path.join(paths.videosDir, demoName);
90
+ await fs.mkdir(stepClipsDir, { recursive: true });
91
+
92
+ for (const boundary of script.stepBoundaries) {
93
+ const isFirstStep = boundary.stepIndex === 0;
94
+ const startMs = isFirstStep ? 0 : boundary.videoStartMs;
95
+ if (startMs >= videoDurationMs) {
96
+ console.warn(`[split] Skipping ${boundary.stepId}: start beyond video duration`);
97
+ continue;
98
+ }
99
+ const clampedEndMs = Math.min(boundary.videoEndMs, videoDurationMs);
100
+ if (clampedEndMs <= startMs) {
101
+ console.warn(`[split] Skipping ${boundary.stepId}: invalid duration after clamp`);
102
+ continue;
103
+ }
104
+ const startSec = startMs / 1000;
105
+ const duration = (clampedEndMs - startMs) / 1000;
106
+ const paddedIndex = String(boundary.stepIndex + 1).padStart(2, "0");
107
+ const safeStepId = sanitizeFileSegment(
108
+ boundary.stepId,
109
+ `step-${boundary.stepIndex + 1}`
110
+ );
111
+ const outputFileName = `step_${paddedIndex}_${safeStepId}.mp4`;
112
+ const outputPath = path.join(stepClipsDir, outputFileName);
113
+
114
+ console.log(
115
+ `[split] ${paddedIndex}. ${boundary.stepId}: ${startSec.toFixed(2)}s - ${(startSec + duration).toFixed(2)}s (${duration.toFixed(2)}s)`
116
+ );
117
+
118
+ try {
119
+ await runFFmpeg([
120
+ "-y",
121
+ "-i",
122
+ videoPath,
123
+ "-ss",
124
+ String(startSec),
125
+ "-t",
126
+ String(duration),
127
+ "-c:v",
128
+ "libx264",
129
+ "-preset",
130
+ "fast",
131
+ "-an",
132
+ outputPath,
133
+ ]);
134
+ } catch (error) {
135
+ console.error(`[split] ✗ Failed to extract step ${boundary.stepId}:`, error);
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ const manifestPath = path.join(stepClipsDir, "steps-manifest.json");
141
+ const manifest = {
142
+ demoName: script.demoName,
143
+ title: script.title,
144
+ generatedAt: new Date().toISOString(),
145
+ sourceVideo: videoPath,
146
+ sourceVideoDurationMs: videoDurationMs,
147
+ steps: script.stepBoundaries.map((boundary) => {
148
+ const paddedIndex = String(boundary.stepIndex + 1).padStart(2, "0");
149
+ const isFirstStep = boundary.stepIndex === 0;
150
+ const splitStartMs = isFirstStep ? 0 : boundary.videoStartMs;
151
+ const clampedEndMs = Math.min(boundary.videoEndMs, videoDurationMs);
152
+ const safeStepId = sanitizeFileSegment(
153
+ boundary.stepId,
154
+ `step-${boundary.stepIndex + 1}`
155
+ );
156
+ return {
157
+ stepId: boundary.stepId,
158
+ safeStepId,
159
+ stepIndex: boundary.stepIndex,
160
+ videoFile: path.join(stepClipsDir, `step_${paddedIndex}_${safeStepId}.mp4`),
161
+ splitStartMs,
162
+ originalStartMs: boundary.videoStartMs,
163
+ originalEndMs: boundary.videoEndMs,
164
+ durationMs: Math.max(0, clampedEndMs - splitStartMs),
165
+ };
166
+ }),
167
+ };
168
+
169
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
170
+
171
+ console.log(`\n[split] ✓ Split into ${script.stepBoundaries.length} clips`);
172
+ console.log(`[split] Output: ${stepClipsDir}`);
173
+ console.log(`[split] Manifest: ${manifestPath}`);
174
+ }
175
+
176
+ async function splitAll(paths) {
177
+ console.log("\n[split] Processing all demos...\n");
178
+
179
+ try {
180
+ const files = await fs.readdir(paths.scriptsDir);
181
+ const scriptFiles = files.filter(
182
+ (file) => file.endsWith(".json") && !file.endsWith(".voice.json") && !file.endsWith("manifest.json")
183
+ );
184
+
185
+ if (scriptFiles.length === 0) {
186
+ console.log("[split] No script files found");
187
+ return;
188
+ }
189
+
190
+ console.log(`[split] Found ${scriptFiles.length} demo(s)\n`);
191
+
192
+ for (const file of scriptFiles) {
193
+ const demoName = path.basename(file, ".json");
194
+ await splitDemo(demoName, paths);
195
+ }
196
+
197
+ console.log("\n[split] All demos processed!");
198
+ } catch (error) {
199
+ console.error("[split] Error:", error);
200
+ }
201
+ }
202
+
203
+ export async function runSplitVideoCommand(argv) {
204
+ const args = argv ?? process.argv.slice(2);
205
+ const help = hasFlag(args, "--help") || hasFlag(args, "-h");
206
+ const demo = getFlagValue(args, "--demo");
207
+ const all = hasFlag(args, "--all");
208
+ const root = getFlagValue(args, "--root");
209
+ const outputDir = getFlagValue(args, "--output-dir");
210
+
211
+ if (help) {
212
+ printHelp();
213
+ return;
214
+ }
215
+
216
+ const hasFFmpeg = await checkFFmpeg();
217
+ if (!hasFFmpeg) {
218
+ console.error("[error] FFmpeg is not installed");
219
+ process.exit(1);
220
+ }
221
+
222
+ const rootDir = resolveRoot(root);
223
+ const paths = await getOutputPaths(rootDir, outputDir);
224
+
225
+ if (demo) {
226
+ await splitDemo(demo, paths);
227
+ return;
228
+ }
229
+
230
+ if (all) {
231
+ await splitAll(paths);
232
+ return;
233
+ }
234
+
235
+ printHelp();
236
+ }
@@ -0,0 +1,15 @@
1
+ export function hasFlag(args, name) {
2
+ return args.includes(name);
3
+ }
4
+
5
+ export function getFlagValue(args, name) {
6
+ const index = args.indexOf(name);
7
+ if (index === -1) return null;
8
+ const value = args[index + 1];
9
+ return value ?? null;
10
+ }
11
+
12
+ export function getFlagValueOrDefault(args, name, defaultValue) {
13
+ const value = getFlagValue(args, name);
14
+ return value === null ? defaultValue : value;
15
+ }
@@ -0,0 +1,81 @@
1
+ import { spawn } from "child_process";
2
+
3
+ const DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024;
4
+
5
+ function appendWithLimit(buffer, chunk, limit) {
6
+ const next = buffer + chunk;
7
+ if (next.length <= limit) {
8
+ return next;
9
+ }
10
+ return next.slice(next.length - limit);
11
+ }
12
+
13
+ function runCommand(command, args, options = {}) {
14
+ const { stdio = ["ignore", "pipe", "pipe"], cwd, maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES } = options;
15
+ return new Promise((resolve, reject) => {
16
+ const child = spawn(command, args, { stdio, cwd });
17
+ let stdout = "";
18
+ let stderr = "";
19
+
20
+ if (child.stdout) {
21
+ child.stdout.on("data", (chunk) => {
22
+ stdout = appendWithLimit(stdout, chunk.toString(), maxOutputBytes);
23
+ });
24
+ }
25
+
26
+ if (child.stderr) {
27
+ child.stderr.on("data", (chunk) => {
28
+ stderr = appendWithLimit(stderr, chunk.toString(), maxOutputBytes);
29
+ });
30
+ }
31
+
32
+ child.on("error", (error) => {
33
+ reject(error);
34
+ });
35
+
36
+ child.on("close", (code) => {
37
+ if (code === 0) {
38
+ resolve({ stdout, stderr });
39
+ return;
40
+ }
41
+ const error = new Error(`${command} exited with code ${code}`);
42
+ error.code = code;
43
+ error.stderr = stderr;
44
+ reject(error);
45
+ });
46
+ });
47
+ }
48
+
49
+ export async function checkFFmpeg() {
50
+ try {
51
+ await runCommand("ffmpeg", ["-version"], { stdio: ["ignore", "ignore", "ignore"] });
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ export async function getMediaDuration(filePath) {
59
+ const { stdout } = await runCommand("ffprobe", [
60
+ "-v",
61
+ "error",
62
+ "-show_entries",
63
+ "format=duration",
64
+ "-of",
65
+ "default=noprint_wrappers=1:nokey=1",
66
+ filePath,
67
+ ]);
68
+ const duration = parseFloat(stdout.trim());
69
+ if (!Number.isFinite(duration)) {
70
+ throw new Error(`Unable to parse media duration for ${filePath}`);
71
+ }
72
+ return duration;
73
+ }
74
+
75
+ export function runFFmpeg(args, options = {}) {
76
+ return runCommand("ffmpeg", args, options);
77
+ }
78
+
79
+ export function runFFprobe(args, options = {}) {
80
+ return runCommand("ffprobe", args, options);
81
+ }
@@ -0,0 +1,93 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+
4
+ export function resolveRoot(explicitRoot) {
5
+ return explicitRoot ? path.resolve(explicitRoot) : process.cwd();
6
+ }
7
+
8
+ async function pathExists(candidatePath) {
9
+ try {
10
+ await fs.access(candidatePath);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function resolveOutputDir(rootDir, explicitOutputDir) {
18
+ if (explicitOutputDir) {
19
+ return path.resolve(rootDir, explicitOutputDir);
20
+ }
21
+
22
+ const outputDir = path.join(rootDir, "output");
23
+ if (await pathExists(outputDir)) {
24
+ return outputDir;
25
+ }
26
+
27
+ const e2eOutputDir = path.join(rootDir, "e2e", "output");
28
+ if (await pathExists(e2eOutputDir)) {
29
+ return e2eOutputDir;
30
+ }
31
+
32
+ return outputDir;
33
+ }
34
+
35
+ export async function resolveEnvFile(rootDir, explicitEnvFile) {
36
+ if (explicitEnvFile) {
37
+ return path.resolve(rootDir, explicitEnvFile);
38
+ }
39
+
40
+ const rootEnvFile = path.join(rootDir, ".env");
41
+ if (await pathExists(rootEnvFile)) {
42
+ return rootEnvFile;
43
+ }
44
+
45
+ const localEnvFile = path.join(rootDir, ".local", ".env");
46
+ if (await pathExists(localEnvFile)) {
47
+ return localEnvFile;
48
+ }
49
+
50
+ const sceneForgeEnvFile = path.join(rootDir, "sceneforge", ".env");
51
+ if (await pathExists(sceneForgeEnvFile)) {
52
+ return sceneForgeEnvFile;
53
+ }
54
+
55
+ const legacyEnvFile = path.join(rootDir, "demo-yaml-creator", ".env");
56
+ if (await pathExists(legacyEnvFile)) {
57
+ return legacyEnvFile;
58
+ }
59
+
60
+ const e2eEnvFile = path.join(rootDir, "e2e", ".env");
61
+ if (await pathExists(e2eEnvFile)) {
62
+ return e2eEnvFile;
63
+ }
64
+
65
+ return null;
66
+ }
67
+
68
+ export async function getOutputPaths(rootDir, explicitOutputDir) {
69
+ const outputDir = await resolveOutputDir(rootDir, explicitOutputDir);
70
+
71
+ return {
72
+ outputDir,
73
+ scriptsDir: path.join(outputDir, "scripts"),
74
+ videosDir: path.join(outputDir, "videos"),
75
+ audioDir: path.join(outputDir, "audio"),
76
+ finalDir: path.join(outputDir, "final"),
77
+ tempDir: path.join(outputDir, "temp"),
78
+ testResultsDir: path.join(outputDir, "test-results"),
79
+ };
80
+ }
81
+
82
+ export async function ensureDir(dirPath) {
83
+ await fs.mkdir(dirPath, { recursive: true });
84
+ }
85
+
86
+ export async function readJson(filePath) {
87
+ const content = await fs.readFile(filePath, "utf-8");
88
+ return JSON.parse(content);
89
+ }
90
+
91
+ export function toAbsolute(rootDir, maybeRelative) {
92
+ return path.isAbsolute(maybeRelative) ? maybeRelative : path.join(rootDir, maybeRelative);
93
+ }
@@ -0,0 +1,19 @@
1
+ const DEFAULT_MAX_LENGTH = 80;
2
+
3
+ export function sanitizeFileSegment(value, fallback = "segment", maxLength = DEFAULT_MAX_LENGTH) {
4
+ const raw = String(value ?? "").trim();
5
+ if (!raw) {
6
+ return fallback;
7
+ }
8
+
9
+ const cleaned = raw.replace(/[^a-zA-Z0-9._-]+/g, "_");
10
+ const collapsed = cleaned.replace(/_{2,}/g, "_").replace(/^_+|_+$/g, "");
11
+ const safe = collapsed || fallback;
12
+ const trimmed = safe.length > maxLength ? safe.slice(0, maxLength) : safe;
13
+
14
+ if (trimmed === "." || trimmed === "..") {
15
+ return fallback;
16
+ }
17
+
18
+ return trimmed;
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3lnet/sceneforge",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "SceneForge runner and generation utilities for YAML-driven demos",
5
5
  "license": "MIT",
6
6
  "author": "T3LNET",
@@ -23,12 +23,16 @@
23
23
  "require": "./dist/index.cjs"
24
24
  }
25
25
  },
26
+ "bin": {
27
+ "sceneforge": "./cli/cli.js"
28
+ },
26
29
  "publishConfig": {
27
30
  "access": "public"
28
31
  },
29
32
  "sideEffects": false,
30
33
  "files": [
31
34
  "dist",
35
+ "cli",
32
36
  "README.md",
33
37
  "LICENSE"
34
38
  ],
@@ -36,6 +40,7 @@
36
40
  "build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean --out-dir dist"
37
41
  },
38
42
  "dependencies": {
43
+ "dotenv": "^17.2.3",
39
44
  "elevenlabs": "^1.59.0",
40
45
  "yaml": "^2.7.0",
41
46
  "zod": "^3.24.2"