@t3lnet/sceneforge 1.0.14 → 1.0.16

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.
@@ -107,6 +107,42 @@ async function mergeWithExisting(
107
107
  }
108
108
  }
109
109
 
110
+ async function writeSplitPointer(
111
+ tool: TargetTool,
112
+ stageNames: Stage[],
113
+ outputDir: string
114
+ ): Promise<{ filePath: string; merged: boolean }> {
115
+ const config = getToolConfig(tool);
116
+ const stageLines = stageNames.map((stage) => {
117
+ const friendly = formatStageName(stage);
118
+ const stageFile = `${config.splitFilePrefix}${getStageFileName(stage)}${config.fileExtension}`;
119
+ const relativePath = path.posix.join(config.splitDir, stageFile);
120
+ return `- ${friendly}: ${relativePath}`;
121
+ });
122
+
123
+ const pointerBody = [
124
+ "SceneForge context for this tool is split across the following stage files:",
125
+ "",
126
+ ...stageLines,
127
+ "",
128
+ `Open the stage file that matches the work you're doing, or run "npx sceneforge context preview --target ${tool} --stage <stage>" to inspect a specific stage.`,
129
+ ].join("\n");
130
+
131
+ const formattedPointer = formatForTool(tool, pointerBody, {
132
+ includeToolHeader: true,
133
+ });
134
+
135
+ const combinedPath = path.join(outputDir, config.combinedFile);
136
+ await fs.mkdir(path.dirname(combinedPath), { recursive: true });
137
+ const { content: finalContent, merged } = await mergeWithExisting(
138
+ combinedPath,
139
+ formattedPointer
140
+ );
141
+ await fs.writeFile(combinedPath, finalContent, "utf-8");
142
+
143
+ return { filePath: combinedPath, merged };
144
+ }
145
+
110
146
  /**
111
147
  * Build context content for a specific tool and stage.
112
148
  */
@@ -210,6 +246,7 @@ export async function deployContext(
210
246
  }
