Skip to content

Commit 960a609

Browse files
fix(cli): align skill docs and command surface with actual CLI
Fixes drifts discovered by walking the skill rules end-to-end against the CLI on a real 47s recording. - output.ts: add outputList<T> so `--json` list commands emit the raw object array instead of the display-formatted `outputTable` keys (was: `{"Start (ms)": "2000"}`, now: `{"startMs": 2000}`). JSON list shape now matches JSON add shape, so agents can use a single parser - zoom/trim/speed/annotate list: migrated to outputList - project-manager.ts: `--wallpaper wallpaperN` now translates to `/wallpapers/wallpaperN.jpg` (the path the renderer expects) in both createProject and editProject. Hex colors and absolute paths pass through unchanged. Out-of-range N (e.g. wallpaper99) now errors with a specific message instead of silently storing garbage - render.ts + index.ts + mcp-server.ts: drop the `still` and `frames` stub commands that errored with "not yet implemented" at runtime. The command surface now matches what actually works so agents aren't led toward dead ends - SKILL.md: remove the stills/frames rule reference and description line. Delete cli/skills/rules/frames-stills.md entirely - troubleshooting.md: add the "Video decode ended early" gotcha with the ffprobe recipe (regions must fall within video duration — the CLI doesn't probe at add time). Correct the Electron install note - export-render.md: correct the progress JSON example (phase is only present during `finalizing`, not `extracting`); document the new `--loop`/`--no-loop` tri-state contract Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e636768 commit 960a609

13 files changed

Lines changed: 67 additions & 120 deletions

File tree

cli/skills/SKILL.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: >-
44
CLI for OpenScreen — screen recording and video editing.
55
Create, inspect, and modify .openscreen project files.
66
Add zoom/trim/speed effects and annotations.
7-
Capture stills, export frame sequences, render to MP4 or GIF.
7+
Render to MP4 or GIF.
88
metadata:
99
tags: [video, screen-recording, editing, export, cli]
1010
---
@@ -59,8 +59,5 @@ Load ./rules/annotations.md
5959
## When rendering or exporting
6060
Load ./rules/export-render.md
6161

62-
## When capturing frames or stills
63-
Load ./rules/frames-stills.md
64-
6562
## When troubleshooting
6663
Load ./rules/troubleshooting.md

cli/skills/rules/export-render.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@ The render command spawns OpenScreen's Electron renderer headlessly. It:
2525

2626
Progress is reported as JSON lines when using `--json`:
2727
```json
28-
{"progress": 45, "current": 135, "total": 300, "phase": "extracting"}
28+
{"progress": 45, "current": 135, "total": 300}
29+
{"progress": 100, "current": 300, "total": 300, "phase": "finalizing"}
30+
{"success": true, "path": "/abs/path/demo.mp4", "format": "mp4", "size": 1076106}
2931
```
3032

33+
Agents should parse the `done` line (with `success: true`) as the terminal event and use `path`/`size`/`format` from it. The `phase` key is only present during the `finalizing` phase; during frame extraction it is absent. On failure, a single `{"error": "..."}` line is emitted to stderr and the process exits non-zero.
34+
3135
## Rendering to GIF
3236

3337
```bash
@@ -36,14 +40,13 @@ openscreen gif \
3640
--output demo.gif \
3741
--frame-rate 15 \
3842
--size-preset medium \
39-
--loop \
4043
--overwrite
4144
```
4245

4346
### GIF settings
4447
- `--frame-rate` — 15 (balanced), 20 (smooth), 25 (very smooth), 30 (maximum)
4548
- `--size-preset` — medium (720p), large (1080p), original
46-
- `--loop` loop the GIF (default: true)
49+
- `--loop` / `--no-loop` — override the project's loop setting. If neither is passed, the GIF uses the project's `editor.gifLoop` (default: loop). Pass `--no-loop` to force a non-looping GIF regardless of project state.
4750

4851
### GIF best practices
4952
- **15 FPS** is usually enough for UI demos

cli/skills/rules/frames-stills.md

Lines changed: 0 additions & 56 deletions
This file was deleted.

cli/skills/rules/troubleshooting.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ openscreen --json project create --video /new/path/recording.webm --output demo.
2525
The project file is missing required fields. It needs: `version`, `media` (or `videoPath`), and `editor`.
2626

2727
### Render commands fail with "requires Electron headless bridge"
28-
The `render`, `gif`, `still`, and `frames` commands need the Electron app installed. These commands are Phase 3 features.
28+
The `render` and `gif` commands spawn a headless Electron process from the repo's `node_modules/.bin/electron`. Run `npm run build-vite` first so `dist-electron/main.js` exists.
29+
30+
### Render fails partway through: "Video decode ended early at X.Ys (needed Y.Ys)"
31+
A region (zoom/trim/speed/annotation) extends past the end of the underlying video. The CLI does not probe video duration at add time — it trusts the caller. Before adding regions with large timestamps, confirm the video is long enough (e.g. with `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 recording.webm`). Timestamps are in milliseconds and must fall within `[0, duration]`.
2932