211
247
  } else {
212
248
  // Generate split files for each stage
249
+ const deployedStages: Stage[] = [];
213
250
  for (const stg of stages) {
214
251
  try {
215
252
  const content = await buildContext(tool, stg, variables);
@@ -240,6 +277,7 @@ export async function deployContext(
240
277
  if (merged) {
241
278
  console.log(` [merged] ${path.relative(outputDir, absolutePath)}`);
242
279
  }
280
+ deployedStages.push(stg);
243
281
  } catch (error) {
244
282
  results.push({
245
283
  tool,
@@ -250,6 +288,24 @@ export async function deployContext(
250
288
  });
251
289
  }
252
290
  }
291
+ if (deployedStages.length > 0) {
292
+ const pointerResult = await writeSplitPointer(
293
+ tool,
294
+ deployedStages,
295
+ outputDir
296
+ );
297
+ results.push({
298
+ tool,
299
+ filePath: pointerResult.filePath,
300
+ created: true,
301
+ skipped: false,
302
+ });
303
+ if (pointerResult.merged) {
304
+ console.log(
305
+ ` [merged] ${path.relative(outputDir, pointerResult.filePath)}`
306
+ );
307
+ }
308
+ }
253
309
  }
254
310
  }
255
311
 
@@ -103,8 +103,11 @@ sceneforge split --demo <name> [options]
103
103
  |------|-------------|
104
104
  | `--demo` | Demo name (folder in output) |
105
105
  | `--output`, `-o` | Output directory |
106
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
107
+ | `--crf` | Override CRF value (0-51, lower = better) |
108
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
106
109
 
107
- **Video Quality:** Encodes with H.264 (libx264), CRF 18, medium preset for high-quality output.
110
+ **Video Quality:** Configurable via `--quality` preset or `--crf`/`--codec` flags. Default: medium preset (CRF 18, libx264).
108
111
 
109
112
  ### voiceover
110
113
  Generate voiceover audio from scripts.
@@ -137,8 +140,11 @@ sceneforge add-audio --demo <name> [options]
137
140
  |------|-------------|
138
141
  | `--demo` | Demo name |
139
142
  | `--output`, `-o` | Output directory |
143
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
144
+ | `--crf` | Override CRF value (0-51, lower = better) |
145
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
140
146
 
141
- **Video Quality:** Re-encodes with H.264 (libx264), CRF 18, medium preset. Audio encoded as AAC at 192kbps.
147
+ **Video Quality:** Configurable via `--quality` preset. Default: medium (CRF 18, libx264). Audio: AAC 192kbps.
142
148
 
143
149
  ### concat
144
150
  Concatenate step clips into final video.
@@ -152,8 +158,15 @@ sceneforge concat --demo <name> [options]
152
158
  |------|-------------|
153
159
  | `--demo` | Demo name |
154
160
  | `--output`, `-o` | Output directory |
161
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
162
+ | `--crf` | Override CRF value (0-51, lower = better) |
163
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
164
+ | `--intro` | Intro video to prepend |
165
+ | `--outro` | Outro video to append |
166
+ | `--music` | Background music file |
167
+ | `--music-volume` | Music volume 0-1 (default: 0.15) |
155
168
 
156
- **Video Quality:** Final encode with H.264 (libx264), CRF 18, medium preset. Includes `+faststart` for web streaming optimization. Audio encoded as AAC at 192kbps.
169
+ **Video Quality:** Configurable via `--quality` preset. Default: medium (CRF 18, libx264). Includes `+faststart` for web streaming. Audio: AAC 192kbps.
157
170
 
158
171
  ### doctor
159
172
  Run environment diagnostics.
@@ -249,24 +262,55 @@ sceneforge record -d demo.yaml -b http://localhost:3000 --headed --slowmo 500
249
262
 
250
263
  ## Video Quality
251
264
 
252
- SceneForge uses high-quality FFmpeg encoding settings optimized for multi-pass video processing:
265
+ SceneForge provides configurable video quality settings via CLI flags on `split`, `add-audio`, and `concat` commands.
253
266
 
254
- | Setting | Value | Purpose |
255
- |---------|-------|---------|
256
- | Codec | `libx264` | H.264 for broad compatibility |
257
- | Preset | `medium` | Balanced quality/speed tradeoff |
258
- | CRF | `18` | High quality (visually lossless) |
259
- | Audio | `aac @ 192k` | High-quality audio |
260
- | Flags | `+faststart` | Web streaming optimization |
267
+ ### Quality Presets
268
+
269
+ | Preset | CRF | Encoding | Use Case |
270
+ |--------|-----|----------|----------|
271
+ | `low` | 28 | fast | Quick drafts, smaller files |
272
+ | `medium` | 18 | medium | Default - balanced quality and size |
273
+ | `high` | 10 | slow | Final delivery, best quality |
274
+
275
+ ### Supported Codecs
276
+
277
+ | Codec | Name | Description |
278
+ |-------|------|-------------|
279
+ | `libx264` | H.264 | Excellent compatibility (default) |
280
+ | `libx265` | H.265/HEVC | ~50% smaller files, slower encoding |
281
+
282
+ ### CLI Flags
283
+
284
+ ```bash
285
+ --quality <preset> # low, medium, high (default: medium)
286
+ --crf <value> # Override CRF (0-51, lower = better)
287
+ --codec <codec> # libx264 or libx265 (default: libx264)
288
+ ```
289
+
290
+ ### Examples
291
+
292
+ ```bash
293
+ # High quality for final output
294
+ sceneforge concat --demo my-demo --quality high
295
+
296
+ # Smaller files with H.265 codec
297
+ sceneforge concat --demo my-demo --codec libx265
298
+
299
+ # Custom CRF for fine control
300
+ sceneforge split --demo my-demo --crf 15
301
+ ```
302
+
303
+ ### Why Quality Matters
261
304
 
262
- **Why these settings:**
263
305
  - Videos go through multiple processing stages (split → add-audio → concat)
264
306
  - Each re-encoding can degrade quality (generation loss)
265
- - CRF 18 preserves quality across all stages
266
- - Medium preset provides good compression without sacrificing quality
307
+ - Higher quality settings (lower CRF) preserve fidelity across stages
308
+ - The `high` preset (CRF 10) produces near-lossless quality
309
+
310
+ ### CRF Reference
267
311
 
268
- **CRF Reference:**
269
312
  - 0 = Lossless (huge files)
270
- - 18 = Visually lossless (SceneForge default)
313
+ - 10 = Near-lossless (high preset)
314
+ - 18 = Visually lossless (medium preset, default)
271
315
  - 23 = FFmpeg default
272
- - 28+ = Lower quality, smaller files
316
+ - 28 = Lower quality (low preset)
@@ -218,24 +218,26 @@ Aim for variance within ±500ms per step:
218
218
  - [ ] No unexpected pauses
219
219
 
220
220
  ### Video Quality Check
221
- SceneForge uses high-quality encoding settings to preserve video fidelity:
221
+ SceneForge provides configurable quality settings to preserve video fidelity:
222
222
  - [ ] Video appears sharp (no compression artifacts)
223
223
  - [ ] Colors are accurate (no banding)
224
224
  - [ ] Text is readable (no blur from compression)
225
225
  - [ ] No visible quality degradation between steps
226
226
 
227
- **Encoding Settings Used:**
228
- | Setting | Value | Purpose |
229
- |---------|-------|---------|
230
- | Codec | `libx264` | H.264 for compatibility |
231
- | CRF | `18` | High quality (visually lossless) |
232
- | Preset | `medium` | Balanced quality/speed |
233
- | Audio | `aac @ 192k` | High-quality audio |
227
+ **Quality Presets:**
228
+ | Preset | CRF | Encoding | Use Case |
229
+ |--------|-----|----------|----------|
230
+ | `low` | 28 | fast | Quick drafts |
231
+ | `medium` | 18 | medium | Default - balanced |
232
+ | `high` | 10 | slow | Final delivery |
233
+
234
+ **CLI Flags:** `--quality`, `--crf`, `--codec` (available on split, add-audio, concat)
234
235
 
235
236
  **If you notice quality issues:**
236
- 1. Ensure source recording is high quality (check `output/videos/<demo>.webm`)
237
- 2. Re-run the pipeline to regenerate all clips
238
- 3. Check available disk space (low space can affect encoding)
237
+ 1. Re-run with `--quality high` for better fidelity
238
+ 2. Ensure source recording is high quality (check `output/videos/<demo>.webm`)
239
+ 3. Try `--codec libx265` for better compression at same quality
240
+ 4. Check available disk space (low space can affect encoding)
239
241
 
240
242
  ## Final Checklist
241
243
 
@@ -118,7 +118,7 @@ describe("context-builder", () => {
118
118
  outputDir: tempDir,
119
119
  });
120
120
 
121
- expect(results.length).toBe(1);
121
+ expect(results.length).toBe(2);
122
122
  expect(results[0].created).toBe(true);
123
123
 
124
124
  const splitDir = path.join(tempDir, ".claude/rules");
@@ -134,9 +134,22 @@ describe("context-builder", () => {
134
134
  outputDir: tempDir,
135
135
  });
136
136
 
137
- expect(results.length).toBe(4);
137
+ expect(results.length).toBe(5);
138
138
  expect(results.every((r) => r.created)).toBe(true);
139
139
  });
140
+
141
+ it("updates combined file when split files are deployed", async () => {
142
+ await deployContext({
143
+ target: "claude",
144
+ stage: "actions",
145
+ format: "split",
146
+ outputDir: tempDir,
147
+ });
148
+
149
+ const claudeFile = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8");
150
+ expect(claudeFile).toContain("split across the following stage files");
151
+ expect(claudeFile).toContain(".claude/rules/stage1-actions.md");
152
+ });
140
153
  });
141
154
 
142
155
  describe("listDeployedContext", () => {
package/dist/index.cjs CHANGED
@@ -2723,6 +2723,33 @@ async function mergeWithExisting(filePath, newContent) {
2723
2723
  throw error;
2724
2724
  }
2725
2725
  }
2726
+ async function writeSplitPointer(tool, stageNames, outputDir) {
2727
+ const config = getToolConfig(tool);
2728
+ const stageLines = stageNames.map((stage) => {
2729
+ const friendly = formatStageName(stage);
2730
+ const stageFile = `${config.splitFilePrefix}${getStageFileName(stage)}${config.fileExtension}`;
2731
+ const relativePath = path6.posix.join(config.splitDir, stageFile);
2732
+ return `- ${friendly}: ${relativePath}`;
2733
+ });
2734
+ const pointerBody = [
2735
+ "SceneForge context for this tool is split across the following stage files:",
2736
+ "",
2737
+ ...stageLines,
2738
+ "",
2739
+ `Open the stage file that matches the work you're doing, or run "npx sceneforge context preview --target ${tool} --stage <stage>" to inspect a specific stage.`
2740
+ ].join("\n");
2741
+ const formattedPointer = formatForTool(tool, pointerBody, {
2742
+ includeToolHeader: true
2743
+ });
2744
+ const combinedPath = path6.join(outputDir, config.combinedFile);
2745
+ await fs6.mkdir(path6.dirname(combinedPath), { recursive: true });
2746
+ const { content: finalContent, merged } = await mergeWithExisting(
2747
+ combinedPath,
2748
+ formattedPointer
2749
+ );
2750
+ await fs6.writeFile(combinedPath, finalContent, "utf-8");
2751
+ return { filePath: combinedPath, merged };
2752
+ }
2726
2753
  async function buildContext(tool, stage, variables) {
2727
2754
  const templates = [];
2728
2755
  const baseTemplates = await loadTemplatesByCategory("base");
@@ -2783,6 +2810,7 @@ async function deployContext(options) {
2783
2810
  });
2784
2811
  }
2785
2812
  } else {
2813
+ const deployedStages = [];
2786
2814
  for (const stg of stages) {
2787
2815
  try {
2788
2816
  const content = await buildContext(tool, stg, variables);
@@ -2805,6 +2833,7 @@ async function deployContext(options) {
2805
2833
  if (merged) {
2806
2834
  console.log(` [merged] ${path6.relative(outputDir, absolutePath)}`);
2807
2835
  }
2836
+ deployedStages.push(stg);
2808
2837
  } catch (error) {
2809
2838
  results.push({
2810
2839
  tool,
@@ -2815,6 +2844,24 @@ async function deployContext(options) {
2815
2844
  });
2816
2845
  }
2817
2846
  }
2847
+ if (deployedStages.length > 0) {
2848
+ const pointerResult = await writeSplitPointer(
2849
+ tool,
2850
+ deployedStages,
2851
+ outputDir
2852
+ );
2853
+ results.push({
2854
+ tool,
2855
+ filePath: pointerResult.filePath,
2856
+ created: true,
2857
+ skipped: false
2858
+ });
2859
+ if (pointerResult.merged) {
2860
+ console.log(
2861
+ ` [merged] ${path6.relative(outputDir, pointerResult.filePath)}`
2862
+ );
2863
+ }
2864
+ }
2818
2865
  }
2819
2866
  }
2820
2867
  return results;