3033
## Debugging tips
3134

@@ -59,5 +62,5 @@ echo "Exit code: $?"
5962
## Performance notes
6063

6164
- CLI commands that manipulate project files (create, edit, zoom/trim/speed/annotate add/remove) are pure Node.js and run in milliseconds
62-
- Render, still, and frames commands spawn Electron headlessly — expect seconds to minutes depending on video length and quality
65+
- `render` and `gif` spawn Electron headlessly — expect seconds to minutes depending on video length and quality
6366
- Use `--quiet` to suppress progress output for faster piped workflows

cli/src/commands/annotate.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
listAnnotationRegions,
55
removeAnnotationRegion,
66
} from "../core/region-manager";
7-
import { outputError, outputSuccess, outputTable } from "../output";
7+
import { outputError, outputList, outputSuccess } from "../output";
88

99
export const annotateCommand = new Command("annotate").description("Manage annotations");
1010

@@ -67,17 +67,17 @@ annotateCommand
6767
.action((opts) => {
6868
try {
6969
const regions = listAnnotationRegions(opts.project);
70-
outputTable(
71-
["ID", "Type", "Start (ms)", "End (ms)", "Position", "Content"],
72-
regions.map((r) => [
70+
outputList(regions, {
71+
headers: ["ID", "Type", "Start (ms)", "End (ms)", "Position", "Content"],
72+
rows: regions.map((r) => [
7373
r.id,
7474
r.type,
7575
String(r.startMs),
7676
String(r.endMs),
7777
`(${r.position.x}, ${r.position.y})`,
7878
(r.textContent || r.content || "").slice(0, 30),
7979
]),
80-
);
80+
});
8181
} catch (e) {
8282
outputError(e instanceof Error ? e.message : String(e));
8383
}

cli/src/commands/render.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -60,29 +60,3 @@ export const gifCommand = new Command("gif")
6060
outputError(e instanceof Error ? e.message : String(e));
6161
}
6262
});
63-
64-
export const stillCommand = new Command("still")
65-
.description("Capture a single frame as PNG or JPEG (requires built Electron app)")
66-
.requiredOption("--project <path>", "Path to .openscreen project file")
67-
.requiredOption("--output <path>", "Output image file path")
68-
.option("--frame <ms>", "Timestamp in milliseconds", "0")
69-
.option("--format <f>", "Image format (png, jpeg)", "png")
70-
.option("--jpeg-quality <q>", "JPEG quality (1-100)", "90")
71-
.option("--scale <n>", "Device scale factor", "1")
72-
.option("--overwrite", "Overwrite existing output file")
73-
.action((_opts) => {
74-
outputError("The still command is not yet implemented. Use render for full video export.");
75-
});
76-
77-
export const framesCommand = new Command("frames")
78-
.description("Export a sequence of frames as images (requires built Electron app)")
79-
.requiredOption("--project <path>", "Path to .openscreen project file")
80-
.requiredOption("--output-dir <dir>", "Output directory for frames")
81-
.option("--start <ms>", "Start timestamp in milliseconds", "0")
82-
.option("--end <ms>", "End timestamp (default: video end)")
83-
.option("--every-nth <n>", "Export every Nth frame", "1")
84-
.option("--format <f>", "Image format (png, jpeg)", "png")
85-
.option("--overwrite", "Overwrite existing output files")
86-
.action((_opts) => {
87-
outputError("The frames command is not yet implemented. Use render for full video export.");
88-
});

cli/src/commands/speed.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from "commander";
22
import { addSpeedRegion, listSpeedRegions, removeSpeedRegion } from "../core/region-manager";
3-
import { outputError, outputSuccess, outputTable } from "../output";
3+
import { outputError, outputList, outputSuccess } from "../output";
44

55
export const speedCommand = new Command("speed").description("Manage speed regions");
66

@@ -38,10 +38,10 @@ speedCommand
3838
.action((opts) => {
3939
try {
4040
const regions = listSpeedRegions(opts.project);
41-
outputTable(
42-
["ID", "Start (ms)", "End (ms)", "Speed"],
43-
regions.map((r) => [r.id, String(r.startMs), String(r.endMs), `${r.speed}x`]),
44-
);
41+
outputList(regions, {
42+
headers: ["ID", "Start (ms)", "End (ms)", "Speed"],
43+
rows: regions.map((r) => [r.id, String(r.startMs), String(r.endMs), `${r.speed}x`]),
44+
});
4545
} catch (e) {
4646
outputError(e instanceof Error ? e.message : String(e));
4747
}

cli/src/commands/trim.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from "commander";
22
import { addTrimRegion, listTrimRegions, removeTrimRegion } from "../core/region-manager";
3-
import { outputError, outputSuccess, outputTable } from "../output";
3+
import { outputError, outputList, outputSuccess } from "../output";
44

55
export const trimCommand = new Command("trim").description("Manage trim regions");
66

@@ -32,10 +32,10 @@ trimCommand
3232
.action((opts) => {
3333
try {
3434
const regions = listTrimRegions(opts.project);
35-
outputTable(
36-
["ID", "Start (ms)", "End (ms)"],
37-
regions.map((r) => [r.id, String(r.startMs), String(r.endMs)]),
38-
);
35+
outputList(regions, {
36+
headers: ["ID", "Start (ms)", "End (ms)"],
37+
rows: regions.map((r) => [r.id, String(r.startMs), String(r.endMs)]),
38+
});
3939
} catch (e) {
4040
outputError(e instanceof Error ? e.message : String(e));
4141
}

cli/src/commands/zoom.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from "commander";
22
import { addZoomRegion, listZoomRegions, removeZoomRegion } from "../core/region-manager";
3-
import { outputError, outputSuccess, outputTable } from "../output";
3+
import { outputError, outputList, outputSuccess } from "../output";
44

55
export const zoomCommand = new Command("zoom").description("Manage zoom regions");
66

@@ -40,9 +40,9 @@ zoomCommand
4040
.action((opts) => {
4141
try {
4242
const regions = listZoomRegions(opts.project);
43-
outputTable(
44-
["ID", "Start (ms)", "End (ms)", "Depth", "Focus X", "Focus Y", "Mode"],
45-
regions.map((r) => [
43+
outputList(regions, {
44+
headers: ["ID", "Start (ms)", "End (ms)", "Depth", "Focus X", "Focus Y", "Mode"],
45+
rows: regions.map((r) => [
4646
r.id,
4747
String(r.startMs),
4848
String(r.endMs),
@@ -51,7 +51,7 @@ zoomCommand
5151
r.focus.cy.toFixed(2),
5252
r.focusMode ?? "manual",
5353
]),
54-
);
54+
});
5555
} catch (e) {
5656
outputError(e instanceof Error ? e.message : String(e));
5757
}

cli/src/core/project-manager.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ export function saveProject(projectPath: string, data: EditorProjectData): void
104104
fs.renameSync(tmpPath, absPath);
105105
}
106106

107+
// Translate wallpaper shortcuts (wallpaper1..wallpaper18) into the bundled
108+
// path the renderer expects (/wallpapers/wallpaperN.jpg). Hex colors, absolute
109+
// paths, and anything already matching a known wallpaper path pass through
110+
// unchanged so GUI-saved projects stay valid.
111+
function normalizeWallpaperInput(value: string): string {
112+
const match = /^wallpaper(\d+)$/.exec(value);
113+
if (!match) return value;
114+
const index = Number.parseInt(match[1], 10);
115+
if (!Number.isFinite(index) || index < 1 || index > WALLPAPER_PATHS.length) {
116+
throw new Error(
117+
`Invalid wallpaper shortcut: ${value}. Expected wallpaper1 through wallpaper${WALLPAPER_PATHS.length}, a hex color (#rrggbb), or an absolute path.`,
118+
);
119+
}
120+
return WALLPAPER_PATHS[index - 1];
121+
}
122+
107123
export function createProject(
108124
options: CreateProjectOptions,
109125
outputPath: string,
@@ -131,7 +147,8 @@ export function createProject(
131147

132148
const editorOverrides: Partial<ProjectEditorState> = {};
133149

134-
if (options.wallpaper !== undefined) editorOverrides.wallpaper = options.wallpaper;
150+
if (options.wallpaper !== undefined)
151+
editorOverrides.wallpaper = normalizeWallpaperInput(options.wallpaper);
135152
if (options.padding !== undefined) editorOverrides.padding = options.padding;
136153
if (options.borderRadius !== undefined) editorOverrides.borderRadius = options.borderRadius;
137154
if (options.aspectRatio !== undefined) editorOverrides.aspectRatio = options.aspectRatio;
@@ -211,7 +228,8 @@ export function editProject(projectPath: string, edits: EditProjectOptions): Edi
211228

212229
const updatedEditor: ProjectEditorState = { ...project.editor };
213230

214-
if (edits.wallpaper !== undefined) updatedEditor.wallpaper = edits.wallpaper;
231+
if (edits.wallpaper !== undefined)
232+
updatedEditor.wallpaper = normalizeWallpaperInput(edits.wallpaper);
215233
if (edits.padding !== undefined) updatedEditor.padding = edits.padding;
216234
if (edits.borderRadius !== undefined) updatedEditor.borderRadius = edits.borderRadius;
217235
if (edits.shadowIntensity !== undefined) updatedEditor.shadowIntensity = edits.shadowIntensity;

0 commit comments

Comments
 (0)