chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
# Openscreen: Project-Specific Analysis & SOP
## Architecture Summary
Openscreen is an Electron-based screen recording editor built with React, PixiJS,
and the WebCodecs API. It records screen captures and provides a timeline editor
for adding zoom effects, speed changes, trims, annotations, crop, and backgrounds.
```
┌──────────────────────────────────────────────────────────────┐
│ Openscreen Electron App (GUI) │
│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
│ │ React UI Components │ │ Video Editor (PixiJS canvas) │ │
│ │ - Timeline │ │ - Zoom transform │ │
│ │ - Settings panel │ │ - Motion blur filter │ │
│ │ - Annotations │ │ - Shadow compositing │ │
│ └──────────┬───────────┘ └──────────────┬───────────────┘ │
│ │ │ │
│ ┌──────────▼──────────────────────────────▼───────────────┐ │
│ │ WebCodecs Pipeline │ │
│ │ web-demuxer → VideoDecoder → FrameRenderer → Encoder │ │
│ │ → mediabunny │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
.openscreen (JSON) project file
┌──────────────────────────────────────────────────────────────┐
│ CLI Harness (this project) │
│ ┌─────────────────────┐ ┌──────────────────────────────┐ │
│ │ Click CLI + REPL │ │ ffmpeg backend │ │
│ │ - Project CRUD │ │ - crop+scale (zoom) │ │
│ │ - Region management│ │ - setpts+atempo (speed) │ │
│ │ - Session/undo │ │ - concat (segments) │ │
│ └──────────┬──────────┘ │ - overlay (background) │ │
│ │ └──────────────┬───────────────┘ │
│ ▼ ▼ │
│ JSON state Rendered MP4 │
└──────────────────────────────────────────────────────────────┘
```
## The .openscreen Project Format
Openscreen saves projects as JSON files with the `.openscreen` extension.
The CLI harness reads and writes this format directly.
### Structure
```json
{
"version": 2,
"media": {
"screenVideoPath": "/path/to/recording.webm",
"webcamVideoPath": "/path/to/webcam.webm"
},
"editor": {
"wallpaper": "gradient_dark",
"shadowIntensity": 0,
"showBlur": false,
"motionBlurAmount": 0,
"borderRadius": 12,
"padding": 50,
"cropRegion": { "x": 0, "y": 0, "width": 1, "height": 1 },
"zoomRegions": [
{
"id": "zoom_1",
"startMs": 2500,
"endMs": 5000,
"depth": 3,
"focus": { "cx": 0.7, "cy": 0.3 },
"focusMode": "manual"
}
],
"speedRegions": [
{ "id": "speed_1", "startMs": 10000, "endMs": 15000, "speed": 2.0 }
],
"trimRegions": [
{ "id": "trim_1", "startMs": 0, "endMs": 500 }
],
"annotationRegions": [],
"aspectRatio": "16:9",
"exportQuality": "good",
"exportFormat": "mp4"
}
}
```
### Key Concepts
| Concept | JSON Path | Description |
|---------|-----------|-------------|
| Zoom | `editor.zoomRegions[]` | Camera zoom with focus point and depth 1-6 |
| Speed | `editor.speedRegions[]` | Playback speed 0.25x-2.0x for time ranges |
| Trim | `editor.trimRegions[]` | Cut out sections of the recording |
| Crop | `editor.cropRegion` | Normalized 0-1 rectangle for visible area |
| Annotation | `editor.annotationRegions[]` | Text/image/arrow overlays |
| Background | `editor.wallpaper` | Gradient, solid color, or blur |
| Padding | `editor.padding` | 0-100, space around video in canvas |
## CLI Strategy
### What We Manipulate Directly
- The `.openscreen` JSON project file (read, write, modify all fields)
- Region arrays (zoom, speed, trim, annotation) — add, remove, list
- Editor settings (wallpaper, padding, aspect ratio, quality)
### What We Delegate to ffmpeg
- Video probing (ffprobe)
- Segment rendering (crop, scale, speed changes)
- Segment concatenation
- Background compositing (color overlay)
- Frame extraction (for thumbnails)
## The Rendering Pipeline
### 1. Segment-based rendering (primary approach)
```
Source video
→ Split into segments at region boundaries
→ For each segment:
→ Apply crop (if zoom: crop to focus region)
→ Scale to target resolution
→ Apply speed change (setpts + atempo)
→ Encode as H.264 MP4
→ Concatenate all segments (stream copy)
→ Composite onto background canvas (color overlay)
→ Final output MP4
```
### 2. Zoom implementation
The GUI uses PixiJS `zoompan` with smooth easing. The CLI approximates this
with `crop+scale`: for a zoom depth of 3 (1.8x), we crop a 1/1.8-sized region
centered on the focus point, then scale it back up to target resolution.
### 3. Speed implementation
Speed regions use ffmpeg's `setpts` (video) and `atempo` (audio) filters.
For speeds outside atempo's 0.5-2.0 range, filters are chained.
## Command Map: GUI Action → CLI Command
| GUI Action | CLI Command |
|-----------|-------------|
| Record screen | (external — use macOS screen recording or Openscreen app) |
| Open project | `project open <path>` |
| Save project | `project save [-o path]` |
| Set background | `project set wallpaper gradient_dark` |
| Set padding | `project set padding 40` |
| Set aspect ratio | `project set aspectRatio 16:9` |
| Add zoom region | `zoom add --start 2500 --end 5000 --depth 3 --focus-x 0.7 --focus-y 0.3` |
| Add speed region | `speed add --start 10000 --end 15000 --speed 2.0` |
| Trim section | `trim add --start 0 --end 500` |
| Add text annotation | `annotation add-text --start 5000 --end 7000 --text "Click here"` |
| Crop video | `crop set --x 0.1 --y 0.1 --width 0.8 --height 0.8` |
| Export video | `export render output.mp4` |
| Undo | `session undo` |
## Verified Workflow: Demo Video Enhancement
1. `project new -v ~/Desktop/recording.mov`
2. `zoom add --start 2500 --end 5000 --depth 3 --focus-x 0.8 --focus-y 0.3`
3. `speed add --start 10000 --end 15000 --speed 2.0`
4. `trim add --start 0 --end 500`
5. `project set wallpaper gradient_dark`
6. `project set padding 40`
7. `export render ~/Desktop/demo.mp4`
**Verification results:**
- Output: 1920x1080 H.264 MP4
- Zoom region correctly crops and scales source
- Speed region reduces segment duration by expected factor
- Trim region excluded from output
- Background composited with centered video and padding
## Test Coverage
**101 total tests** across two suites:
- `test_core.py` (78 tests): Session, project, zoom, speed, trim, crop, annotation, integration
- `test_full_e2e.py` (23 tests): Real ffmpeg rendering, CLI subprocess, media probing
+142
View File
@@ -0,0 +1,142 @@
# Openscreen CLI — Test Results
## Summary
- **Total tests**: 101
- **Passed**: 101
- **Failed**: 0
- **Test suites**: 2 (unit + end-to-end)
## Test Suites
### test_core.py — Unit Tests (78 tests, no ffmpeg required)
| # | Test | Status |
|---|------|--------|
| 1 | TestSession::test_new_session | PASSED |
| 2 | TestSession::test_new_session_with_id | PASSED |
| 3 | TestSession::test_new_project | PASSED |
| 4 | TestSession::test_new_project_with_video | PASSED |
| 5 | TestSession::test_undo_redo | PASSED |
| 6 | TestSession::test_undo_clears_redo | PASSED |
| 7 | TestSession::test_save_load_project | PASSED |
| 8 | TestSession::test_open_nonexistent | PASSED |
| 9 | TestSession::test_save_without_path | PASSED |
| 10 | TestSession::test_status | PASSED |
| 11 | TestSession::test_editor_raises_when_not_open | PASSED |
| 12 | TestSession::test_checkpoint_adds_to_undo | PASSED |
| 13 | TestSession::test_undo_stack_limit_50 | PASSED |
| 14 | TestSession::test_is_modified_after_checkpoint | PASSED |
| 15 | TestSession::test_open_invalid_json_raises | PASSED |
| 16 | TestProject::test_new_project | PASSED |
| 17 | TestProject::test_info | PASSED |
| 18 | TestProject::test_info_without_project | PASSED |
| 19 | TestProject::test_set_setting | PASSED |
| 20 | TestProject::test_set_invalid_setting | PASSED |
| 21 | TestProject::test_set_aspect_ratio_valid | PASSED |
| 22 | TestProject::test_set_aspect_ratio_invalid | PASSED |
| 23 | TestProject::test_set_export_quality_valid | PASSED |
| 24 | TestProject::test_set_export_quality_invalid | PASSED |
| 25 | TestProject::test_set_export_format_valid | PASSED |
| 26 | TestProject::test_set_export_format_invalid | PASSED |
| 27 | TestProject::test_set_padding_valid | PASSED |
| 28 | TestProject::test_set_padding_invalid | PASSED |
| 29 | TestProject::test_set_shadow_intensity_range | PASSED |
| 30 | TestProject::test_set_border_radius_negative | PASSED |
| 31 | TestZoom::test_add_zoom | PASSED |
| 32 | TestZoom::test_list_zoom | PASSED |
| 33 | TestZoom::test_remove_zoom | PASSED |
| 34 | TestZoom::test_remove_nonexistent_zoom | PASSED |
| 35 | TestZoom::test_invalid_depth | PASSED |
| 36 | TestZoom::test_invalid_focus | PASSED |
| 37 | TestZoom::test_invalid_time_range | PASSED |
| 38 | TestZoom::test_zoom_undo | PASSED |
| 39 | TestZoom::test_add_zoom_with_focus | PASSED |
| 40 | TestZoom::test_list_zoom_sorted | PASSED |
| 41 | TestZoom::test_update_zoom_region | PASSED |
| 42 | TestZoom::test_depth_map_values | PASSED |
| 43 | TestZoom::test_add_zoom_calls_checkpoint | PASSED |
| 44 | TestSpeed::test_add_speed | PASSED |
| 45 | TestSpeed::test_invalid_speed | PASSED |
| 46 | TestSpeed::test_list_and_remove | PASSED |
| 47 | TestSpeed::test_all_valid_speed_values | PASSED |
| 48 | TestSpeed::test_list_speed_sorted | PASSED |
| 49 | TestSpeed::test_remove_speed_nonexistent | PASSED |
| 50 | TestTrim::test_add_trim | PASSED |
| 51 | TestTrim::test_list_and_remove | PASSED |
| 52 | TestTrim::test_add_trim_zero_start | PASSED |
| 53 | TestTrim::test_list_trim_sorted | PASSED |
| 54 | TestTrim::test_remove_trim_nonexistent | PASSED |
| 55 | TestCrop::test_default_crop | PASSED |
| 56 | TestCrop::test_set_crop | PASSED |
| 57 | TestCrop::test_invalid_crop | PASSED |
| 58 | TestCrop::test_crop_out_of_bounds | PASSED |
| 59 | TestCrop::test_set_crop_overflow_x | PASSED |
| 60 | TestCrop::test_set_crop_overflow_y | PASSED |
| 61 | TestCrop::test_set_crop_negative_raises | PASSED |
| 62 | TestCrop::test_set_crop_calls_checkpoint | PASSED |
| 63 | TestCrop::test_crop_full_frame_valid | PASSED |
| 64 | TestAnnotation::test_add_text_annotation | PASSED |
| 65 | TestAnnotation::test_list_and_remove | PASSED |
| 66 | TestAnnotation::test_add_annotation_position | PASSED |
| 67 | TestAnnotation::test_list_annotations_sorted | PASSED |
| 68 | TestAnnotation::test_update_annotation_text | PASSED |
| 69 | TestAnnotation::test_update_annotation_nonexistent | PASSED |
| 70 | TestAnnotation::test_annotation_style_fields | PASSED |
| 71 | TestIntegration::test_full_workflow | PASSED |
| 72 | TestIntegration::test_export_presets | PASSED |
| 73 | TestIntegration::test_undo_redo_zoom_workflow | PASSED |
| 74 | TestIntegration::test_multiple_undo_levels | PASSED |
| 75 | TestIntegration::test_timeline_boundaries | PASSED |
| 76 | TestIntegration::test_active_regions_at | PASSED |
| 77 | TestIntegration::test_project_set_triggers_undo | PASSED |
| 78 | TestProject::test_crop_region_overflow_raises | PASSED |
### test_full_e2e.py — End-to-End Tests (23 tests, requires ffmpeg)
| # | Test | Status |
|---|------|--------|
| 79 | TestMediaE2E::test_probe_real_video | PASSED |
| 80 | TestMediaE2E::test_check_video | PASSED |
| 81 | TestMediaE2E::test_check_invalid_video | PASSED |
| 82 | TestMediaE2E::test_extract_thumbnail | PASSED |
| 83 | TestMediaE2E::test_extract_thumbnail_at_zero | PASSED |
| 84 | TestMediaE2E::test_extract_frames | PASSED |
| 85 | TestMediaE2E::test_ffmpeg_and_ffprobe_found | PASSED |
| 86 | TestExportE2E::test_basic_export | PASSED |
| 87 | TestExportE2E::test_export_with_zoom | PASSED |
| 88 | TestExportE2E::test_export_with_speed | PASSED |
| 89 | TestExportE2E::test_export_with_trim | PASSED |
| 90 | TestExportE2E::test_export_complex | PASSED |
| 91 | TestExportE2E::test_export_no_video_raises | PASSED |
| 92 | TestExportE2E::test_export_missing_video_raises | PASSED |
| 93 | TestCLISubprocess::test_cli_help | PASSED |
| 94 | TestCLISubprocess::test_cli_version | PASSED |
| 95 | TestCLISubprocess::test_cli_export_presets | PASSED |
| 96 | TestCLISubprocess::test_cli_media_probe | PASSED |
| 97 | TestCLISubprocess::test_cli_project_new_json | PASSED |
| 98 | TestCLISubprocess::test_cli_zoom_add | PASSED |
| 99 | TestCLISubprocess::test_cli_full_workflow | PASSED |
| 100 | TestCLISubprocess::test_cli_media_check_valid | PASSED |
| 101 | TestCLISubprocess::test_cli_session_status | PASSED |
## Raw pytest output
```
============================= test session starts ==============================
platform linux -- Python 3.11.2, pytest-9.0.2, pluggy-1.6.0
collected 101 items
cli_anything/openscreen/tests/test_core.py ............................................... [ 76%]
............................... [ 77%]
cli_anything/openscreen/tests/test_full_e2e.py ....................... [100%]
============================= 101 passed in 35.19s =============================
```
## Environment
- Python: 3.11.2
- pytest: 9.0.2
- ffmpeg: 5.1.6
- OS: Linux (Debian)
@@ -0,0 +1,75 @@
# cli-anything-openscreen
CLI harness for [Openscreen](https://github.com/siddharthvaddem/openscreen) — a screen recording editor.
Edit screen recordings via command line: add zoom effects, speed ramps, trim sections, crop, annotate, set backgrounds, and export polished demo videos.
## Installation
```bash
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=openscreen/agent-harness
```
**Prerequisites:**
- Python 3.10+
- ffmpeg (for rendering/export)
## Quick Start
```bash
# Interactive REPL
cli-anything-openscreen
# Create project from a recording
cli-anything-openscreen project new -v recording.mp4 -o project.openscreen
# Add zoom on a click moment (2.5s-5s, depth 3, focus on button)
cli-anything-openscreen zoom add --start 2500 --end 5000 --depth 3 --focus-x 0.8 --focus-y 0.3
# Speed up idle time (10s-15s at 2x)
cli-anything-openscreen speed add --start 10000 --end 15000 --speed 2.0
# Export
cli-anything-openscreen export render demo.mp4
# JSON output for AI agents
cli-anything-openscreen --json project info
```
## Preview Bundles
Openscreen supports static preview bundles for review-ready editing checks.
```bash
# Capture a low-res preview bundle
cli-anything-openscreen --json --project project.openscreen preview capture --recipe quick
# Read the latest bundle
cli-anything-openscreen --json --project project.openscreen preview latest --recipe quick
```
The preview bundle contains a low-res review clip, sampled frames, and summary
metadata. Capture also appends to a stable `trajectory.json` beside the preview
recipe root.
Inspect or open the resulting bundle with:
```bash
cli-hub previews inspect /path/to/bundle
cli-hub previews html /path/to/bundle -o page.html
cli-hub previews open /path/to/bundle
```
## Command Groups
| Group | Commands |
|-------|----------|
| `project` | new, open, save, info, set-video, set |
| `zoom` | list, add, remove |
| `speed` | list, add, remove |
| `trim` | list, add, remove |
| `crop` | get, set |
| `annotation` | list, add-text, remove |
| `media` | probe, check, thumbnail |
| `export` | presets, render |
| `session` | status, undo, redo, save, list |
@@ -0,0 +1,3 @@
"""Openscreen CLI package."""
__version__ = "1.0.0"
@@ -0,0 +1,4 @@
"""Allow running as python3 -m cli_anything.openscreen."""
from .openscreen_cli import cli
cli()
@@ -0,0 +1,269 @@
"""Export pipeline — render the final video from project state.
Strategy:
1. Read project editor state (zoom, speed, trim, crop, background, padding)
2. Split video into segments based on region boundaries
3. Render each segment with ffmpeg (crop for zoom, setpts for speed)
4. Concatenate segments
5. Composite onto background canvas with padding
6. Verify output with ffprobe
"""
import os
import tempfile
from typing import Optional, Callable
from .session import Session
from ..utils import ffmpeg_backend
ZOOM_SCALES = {1: 1.25, 2: 1.5, 3: 1.8, 4: 2.2, 5: 3.5, 6: 5.0}
ASPECT_DIMENSIONS = {
"16:9": (1920, 1080),
"9:16": (1080, 1920),
"1:1": (1080, 1080),
"4:3": (1440, 1080),
"4:5": (1080, 1350),
"16:10": (1920, 1200),
"10:16": (1200, 1920),
}
BG_COLORS = {
"gradient_dark": "#1a1a2e",
"solid_dark": "#1a1a2e",
"gradient_light": "#fdf6f0",
"solid_light": "#f5f5f5",
"gradient_sunset": "#2d1b3d",
"blur": "#1a1a2e",
}
def list_presets() -> list[dict]:
"""List available export presets."""
return [
{"name": "mp4_good", "format": "mp4", "quality": "good", "description": "MP4 H.264, balanced quality"},
{"name": "mp4_source", "format": "mp4", "quality": "source", "description": "MP4 H.264, source quality"},
{"name": "mp4_medium", "format": "mp4", "quality": "medium", "description": "MP4 H.264, smaller file"},
{"name": "gif_medium", "format": "gif", "quality": "medium", "description": "GIF, 720p, 15fps"},
]
def render(
session: Session,
output_path: str,
on_progress: Optional[Callable] = None,
) -> dict:
"""Render the project to a final video file.
Args:
session: Active session with open project.
output_path: Destination file path.
on_progress: Optional callback(stage, message).
Returns:
Dict with output path, file_size, duration, resolution.
"""
if not session.is_open:
raise RuntimeError("No project is open")
editor = session.editor
media = session.data.get("media", {})
video_path = media.get("screenVideoPath")
if not video_path:
video_path = session.data.get("videoPath")
if not video_path or not os.path.isfile(video_path):
raise FileNotFoundError(
f"Source video not found: {video_path}. "
"Set it with: project set-video <path>"
)
# Probe source
if on_progress:
on_progress("probe", "Probing source video...")
meta = ffmpeg_backend.probe(video_path)
src_w, src_h = meta["width"], meta["height"]
duration = meta["duration"]
fps = min(int(meta["fps"]), 30)
# Read editor state
zoom_regions = editor.get("zoomRegions", [])
speed_regions = editor.get("speedRegions", [])
trim_regions = editor.get("trimRegions", [])
crop_region = editor.get("cropRegion", {"x": 0, "y": 0, "width": 1, "height": 1})
aspect_ratio = editor.get("aspectRatio", "16:9")
padding_pct = editor.get("padding", 50)
background = editor.get("wallpaper", "gradient_dark")
# Calculate dimensions
out_w, out_h = ASPECT_DIMENSIONS.get(aspect_ratio, (1920, 1080))
padding_scale = 1.0 - (padding_pct / 100.0) * 0.4
vid_w = int(out_w * padding_scale)
vid_h = int(out_h * padding_scale)
# Apply crop to source dimensions
crop_x = int(crop_region["x"] * src_w)
crop_y = int(crop_region["y"] * src_h)
crop_w = int(crop_region["width"] * src_w)
crop_h = int(crop_region["height"] * src_h)
effective_w = crop_w
effective_h = crop_h
# Maintain aspect ratio within padded area
src_ar = effective_w / effective_h
if vid_w / vid_h > src_ar:
vid_w = int(vid_h * src_ar)
else:
vid_h = int(vid_w / src_ar)
vid_w -= vid_w % 2
vid_h -= vid_h % 2
out_w -= out_w % 2
out_h -= out_h % 2
# Build timeline segments
trim_ranges = [(t["startMs"] / 1000, t["endMs"] / 1000) for t in trim_regions]
def is_trimmed(t):
return any(ts <= t <= te for ts, te in trim_ranges)
def get_speed_at(t):
for sr in speed_regions:
if sr["startMs"] / 1000 <= t < sr["endMs"] / 1000:
return sr["speed"]
return 1.0
def get_zoom_at(t):
for zr in zoom_regions:
if zr["startMs"] / 1000 <= t < zr["endMs"] / 1000:
return zr
return None
# Event boundaries
events = sorted(set(
[0.0, duration]
+ [z["startMs"] / 1000 for z in zoom_regions]
+ [z["endMs"] / 1000 for z in zoom_regions]
+ [s["startMs"] / 1000 for s in speed_regions]
+ [s["endMs"] / 1000 for s in speed_regions]
+ [t["startMs"] / 1000 for t in trim_regions]
+ [t["endMs"] / 1000 for t in trim_regions]
))
events = [e for e in events if 0 <= e <= duration]
segments = []
for i in range(len(events) - 1):
s, e = events[i], events[i + 1]
if e - s < 0.05:
continue
mid = (s + e) / 2
if is_trimmed(mid):
continue
segments.append({
"start": s, "end": e,
"speed": get_speed_at(mid),
"zoom": get_zoom_at(mid),
})
if not segments:
segments = [{"start": 0, "end": duration, "speed": 1.0, "zoom": None}]
if on_progress:
on_progress("timeline", f"Built {len(segments)} segments")
# Render segments
tmpdir = tempfile.mkdtemp(prefix="openscreen_export_")
seg_files = []
for idx, seg in enumerate(segments):
seg_file = os.path.join(tmpdir, f"seg_{idx:04d}.mp4")
if on_progress:
on_progress("segment", f"Segment {idx+1}/{len(segments)}")
crop = None
if seg["zoom"]:
zr = seg["zoom"]
zscale = ZOOM_SCALES.get(zr.get("depth", 3), 1.8)
fx = max(0.05, min(0.95, zr["focus"]["cx"]))
fy = max(0.05, min(0.95, zr["focus"]["cy"]))
zw = int(effective_w / zscale)
zh = int(effective_h / zscale)
zw -= zw % 2
zh -= zh % 2
zx = max(0, min(effective_w - zw, int(fx * effective_w - zw / 2)))
zy = max(0, min(effective_h - zh, int(fy * effective_h - zh / 2)))
# Offset by crop region
crop = {
"w": zw, "h": zh,
"x": crop_x + zx, "y": crop_y + zy,
}
elif crop_region != {"x": 0, "y": 0, "width": 1, "height": 1}:
crop = {"w": crop_w, "h": crop_h, "x": crop_x, "y": crop_y}
ffmpeg_backend.render_segment(
input_path=video_path,
output_path=seg_file,
start_s=seg["start"],
end_s=seg["end"],
target_w=vid_w,
target_h=vid_h,
fps=fps,
speed=seg["speed"],
crop=crop,
)
if os.path.exists(seg_file):
seg_files.append(seg_file)
if not seg_files:
raise RuntimeError("No segments rendered successfully")
# Concat
if on_progress:
on_progress("concat", f"Concatenating {len(seg_files)} segments")
concat_out = os.path.join(tmpdir, "concat.mp4")
ffmpeg_backend.concat_segments(seg_files, concat_out)
# Composite on background
if on_progress:
on_progress("composite", "Adding background and padding")
bg_color = BG_COLORS.get(background, "#1a1a2e")
ffmpeg_backend.composite_on_background(
input_path=concat_out,
output_path=output_path,
canvas_w=out_w,
canvas_h=out_h,
video_w=vid_w,
video_h=vid_h,
bg_color=bg_color,
fps=fps,
)
# Verify output
if on_progress:
on_progress("verify", "Verifying output...")
out_meta = ffmpeg_backend.probe(output_path)
# Cleanup
for f in seg_files + [concat_out]:
try:
os.remove(f)
except OSError:
pass
try:
os.rmdir(tmpdir)
except OSError:
pass
return {
"output": os.path.abspath(output_path),
"file_size": out_meta["file_size"],
"duration": out_meta["duration"],
"width": out_meta["width"],
"height": out_meta["height"],
"codec": out_meta["codec"],
"segments_rendered": len(seg_files),
}
@@ -0,0 +1,71 @@
"""Media operations — probe input files, extract thumbnails."""
import os
from ..utils import ffmpeg_backend
def probe(path: str) -> dict:
"""Probe a media file and return metadata.
Returns dict with: width, height, duration, fps, codec,
has_audio, file_size, path.
"""
return ffmpeg_backend.probe(path)
def check_video(path: str) -> dict:
"""Check if a video file is valid and usable.
Returns dict with validity status and metadata.
"""
if not os.path.isfile(path):
return {"valid": False, "error": f"File not found: {path}"}
try:
meta = ffmpeg_backend.probe(path)
return {
"valid": True,
"width": meta["width"],
"height": meta["height"],
"duration": meta["duration"],
"fps": meta["fps"],
"codec": meta["codec"],
"has_audio": meta["has_audio"],
"file_size": meta["file_size"],
}
except Exception as e:
return {"valid": False, "error": str(e)}
def extract_thumbnail(input_path: str, output_path: str,
time_s: float = 0.0) -> dict:
"""Extract a single frame as a JPEG thumbnail.
Args:
input_path: Source video.
output_path: Destination JPEG path.
time_s: Timestamp to capture (seconds).
Returns:
Dict with output path and file_size.
"""
import subprocess
exe = ffmpeg_backend.find_ffmpeg()
cmd = [
exe, "-y", "-ss", str(time_s),
"-i", input_path,
"-frames:v", "1",
"-q:v", "2",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"Thumbnail extraction failed: {result.stderr[:500]}")
return {
"output": os.path.abspath(output_path),
"file_size": os.path.getsize(output_path),
"time_s": time_s,
}
@@ -0,0 +1,266 @@
"""Preview bundle generation for the Openscreen harness."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..utils.preview_bundle import (
append_live_trajectory,
artifact_record,
bundle_root,
finalize_bundle,
find_latest_manifest,
fingerprint_data,
fingerprint_file,
prepare_bundle,
)
from . import export as export_mod
from . import media as media_mod
from .session import Session
HARNESS_VERSION = "1.0.0"
RECIPES: Dict[str, Dict[str, Any]] = {
"quick": {
"description": "Render a review clip and extract sampled frames",
"thumbnail_times": [0.0, 0.25, 0.5, 0.75, 0.95],
},
}
def list_recipes() -> List[Dict[str, Any]]:
"""Return available preview recipes."""
return [
{
"name": name,
"description": config["description"],
"bundle_kind": "capture",
"artifacts": ["preview-clip", "hero", "gallery"],
}
for name, config in RECIPES.items()
]
def _project_fingerprint(session: Session) -> str:
if not session.is_open:
raise RuntimeError("No project is open")
media = session.data.get("media", {})
source_video = media.get("screenVideoPath") or session.data.get("videoPath")
payload: Dict[str, Any] = {
"project_path": session.project_path,
"session_id": session.session_id,
"project_data": session.data,
}
if source_video and os.path.isfile(source_video):
payload["source_video"] = {
"path": os.path.abspath(source_video),
"fingerprint": fingerprint_file(source_video),
}
return fingerprint_data(payload)
def _metrics(session: Session) -> Dict[str, Any]:
editor = session.editor
return {
"zoom_region_count": len(editor.get("zoomRegions", [])),
"speed_region_count": len(editor.get("speedRegions", [])),
"trim_region_count": len(editor.get("trimRegions", [])),
"annotation_count": len(editor.get("annotationRegions", [])),
"aspect_ratio": editor.get("aspectRatio", "16:9"),
"padding": editor.get("padding", 50),
"background": editor.get("wallpaper", "gradient_dark"),
}
def _trajectory_dir(session: Session, recipe: str, root_dir: Optional[str] = None) -> str:
return str(
bundle_root(
"openscreen",
recipe,
project_path=session.project_path,
root_dir=root_dir,
).resolve()
)
def _attach_trajectory_ref(manifest: Dict[str, Any]) -> Dict[str, Any]:
bundle_dir = manifest.get("_bundle_dir")
context = manifest.get("context") or {}
trajectory_ref = context.get("trajectory_path")
if bundle_dir and trajectory_ref:
trajectory_path = (Path(str(bundle_dir)) / str(trajectory_ref)).resolve()
if trajectory_path.is_file():
manifest["_trajectory_path"] = str(trajectory_path)
return manifest
def capture(
session: Session,
recipe: str = "quick",
*,
root_dir: Optional[str] = None,
force: bool = False,
command: Optional[str] = None,
) -> Dict[str, Any]:
"""Render a preview bundle for the active Openscreen project."""
if not session.is_open:
raise RuntimeError("No project is open")
if recipe not in RECIPES:
raise ValueError(
f"Unknown preview recipe: {recipe!r}. Available: {', '.join(sorted(RECIPES))}"
)
config = RECIPES[recipe]
source_fingerprint = _project_fingerprint(session)
prepared = prepare_bundle(
software="openscreen",
recipe=recipe,
bundle_kind="capture",
source_fingerprint=source_fingerprint,
options=config,
harness_version=HARNESS_VERSION,
project_path=session.project_path,
root_dir=root_dir,
force=force,
)
if prepared["cached"]:
manifest = dict(prepared["manifest"])
manifest["cached"] = True
return _attach_trajectory_ref(manifest)
bundle_dir = prepared["bundle_dir"]
artifacts_dir = prepared["artifacts_dir"]
trajectory_dir = _trajectory_dir(session, recipe, root_dir=root_dir)
trajectory_rel = os.path.relpath(Path(trajectory_dir) / "trajectory.json", Path(bundle_dir))
preview_clip = os.path.join(artifacts_dir, "preview.mp4")
render_result = export_mod.render(session, preview_clip)
clip_meta = media_mod.probe(preview_clip)
duration_s = float(clip_meta.get("duration", render_result.get("duration", 0.0)) or 0.0)
width = int(clip_meta.get("width", render_result.get("width", 0)) or 0)
height = int(clip_meta.get("height", render_result.get("height", 0)) or 0)
warnings: List[str] = []
artifacts = [
artifact_record(
bundle_dir,
preview_clip,
artifact_id="clip",
role="preview-clip",
kind="clip",
label="Rendered preview clip",
width=width or None,
height=height or None,
duration_s=round(duration_s, 3),
segments_rendered=render_result.get("segments_rendered"),
)
]
for index, ratio in enumerate(config["thumbnail_times"]):
capture_time = duration_s * ratio if duration_s > 0 else 0.0
image_path = os.path.join(artifacts_dir, f"frame_{index + 1:02d}.jpg")
try:
media_mod.extract_thumbnail(preview_clip, image_path, capture_time)
except Exception as exc:
warnings.append(f"frame sample {index + 1} failed: {exc}")
continue
role = "hero" if abs(ratio - 0.5) < 1e-9 else "gallery"
label = "Midpoint frame" if role == "hero" else f"Sample frame {index + 1}"
artifacts.append(
artifact_record(
bundle_dir,
image_path,
artifact_id=f"frame_{index + 1:02d}",
role=role,
kind="image",
label=label,
time_s=round(capture_time, 3),
)
)
metrics = _metrics(session)
summary = {
"headline": (
f"Openscreen {recipe} preview rendered"
+ (f" at {width}x{height}" if width and height else "")
),
"facts": {
"recipe": recipe,
"duration_s": round(duration_s, 3),
"resolution": f"{width}x{height}" if width and height else "unknown",
**metrics,
},
"warnings": warnings,
"next_actions": [
"Inspect the review clip for zoom timing, speed ramps, and padding.",
"Open the bundle in cli-hub previews html for a gallery layout.",
],
}
source_video = session.data.get("media", {}).get("screenVideoPath") or session.data.get("videoPath")
manifest = finalize_bundle(
bundle_dir=bundle_dir,
bundle_id=prepared["bundle_id"],
bundle_kind="capture",
software="openscreen",
recipe=recipe,
source={
"project_path": session.project_path,
"project_name": os.path.basename(session.project_path) if session.project_path else None,
"project_fingerprint": source_fingerprint,
"source_video": source_video,
"session_id": session.session_id,
},
artifacts=artifacts,
summary=summary,
cache_key=prepared["cache_key"],
generator={
"entry_point": "cli-anything-openscreen",
"harness_version": HARNESS_VERSION,
"command": command or f"cli-anything-openscreen preview capture --recipe {recipe}",
},
status="partial" if warnings else "ok",
warnings=warnings or None,
context={
"editor": {"aspectRatio": metrics["aspect_ratio"], "background": metrics["background"]},
"trajectory_path": trajectory_rel,
},
metrics=metrics,
labels=["video", "screen-recording", "preview"],
)
trajectory = append_live_trajectory(
trajectory_dir,
software="openscreen",
recipe=recipe,
bundle_manifest=manifest,
publish_reason="capture",
project_path=session.project_path,
project_name=os.path.basename(session.project_path) if session.project_path else None,
session_name=f"{os.path.splitext(os.path.basename(session.project_path or 'untitled'))[0]}-{recipe}",
command=command,
stage_label="preview-capture",
)
manifest["_trajectory_path"] = trajectory["_trajectory_path"]
manifest["cached"] = False
return manifest
def latest(
*,
project_path: Optional[str] = None,
recipe: Optional[str] = None,
root_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""Return the latest preview bundle manifest for Openscreen."""
manifest = find_latest_manifest(
software="openscreen",
recipe=recipe,
bundle_kind="capture",
project_path=project_path,
root_dir=root_dir,
)
if manifest is None:
raise FileNotFoundError("No Openscreen preview bundle found")
return _attach_trajectory_ref(manifest)
@@ -0,0 +1,202 @@
"""Project operations — new, open, save, info, set video source."""
import os
from typing import Optional
from .session import Session
from ..utils import ffmpeg_backend
# ── Aspect ratio presets ────────────────────────────────────────────────
ASPECT_RATIOS = {
"16:9": (1920, 1080),
"9:16": (1080, 1920),
"1:1": (1080, 1080),
"4:3": (1440, 1080),
"4:5": (1080, 1350),
"16:10": (1920, 1200),
"10:16": (1200, 1920),
}
BACKGROUNDS = [
"gradient_dark", "gradient_light", "gradient_sunset",
"solid_dark", "solid_light", "blur",
]
EXPORT_QUALITIES = ["medium", "good", "source"]
EXPORT_FORMATS = ["mp4", "gif"]
def new_project(session: Session, video_path: Optional[str] = None) -> dict:
"""Create a new Openscreen project.
Args:
session: The active session.
video_path: Optional path to a screen recording to attach.
Returns:
Project info dict.
"""
session.new_project(video_path)
result = {"status": "created", "video": None}
if video_path:
result["video"] = os.path.abspath(video_path)
return result
def open_project(session: Session, path: str) -> dict:
"""Open an existing .openscreen project file.
Returns:
Project info dict.
"""
session.open_project(path)
return info(session)
def save_project(session: Session, path: Optional[str] = None) -> dict:
"""Save the current project.
Returns:
Dict with saved path.
"""
saved = session.save_project(path)
return {"status": "saved", "path": saved}
def info(session: Session) -> dict:
"""Get project information.
Returns:
Dict with project metadata and editor state summary.
"""
if not session.is_open:
raise RuntimeError("No project is open")
editor = session.editor
media = session.data.get("media", {})
return {
"version": session.data.get("version", 1),
"project_path": session.project_path,
"modified": session.is_modified,
"video": media.get("screenVideoPath"),
"webcam_video": media.get("webcamVideoPath"),
"aspect_ratio": editor.get("aspectRatio", "16:9"),
"background": editor.get("wallpaper", "gradient_dark"),
"padding": editor.get("padding", 50),
"border_radius": editor.get("borderRadius", 12),
"shadow_intensity": editor.get("shadowIntensity", 0),
"motion_blur": editor.get("motionBlurAmount", 0),
"export_quality": editor.get("exportQuality", "good"),
"export_format": editor.get("exportFormat", "mp4"),
"zoom_regions": len(editor.get("zoomRegions", [])),
"speed_regions": len(editor.get("speedRegions", [])),
"trim_regions": len(editor.get("trimRegions", [])),
"annotations": len(editor.get("annotationRegions", [])),
}
def set_video(session: Session, video_path: str) -> dict:
"""Set the source video for the project."""
if not session.is_open:
raise RuntimeError("No project is open")
path = os.path.abspath(video_path)
if not os.path.isfile(path):
raise FileNotFoundError(f"Video file not found: {path}")
session.checkpoint()
if "media" not in session.data:
session.data["media"] = {}
session.data["media"]["screenVideoPath"] = path
return {"status": "ok", "video": path}
def _validate_crop_region(region) -> None:
"""Validate a cropRegion dict.
All four fields (x, y, width, height) must be present and in [0, 1].
x + width must be <= 1 and y + height must be <= 1.
Raises:
ValueError: If validation fails.
"""
if not isinstance(region, dict):
raise ValueError("cropRegion must be a dict with x, y, width, height.")
for field in ("x", "y", "width", "height"):
if field not in region:
raise ValueError(f"cropRegion missing field '{field}'")
v = float(region[field])
if not (0.0 <= v <= 1.0):
raise ValueError(f"cropRegion.{field} must be 0.0-1.0, got {v}")
if float(region["x"]) + float(region["width"]) > 1.0 + 1e-9:
raise ValueError("cropRegion x + width must be <= 1.0")
if float(region["y"]) + float(region["height"]) > 1.0 + 1e-9:
raise ValueError("cropRegion y + height must be <= 1.0")
def set_setting(session: Session, key: str, value) -> dict:
"""Set a project editor setting.
Supported keys: aspectRatio, wallpaper, padding, borderRadius,
shadowIntensity, motionBlurAmount, showBlur, exportQuality,
exportFormat, gifFrameRate, gifLoop, gifSizePreset,
webcamLayoutPreset, webcamMaskShape, cropRegion.
"""
if not session.is_open:
raise RuntimeError("No project is open")
editor = session.editor
VALID_KEYS = {
"aspectRatio", "wallpaper", "padding", "borderRadius",
"shadowIntensity", "motionBlurAmount", "showBlur",
"exportQuality", "exportFormat",
"gifFrameRate", "gifLoop", "gifSizePreset",
"webcamLayoutPreset", "webcamMaskShape",
"cropRegion",
}
if key not in VALID_KEYS:
raise ValueError(f"Unknown setting: {key}. Valid: {sorted(VALID_KEYS)}")
if key == "aspectRatio" and value not in ASPECT_RATIOS:
raise ValueError(
f"Invalid aspectRatio '{value}'. Valid: {', '.join(ASPECT_RATIOS)}"
)
if key == "exportQuality" and value not in EXPORT_QUALITIES:
raise ValueError(
f"Invalid exportQuality '{value}'. Valid: {', '.join(EXPORT_QUALITIES)}"
)
if key == "exportFormat" and value not in EXPORT_FORMATS:
raise ValueError(
f"Invalid exportFormat '{value}'. Valid: {', '.join(EXPORT_FORMATS)}"
)
if key == "padding":
v = int(value)
if not (0 <= v <= 100):
raise ValueError(f"padding must be 0-100, got {v}")
if key == "shadowIntensity":
v = float(value)
if not (0.0 <= v <= 1.0):
raise ValueError(f"shadowIntensity must be 0.0-1.0, got {v}")
if key == "motionBlurAmount":
v = float(value)
if not (0.0 <= v <= 1.0):
raise ValueError(f"motionBlurAmount must be 0.0-1.0, got {v}")
if key == "borderRadius":
v = int(value)
if v < 0:
raise ValueError(f"borderRadius must be >= 0, got {v}")
if key == "cropRegion":
_validate_crop_region(value)
session.checkpoint()
editor[key] = value
return {"status": "ok", "key": key, "value": value}
@@ -0,0 +1,243 @@
"""Stateful session management for the Openscreen CLI.
A session tracks the currently open project, undo history, and working state.
Sessions persist to disk as JSON so they survive process restarts.
Openscreen projects are JSON files (.openscreen) containing all editor state:
zoom regions, speed regions, trim regions, annotations, crop, wallpaper, etc.
"""
import json
import copy
import os
import time
from pathlib import Path
from typing import Optional
def _locked_save_json(path, data, **dump_kwargs) -> None:
"""Atomically write JSON with exclusive file locking."""
path = str(path)
try:
f = open(path, "r+")
except FileNotFoundError:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
f = open(path, "w")
with f:
_locked = False
try:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
_locked = True
except (ImportError, OSError):
pass
try:
f.seek(0)
f.truncate()
json.dump(data, f, **dump_kwargs)
f.flush()
finally:
if _locked:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
SESSION_DIR = Path.home() / ".openscreen-cli" / "sessions"
MAX_UNDO_DEPTH = 50
class Session:
"""Represents a stateful CLI editing session."""
def __init__(self, session_id: Optional[str] = None):
self.session_id = session_id or f"session_{int(time.time())}"
self.project_path: Optional[str] = None
self.data: Optional[dict] = None # The full project JSON
self._undo_stack: list[str] = [] # Serialized JSON snapshots
self._redo_stack: list[str] = []
self._modified = False
self._metadata: dict = {}
@property
def is_open(self) -> bool:
return self.data is not None
@property
def is_modified(self) -> bool:
return self._modified
@property
def editor(self) -> dict:
"""Get the editor state dict. Raises if no project open."""
if self.data is None:
raise RuntimeError("No project is open")
return self.data.get("editor", {})
def _snapshot(self) -> str:
"""Capture current state for undo."""
if self.data is None:
return ""
return json.dumps(self.data)
def _push_undo(self) -> None:
"""Save current state to undo stack before a mutation."""
snap = self._snapshot()
if snap:
self._undo_stack.append(snap)
if len(self._undo_stack) > MAX_UNDO_DEPTH:
self._undo_stack.pop(0)
self._redo_stack.clear()
def checkpoint(self) -> None:
"""Create a checkpoint before performing a mutation.
Call this before any operation that changes the project.
"""
self._push_undo()
self._modified = True
def undo(self) -> bool:
"""Undo the last operation. Returns True if successful."""
if not self._undo_stack:
return False
self._redo_stack.append(self._snapshot())
prev = self._undo_stack.pop()
self.data = json.loads(prev)
self._modified = bool(self._undo_stack)
return True
def redo(self) -> bool:
"""Redo the last undone operation. Returns True if successful."""
if not self._redo_stack:
return False
self._undo_stack.append(self._snapshot())
nxt = self._redo_stack.pop()
self.data = json.loads(nxt)
self._modified = True
return True
def new_project(self, video_path: Optional[str] = None) -> None:
"""Create a new blank project."""
self.data = {
"version": 2,
"editor": {
"wallpaper": "gradient_dark",
"shadowIntensity": 0,
"showBlur": False,
"motionBlurAmount": 0,
"borderRadius": 12,
"padding": 50,
"cropRegion": {"x": 0, "y": 0, "width": 1, "height": 1},
"zoomRegions": [],
"trimRegions": [],
"speedRegions": [],
"annotationRegions": [],
"aspectRatio": "16:9",
"webcamLayoutPreset": "picture-in-picture",
"webcamMaskShape": "rectangle",
"webcamPosition": None,
"exportQuality": "good",
"exportFormat": "mp4",
"gifFrameRate": 15,
"gifLoop": True,
"gifSizePreset": "medium",
},
}
if video_path:
self.data["media"] = {
"screenVideoPath": os.path.abspath(video_path),
}
self.project_path = None
self._undo_stack.clear()
self._redo_stack.clear()
self._modified = False
def open_project(self, path: str) -> None:
"""Open an existing .openscreen project file."""
path = os.path.abspath(path)
if not os.path.isfile(path):
raise FileNotFoundError(f"Project file not found: {path}")
with open(path) as f:
data = json.load(f)
if not isinstance(data, dict) or "editor" not in data:
raise ValueError(f"Invalid Openscreen project file: {path}")
self.data = data
self.project_path = path
self._undo_stack.clear()
self._redo_stack.clear()
self._modified = False
def save_project(self, path: Optional[str] = None) -> str:
"""Save the project. Returns the path saved to."""
if self.data is None:
raise RuntimeError("No project is open")
save_path = path or self.project_path
if not save_path:
raise RuntimeError("No save path specified and project has no path")
save_path = os.path.abspath(save_path)
_locked_save_json(save_path, self.data, indent=2, sort_keys=False)
self.project_path = save_path
self._modified = False
return save_path
def save_session_state(self) -> str:
"""Persist session metadata to disk."""
SESSION_DIR.mkdir(parents=True, exist_ok=True)
state = {
"session_id": self.session_id,
"project_path": self.project_path,
"modified": self._modified,
"undo_depth": len(self._undo_stack),
"redo_depth": len(self._redo_stack),
"metadata": self._metadata,
"timestamp": time.time(),
}
path = SESSION_DIR / f"{self.session_id}.json"
_locked_save_json(path, state, indent=2, sort_keys=True)
return str(path)
@classmethod
def load_session_state(cls, session_id: str) -> Optional[dict]:
"""Load session metadata from disk."""
path = SESSION_DIR / f"{session_id}.json"
if not path.is_file():
return None
with open(path) as f:
return json.load(f)
@classmethod
def list_sessions(cls) -> list[dict]:
"""List all saved sessions."""
SESSION_DIR.mkdir(parents=True, exist_ok=True)
sessions = []
for p in SESSION_DIR.glob("*.json"):
try:
with open(p) as f:
sessions.append(json.load(f))
except (json.JSONDecodeError, OSError):
continue
sessions.sort(key=lambda s: s.get("timestamp", 0), reverse=True)
return sessions
def status(self) -> dict:
"""Get current session status."""
result = {
"session_id": self.session_id,
"project_open": self.is_open,
"project_path": self.project_path,
"modified": self._modified,
"undo_available": len(self._undo_stack),
"redo_available": len(self._redo_stack),
}
if self.is_open:
editor = self.editor
result["zoom_region_count"] = len(editor.get("zoomRegions", []))
result["speed_region_count"] = len(editor.get("speedRegions", []))
result["trim_region_count"] = len(editor.get("trimRegions", []))
result["annotation_count"] = len(editor.get("annotationRegions", []))
result["aspect_ratio"] = editor.get("aspectRatio", "16:9")
result["background"] = editor.get("wallpaper", "gradient_dark")
result["padding"] = editor.get("padding", 50)
return result
@@ -0,0 +1,435 @@
"""Timeline operations — zoom, speed, trim, crop, and annotation regions.
Each region type maps directly to Openscreen's data model:
- ZoomRegion: startMs, endMs, depth (1-6), focus (cx, cy), focusMode
- SpeedRegion: startMs, endMs, speed (0.25-2.0)
- TrimRegion: startMs, endMs
- AnnotationRegion: startMs, endMs, type, content, position, size, style
- CropRegion: x, y, width, height (all normalized 0-1)
"""
import uuid
from typing import Optional
from .session import Session
# ── Constants ────────────────────────────────────────────────────────────
ZOOM_DEPTHS = {1: 1.25, 2: 1.5, 3: 1.8, 4: 2.2, 5: 3.5, 6: 5.0}
VALID_SPEEDS = [0.25, 0.5, 0.75, 1.25, 1.5, 1.75, 2.0]
ANNOTATION_TYPES = ["text", "image", "figure"]
ARROW_DIRECTIONS = [
"up", "down", "left", "right",
"up-right", "up-left", "down-right", "down-left",
]
def _gen_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:8]}"
def _validate_time_range(start_ms: int, end_ms: int) -> None:
"""Validate that start_ms >= 0 and end_ms > start_ms."""
if start_ms < 0:
raise ValueError(f"start_ms must be >= 0, got {start_ms}")
if end_ms <= start_ms:
raise ValueError(f"end_ms ({end_ms}) must be > start_ms ({start_ms})")
# ── Zoom regions ─────────────────────────────────────────────────────────
def list_zoom_regions(session: Session) -> list[dict]:
"""List all zoom regions."""
if not session.is_open:
raise RuntimeError("No project is open")
regions = session.editor.get("zoomRegions", [])
return sorted(regions, key=lambda r: r["startMs"])
def add_zoom_region(
session: Session,
start_ms: int,
end_ms: int,
depth: int = 3,
focus_x: float = 0.5,
focus_y: float = 0.5,
focus_mode: str = "manual",
) -> dict:
"""Add a zoom region to the timeline."""
if not session.is_open:
raise RuntimeError("No project is open")
if depth not in ZOOM_DEPTHS:
raise ValueError(f"Invalid depth {depth}. Valid: {list(ZOOM_DEPTHS.keys())}")
if not 0 <= focus_x <= 1 or not 0 <= focus_y <= 1:
raise ValueError("Focus coordinates must be 0.0-1.0")
_validate_time_range(start_ms, end_ms)
session.checkpoint()
region = {
"id": _gen_id("zoom"),
"startMs": start_ms,
"endMs": end_ms,
"depth": depth,
"focus": {"cx": focus_x, "cy": focus_y},
"focusMode": focus_mode,
}
session.editor.setdefault("zoomRegions", []).append(region)
return region
def remove_zoom_region(session: Session, region_id: str) -> dict:
"""Remove a zoom region by ID."""
if not session.is_open:
raise RuntimeError("No project is open")
session.checkpoint()
regions = session.editor.get("zoomRegions", [])
before = len(regions)
session.editor["zoomRegions"] = [r for r in regions if r["id"] != region_id]
removed = before - len(session.editor["zoomRegions"])
if removed == 0:
raise ValueError(f"Zoom region not found: {region_id}")
return {"status": "removed", "id": region_id}
# ── Speed regions ────────────────────────────────────────────────────────
def list_speed_regions(session: Session) -> list[dict]:
"""List all speed regions."""
if not session.is_open:
raise RuntimeError("No project is open")
regions = session.editor.get("speedRegions", [])
return sorted(regions, key=lambda r: r["startMs"])
def add_speed_region(
session: Session,
start_ms: int,
end_ms: int,
speed: float = 1.5,
) -> dict:
"""Add a speed region to the timeline."""
if not session.is_open:
raise RuntimeError("No project is open")
if speed not in VALID_SPEEDS:
raise ValueError(f"Invalid speed {speed}. Valid: {VALID_SPEEDS}")
_validate_time_range(start_ms, end_ms)
session.checkpoint()
region = {
"id": _gen_id("speed"),
"startMs": start_ms,
"endMs": end_ms,
"speed": speed,
}
session.editor.setdefault("speedRegions", []).append(region)
return region
def remove_speed_region(session: Session, region_id: str) -> dict:
"""Remove a speed region by ID."""
if not session.is_open:
raise RuntimeError("No project is open")
session.checkpoint()
regions = session.editor.get("speedRegions", [])
before = len(regions)
session.editor["speedRegions"] = [r for r in regions if r["id"] != region_id]
if before - len(session.editor["speedRegions"]) == 0:
raise ValueError(f"Speed region not found: {region_id}")
return {"status": "removed", "id": region_id}
# ── Trim regions ─────────────────────────────────────────────────────────
def list_trim_regions(session: Session) -> list[dict]:
"""List all trim regions."""
if not session.is_open:
raise RuntimeError("No project is open")
regions = session.editor.get("trimRegions", [])
return sorted(regions, key=lambda r: r["startMs"])
def add_trim_region(session: Session, start_ms: int, end_ms: int) -> dict:
"""Add a trim (cut) region to the timeline."""
if not session.is_open:
raise RuntimeError("No project is open")
_validate_time_range(start_ms, end_ms)
session.checkpoint()
region = {
"id": _gen_id("trim"),
"startMs": start_ms,
"endMs": end_ms,
}
session.editor.setdefault("trimRegions", []).append(region)
return region
def remove_trim_region(session: Session, region_id: str) -> dict:
"""Remove a trim region by ID."""
if not session.is_open:
raise RuntimeError("No project is open")
session.checkpoint()
regions = session.editor.get("trimRegions", [])
before = len(regions)
session.editor["trimRegions"] = [r for r in regions if r["id"] != region_id]
if before - len(session.editor["trimRegions"]) == 0:
raise ValueError(f"Trim region not found: {region_id}")
return {"status": "removed", "id": region_id}
# ── Crop ─────────────────────────────────────────────────────────────────
def get_crop(session: Session) -> dict:
"""Get current crop region."""
if not session.is_open:
raise RuntimeError("No project is open")
return session.editor.get("cropRegion", {"x": 0, "y": 0, "width": 1, "height": 1})
def set_crop(session: Session, x: float, y: float, w: float, h: float) -> dict:
"""Set crop region (all values normalized 0-1)."""
if not session.is_open:
raise RuntimeError("No project is open")
for val, name in [(x, "x"), (y, "y"), (w, "width"), (h, "height")]:
if not 0 <= val <= 1:
raise ValueError(f"{name} must be 0.0-1.0, got {val}")
if x + w > 1.001 or y + h > 1.001:
raise ValueError("Crop region extends beyond frame boundaries")
session.checkpoint()
session.editor["cropRegion"] = {"x": x, "y": y, "width": w, "height": h}
return session.editor["cropRegion"]
# ── Annotations ──────────────────────────────────────────────────────────
def list_annotations(session: Session) -> list[dict]:
"""List all annotation regions."""
if not session.is_open:
raise RuntimeError("No project is open")
regions = session.editor.get("annotationRegions", [])
return sorted(regions, key=lambda r: r["startMs"])
def add_text_annotation(
session: Session,
start_ms: int,
end_ms: int,
text: str,
x: float = 0.5,
y: float = 0.5,
font_size: int = 32,
color: str = "#ffffff",
bg_color: str = "#000000",
) -> dict:
"""Add a text annotation to the timeline."""
if not session.is_open:
raise RuntimeError("No project is open")
_validate_time_range(start_ms, end_ms)
session.checkpoint()
region = {
"id": _gen_id("ann"),
"startMs": start_ms,
"endMs": end_ms,
"type": "text",
"textContent": text,
"content": text,
"position": {"x": x, "y": y},
"size": {"width": 0.3, "height": 0.1},
"style": {
"color": color,
"backgroundColor": bg_color,
"fontSize": font_size,
"fontFamily": "Inter",
"fontWeight": "normal",
"fontStyle": "normal",
"textDecoration": "none",
"textAlign": "center",
},
"zIndex": 1,
}
session.editor.setdefault("annotationRegions", []).append(region)
return region
def remove_annotation(session: Session, region_id: str) -> dict:
"""Remove an annotation by ID."""
if not session.is_open:
raise RuntimeError("No project is open")
session.checkpoint()
regions = session.editor.get("annotationRegions", [])
before = len(regions)
session.editor["annotationRegions"] = [r for r in regions if r["id"] != region_id]
if before - len(session.editor["annotationRegions"]) == 0:
raise ValueError(f"Annotation not found: {region_id}")
return {"status": "removed", "id": region_id}
# ── Update / query helpers (added from auto version) ─────────────────────
def update_zoom_region(
session: Session,
region_id: str,
start_ms: Optional[int] = None,
end_ms: Optional[int] = None,
depth: Optional[int] = None,
focus_x: Optional[float] = None,
focus_y: Optional[float] = None,
) -> dict:
"""Update an existing zoom region.
Only the keyword arguments that are provided are changed; omitted arguments
leave the corresponding field unchanged.
Args:
session: Active Session instance.
region_id: ID of the zoom region to update.
start_ms: New start time in milliseconds.
end_ms: New end time in milliseconds.
depth: New zoom depth (1-6).
focus_x: New horizontal focus center (0.0-1.0).
focus_y: New vertical focus center (0.0-1.0).
Returns:
The updated region dict.
Raises:
RuntimeError: If no project is open.
ValueError: If region_id is not found or parameters are invalid.
"""
if not session.is_open:
raise RuntimeError("No project is open")
regions = session.editor.get("zoomRegions", [])
region = next((r for r in regions if r.get("id") == region_id), None)
if region is None:
raise ValueError(f"Zoom region not found: {region_id}")
new_start = start_ms if start_ms is not None else region["startMs"]
new_end = end_ms if end_ms is not None else region["endMs"]
_validate_time_range(new_start, new_end)
new_depth = depth if depth is not None else region["depth"]
if new_depth not in ZOOM_DEPTHS:
raise ValueError(f"Invalid depth {new_depth}. Valid: {list(ZOOM_DEPTHS.keys())}")
new_fx = focus_x if focus_x is not None else region["focus"]["cx"]
new_fy = focus_y if focus_y is not None else region["focus"]["cy"]
if not 0 <= new_fx <= 1 or not 0 <= new_fy <= 1:
raise ValueError("Focus coordinates must be 0.0-1.0")
session.checkpoint()
region["startMs"] = new_start
region["endMs"] = new_end
region["depth"] = new_depth
region["focus"]["cx"] = new_fx
region["focus"]["cy"] = new_fy
return region
def update_annotation(session: Session, region_id: str, **kwargs) -> dict:
"""Update fields on an existing annotation region.
Supported kwargs: start_ms, end_ms, text_content, position_x, position_y,
size, color, background_color, font_size, font_family.
Args:
session: Active Session instance.
region_id: ID of the annotation to update.
**kwargs: Fields to update (see above).
Returns:
The updated region dict.
Raises:
RuntimeError: If no project is open.
ValueError: If region_id is not found.
"""
if not session.is_open:
raise RuntimeError("No project is open")
regions = session.editor.get("annotationRegions", [])
region = next((r for r in regions if r.get("id") == region_id), None)
if region is None:
raise ValueError(f"Annotation not found: {region_id}")
session.checkpoint()
if "start_ms" in kwargs:
region["startMs"] = int(kwargs["start_ms"])
if "end_ms" in kwargs:
region["endMs"] = int(kwargs["end_ms"])
if "text_content" in kwargs:
region["textContent"] = str(kwargs["text_content"])
region["content"] = str(kwargs["text_content"])
if "position_x" in kwargs:
region["position"]["x"] = float(kwargs["position_x"])
if "position_y" in kwargs:
region["position"]["y"] = float(kwargs["position_y"])
if "size" in kwargs:
region["size"] = kwargs["size"]
if "color" in kwargs:
region["style"]["color"] = str(kwargs["color"])
if "background_color" in kwargs:
region["style"]["backgroundColor"] = str(kwargs["background_color"])
if "font_size" in kwargs:
region["style"]["fontSize"] = int(kwargs["font_size"])
if "font_family" in kwargs:
region["style"]["fontFamily"] = str(kwargs["font_family"])
return region
def get_timeline_boundaries(session: Session) -> list[int]:
"""Return a sorted list of all unique time boundary points (in ms).
Includes 0 and all startMs/endMs values from zoom, speed, trim, and
annotation regions. Useful for computing rendering segments.
Args:
session: Active Session instance.
Returns:
Sorted list of unique boundary millisecond values.
Raises:
RuntimeError: If no project is open.
"""
if not session.is_open:
raise RuntimeError("No project is open")
ed = session.editor
boundaries = {0}
for region_key in ("zoomRegions", "speedRegions", "trimRegions", "annotationRegions"):
for region in ed.get(region_key, []):
boundaries.add(region.get("startMs", 0))
boundaries.add(region.get("endMs", 0))
return sorted(boundaries)
def get_active_regions_at(session: Session, time_ms: int) -> dict:
"""Return all regions active at a specific time (startMs <= time_ms < endMs).
Args:
session: Active Session instance.
time_ms: Query time in milliseconds.
Returns:
Dict with keys "zoom", "speed", "trim", "annotation", each mapping to
a list of region dicts active at that time.
Raises:
RuntimeError: If no project is open.
"""
if not session.is_open:
raise RuntimeError("No project is open")
ed = session.editor
def active(regions: list) -> list:
return [r for r in regions if r.get("startMs", 0) <= time_ms < r.get("endMs", 0)]
return {
"zoom": active(ed.get("zoomRegions", [])),
"speed": active(ed.get("speedRegions", [])),
"trim": active(ed.get("trimRegions", [])),
"annotation": active(ed.get("annotationRegions", [])),
}
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
"""Openscreen CLI — Screen recording editor for AI agents and power users.
A stateful CLI for editing screen recordings: add zoom, speed ramps,
trim, crop, annotations, backgrounds, and export polished demo videos.
Built on the Openscreen JSON project format with ffmpeg as the rendering backend.
"""
import functools
import json
import os
import sys
from typing import Optional
import click
from .core.session import Session
from .core import project as proj_mod
from .core import timeline as tl_mod
from .core import export as export_mod
from .core import media as media_mod
from .core import preview as preview_mod
# ── Global state ──────────────────────────────────────────────────────────
_session: Optional[Session] = None
_json_output = False
_repl_mode = False
_auto_save = False
_dry_run = False
# ── Output helpers ────────────────────────────────────────────────────────
def output(data, message: str = ""):
"""Print output in JSON or human-readable format."""
if _json_output:
click.echo(json.dumps(data, indent=2, default=str))
else:
if message:
click.echo(message)
if isinstance(data, dict):
_print_dict(data)
elif isinstance(data, list):
_print_list(data)
else:
click.echo(str(data))
def _print_dict(d: dict, indent: int = 2):
for k, v in d.items():
if isinstance(v, dict):
click.echo(f"{' ' * indent}{k}:")
_print_dict(v, indent + 2)
elif isinstance(v, list):
click.echo(f"{' ' * indent}{k}: [{len(v)} items]")
else:
click.echo(f"{' ' * indent}{k}: {v}")
def _print_list(items: list):
if not items:
click.echo(" (empty)")
return
for i, item in enumerate(items):
if isinstance(item, dict):
click.echo(f" [{i}]")
_print_dict(item, indent=4)
else:
click.echo(f" [{i}] {item}")
# ── Error handler ─────────────────────────────────────────────────────────
def handle_error(func):
"""Decorator to catch and format errors."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except FileNotFoundError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "file_not_found"}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
except (ValueError, IndexError) as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "validation"}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
except RuntimeError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "runtime"}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
except Exception as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "unexpected"}))
else:
click.echo(f"Unexpected error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
return wrapper
# ── Main group ────────────────────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.version_option("1.0.0", prog_name="Openscreen CLI")
@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format")
@click.option("--session", "session_id", default=None, help="Session ID to resume")
@click.option("--project", "project_path", default=None, help="Open project on start")
@click.option("-s", "--save", "auto_save", is_flag=True, help="Auto-save after mutations")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, json_mode, session_id, project_path, auto_save, dry_run):
"""Openscreen CLI — Screen recording editor.
Edit screen recordings via command line: zoom, speed, trim, crop,
annotate, and export polished demo videos.
Run without a subcommand to enter REPL mode.
"""
global _session, _json_output, _auto_save, _dry_run
_json_output = json_mode
_auto_save = auto_save
_dry_run = dry_run
_session = Session(session_id)
if project_path:
_session.open_project(project_path)
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
# ── Project commands ──────────────────────────────────────────────────────
@cli.group()
def project():
"""Project management — new, open, save, info, settings."""
pass
@project.command("new")
@click.option("-v", "--video", default=None, help="Source video path")
@click.option("-o", "--output", default=None, help="Save project to this path")
@handle_error
def project_new(video, output):
"""Create a new project."""
result = proj_mod.new_project(_session, video)
if output:
proj_mod.save_project(_session, output)
result["saved_to"] = output
output_fn = globals()["output"]
output_fn(result, "Project created")
@project.command("open")
@click.argument("path")
@handle_error
def project_open(path):
"""Open an existing .openscreen project."""
result = proj_mod.open_project(_session, path)
output(result, f"Opened: {path}")
@project.command("save")
@click.option("-o", "--output", "output_path", default=None, help="Save to path (default: current)")
@handle_error
def project_save(output_path=None):
"""Save the current project."""
result = proj_mod.save_project(_session, output_path)
output(result)
@project.command("info")
@handle_error
def project_info():
"""Show project information."""
result = proj_mod.info(_session)
output(result)
@project.command("set-video")
@click.argument("path")
@handle_error
def project_set_video(path):
"""Set the source video for the project."""
result = proj_mod.set_video(_session, path)
output(result, f"Video set: {path}")
@project.command("set")
@click.argument("key")
@click.argument("value")
@handle_error
def project_set(key, value):
"""Set a project setting (e.g., aspectRatio, wallpaper, padding)."""
# Auto-convert numeric and boolean values
if value.lower() in ("true", "false"):
value = value.lower() == "true"
else:
try:
value = int(value)
except ValueError:
try:
value = float(value)
except ValueError:
pass
result = proj_mod.set_setting(_session, key, value)
output(result)
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
# ── Zoom commands ─────────────────────────────────────────────────────────
@cli.group()
def zoom():
"""Zoom regions — add, remove, list zoom effects on timeline."""
pass
@zoom.command("list")
@handle_error
def zoom_list():
"""List all zoom regions."""
result = tl_mod.list_zoom_regions(_session)
output(result)
@zoom.command("add")
@click.option("--start", required=True, type=int, help="Start time in milliseconds")
@click.option("--end", required=True, type=int, help="End time in milliseconds")
@click.option("--depth", default=3, type=int, help="Zoom depth 1-6 (default: 3)")
@click.option("--focus-x", default=0.5, type=float, help="Focus X (0-1)")
@click.option("--focus-y", default=0.5, type=float, help="Focus Y (0-1)")
@click.option("--focus-mode", default="manual", help="Focus mode: manual or auto")
@handle_error
def zoom_add(start, end, depth, focus_x, focus_y, focus_mode):
"""Add a zoom region."""
result = tl_mod.add_zoom_region(
_session, start, end, depth, focus_x, focus_y, focus_mode
)
output(result, "Zoom region added")
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@zoom.command("remove")
@click.argument("region_id")
@handle_error
def zoom_remove(region_id):
"""Remove a zoom region by ID."""
result = tl_mod.remove_zoom_region(_session, region_id)
output(result)
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
# ── Speed commands ────────────────────────────────────────────────────────
@cli.group()
def speed():
"""Speed regions — add, remove, list speed changes."""
pass
@speed.command("list")
@handle_error
def speed_list():
"""List all speed regions."""
result = tl_mod.list_speed_regions(_session)
output(result)
@speed.command("add")
@click.option("--start", required=True, type=int, help="Start time in ms")
@click.option("--end", required=True, type=int, help="End time in ms")
@click.option("--speed", "spd", default=1.5, type=float, help="Speed multiplier")
@handle_error
def speed_add(start, end, spd):
"""Add a speed region."""
result = tl_mod.add_speed_region(_session, start, end, spd)
output(result, "Speed region added")
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@speed.command("remove")
@click.argument("region_id")
@handle_error
def speed_remove(region_id):
"""Remove a speed region by ID."""
result = tl_mod.remove_speed_region(_session, region_id)
output(result)
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
# ── Trim commands ─────────────────────────────────────────────────────────
@cli.group()
def trim():
"""Trim regions — cut out sections of the recording."""
pass
@trim.command("list")
@handle_error
def trim_list():
"""List all trim regions."""
result = tl_mod.list_trim_regions(_session)
output(result)
@trim.command("add")
@click.option("--start", required=True, type=int, help="Start time in ms")
@click.option("--end", required=True, type=int, help="End time in ms")
@handle_error
def trim_add(start, end):
"""Add a trim region (cuts this section out)."""
result = tl_mod.add_trim_region(_session, start, end)
output(result, "Trim region added")
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@trim.command("remove")
@click.argument("region_id")
@handle_error
def trim_remove(region_id):
"""Remove a trim region by ID."""
result = tl_mod.remove_trim_region(_session, region_id)
output(result)
# ── Crop commands ─────────────────────────────────────────────────────────
@cli.group()
def crop():
"""Crop — set the visible area of the recording."""
pass
@crop.command("get")
@handle_error
def crop_get():
"""Show current crop region."""
result = tl_mod.get_crop(_session)
output(result)
@crop.command("set")
@click.option("--x", default=0.0, type=float, help="Left edge (0-1)")
@click.option("--y", default=0.0, type=float, help="Top edge (0-1)")
@click.option("--width", "w", default=1.0, type=float, help="Width (0-1)")
@click.option("--height", "h", default=1.0, type=float, help="Height (0-1)")
@handle_error
def crop_set(x, y, w, h):
"""Set crop region (normalized 0-1 coordinates)."""
result = tl_mod.set_crop(_session, x, y, w, h)
output(result, "Crop updated")
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
# ── Annotation commands ───────────────────────────────────────────────────
@cli.group()
def annotation():
"""Annotations — add text overlays to the recording."""
pass
@annotation.command("list")
@handle_error
def annotation_list():
"""List all annotations."""
result = tl_mod.list_annotations(_session)
output(result)
@annotation.command("add-text")
@click.option("--start", required=True, type=int, help="Start time in ms")
@click.option("--end", required=True, type=int, help="End time in ms")
@click.option("--text", required=True, help="Text content")
@click.option("--x", default=0.5, type=float, help="X position (0-1)")
@click.option("--y", default=0.5, type=float, help="Y position (0-1)")
@click.option("--font-size", default=32, type=int, help="Font size")
@click.option("--color", default="#ffffff", help="Text color (hex)")
@click.option("--bg-color", default="#000000", help="Background color (hex)")
@handle_error
def annotation_add_text(start, end, text, x, y, font_size, color, bg_color):
"""Add a text annotation."""
result = tl_mod.add_text_annotation(
_session, start, end, text, x, y, font_size, color, bg_color
)
output(result, "Annotation added")
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@annotation.command("remove")
@click.argument("region_id")
@handle_error
def annotation_remove(region_id):
"""Remove an annotation by ID."""
result = tl_mod.remove_annotation(_session, region_id)
output(result)
# ── Media commands ────────────────────────────────────────────────────────
@cli.group()
def media():
"""Media — probe and inspect video files."""
pass
@media.command("probe")
@click.argument("path")
@handle_error
def media_probe(path):
"""Probe a video file and show metadata."""
result = media_mod.probe(path)
output(result)
@media.command("check")
@click.argument("path")
@handle_error
def media_check(path):
"""Check if a video file is valid."""
result = media_mod.check_video(path)
output(result)
@media.command("thumbnail")
@click.argument("input_path")
@click.argument("output_path")
@click.option("-t", "--time", "time_s", default=0.0, help="Time in seconds")
@handle_error
def media_thumbnail(input_path, output_path, time_s):
"""Extract a thumbnail frame from a video."""
result = media_mod.extract_thumbnail(input_path, output_path, time_s)
output(result)
# ── Export commands ────────────────────────────────────────────────────────
@cli.group()
def export():
"""Export — render the final video."""
pass
@cli.group()
def preview():
"""Preview bundle capture and inspection."""
pass
@export.command("presets")
@handle_error
def export_presets():
"""List available export presets."""
result = export_mod.list_presets()
output(result)
@export.command("render")
@click.argument("output_path")
@handle_error
def export_render(output_path):
"""Render the project to a video file."""
def on_progress(stage, msg):
if not _json_output:
click.echo(f" [{stage}] {msg}")
result = export_mod.render(_session, output_path, on_progress)
output(result, f"Exported to: {output_path}")
@preview.command("recipes")
@handle_error
def preview_recipes():
"""List available preview recipes."""
output(preview_mod.list_recipes(), "Preview recipes")
@preview.command("capture")
@click.option("--recipe", default="quick", help="Preview recipe name")
@click.option("--force", is_flag=True, help="Bypass preview cache")
@click.option("--root-dir", default=None, help="Override preview bundle root directory")
@handle_error
def preview_capture(recipe, force, root_dir):
"""Capture a preview bundle for the active project."""
result = preview_mod.capture(
_session,
recipe=recipe,
force=force,
root_dir=root_dir,
command=f"cli-anything-openscreen --project {_session.project_path or ''} preview capture --recipe {recipe}".strip(),
)
bundle_dir = result.get("_bundle_dir", result.get("bundle_dir", ""))
status = "Reused preview bundle" if result.get("cached") else "Created preview bundle"
output(result, f"{status}: {bundle_dir}")
@preview.command("latest")
@click.option("--recipe", default=None, help="Filter by recipe name")
@click.option("--root-dir", default=None, help="Override preview bundle root directory")
@handle_error
def preview_latest(recipe, root_dir):
"""Show the latest preview bundle manifest."""
result = preview_mod.latest(project_path=_session.project_path, recipe=recipe, root_dir=root_dir)
output(result, f"Latest preview bundle: {result.get('_bundle_dir', '')}")
# ── Session commands ──────────────────────────────────────────────────────
@cli.group("session")
def session_group():
"""Session — undo, redo, status, save/list sessions."""
pass
@session_group.command("status")
@handle_error
def session_status():
"""Show current session status."""
result = _session.status()
output(result)
@session_group.command("undo")
@handle_error
def session_undo():
"""Undo the last operation."""
if _session.undo():
output({"status": "undone", "undo_remaining": len(_session._undo_stack)})
else:
output({"status": "nothing_to_undo"})
@session_group.command("redo")
@handle_error
def session_redo():
"""Redo the last undone operation."""
if _session.redo():
output({"status": "redone", "redo_remaining": len(_session._redo_stack)})
else:
output({"status": "nothing_to_redo"})
@session_group.command("save")
@handle_error
def session_save_state():
"""Save session state to disk."""
path = _session.save_session_state()
output({"status": "saved", "path": path})
@session_group.command("list")
@handle_error
def session_list():
"""List all saved sessions."""
result = Session.list_sessions()
output(result)
# ── REPL command ──────────────────────────────────────────────────────────
@cli.command()
def repl():
"""Start interactive REPL mode."""
global _repl_mode
_repl_mode = True
from .utils.repl_skin import ReplSkin
skin = ReplSkin("openscreen", version="1.0.0")
skin.print_banner()
pt_session = skin.create_prompt_session()
COMMANDS = {
"help": "Show this help",
"quit": "Exit REPL",
"status": "Show session status",
"undo": "Undo last operation",
"redo": "Redo last operation",
"new [video]": "Create new project (optional video path)",
"open <path>": "Open .openscreen project file",
"save [path]": "Save project",
"info": "Show project info",
"set-video <p>": "Set source video",
"set <k> <v>": "Set editor setting",
"zoom list": "List zoom regions",
"zoom add": "Add zoom (prompts for params)",
"zoom rm <id>": "Remove zoom region",
"speed list": "List speed regions",
"speed add": "Add speed region (prompts)",
"speed rm <id>": "Remove speed region",
"trim list": "List trim regions",
"trim add": "Add trim region (prompts)",
"trim rm <id>": "Remove trim region",
"crop": "Show crop region",
"crop set": "Set crop (prompts)",
"probe <path>": "Probe a video file",
"export <path>": "Render and export video",
"preview [recipe]": "Capture a preview bundle",
"preview-latest [recipe]": "Show the latest preview bundle",
}
while True:
try:
proj_name = ""
modified = False
if _session.is_open:
proj_name = os.path.basename(_session.project_path or "untitled")
modified = _session.is_modified
line = skin.get_input(pt_session, project_name=proj_name, modified=modified)
if not line:
continue
parts = line.split()
cmd = parts[0].lower()
if cmd in ("quit", "exit", "q"):
if _session.is_modified:
skin.warning("Unsaved changes! Use 'save' first or 'quit' again.")
_session._modified = False # Allow next quit
continue
break
elif cmd == "help":
skin.help(COMMANDS)
elif cmd == "status":
result = _session.status()
output(result)
elif cmd == "undo":
if _session.undo():
skin.success("Undone")
else:
skin.warning("Nothing to undo")
elif cmd == "redo":
if _session.redo():
skin.success("Redone")
else:
skin.warning("Nothing to redo")
elif cmd == "new":
video = parts[1] if len(parts) > 1 else None
proj_mod.new_project(_session, video)
skin.success("New project created")
elif cmd == "open":
if len(parts) < 2:
skin.error("Usage: open <path>")
continue
proj_mod.open_project(_session, parts[1])
skin.success(f"Opened: {parts[1]}")
elif cmd == "save":
path = parts[1] if len(parts) > 1 else None
result = proj_mod.save_project(_session, path)
skin.success(f"Saved: {result['path']}")
elif cmd == "info":
result = proj_mod.info(_session)
output(result)
elif cmd == "set-video":
if len(parts) < 2:
skin.error("Usage: set-video <path>")
continue
proj_mod.set_video(_session, parts[1])
skin.success(f"Video set: {parts[1]}")
elif cmd == "set":
if len(parts) < 3:
skin.error("Usage: set <key> <value>")
continue
val = parts[2]
if val.lower() in ("true", "false"):
val = val.lower() == "true"
else:
try:
val = int(val)
except ValueError:
try:
val = float(val)
except ValueError:
pass
proj_mod.set_setting(_session, parts[1], val)
skin.success(f"{parts[1]} = {val}")
elif cmd == "zoom":
_repl_zoom(parts[1:], skin, pt_session)
elif cmd == "speed":
_repl_speed(parts[1:], skin, pt_session)
elif cmd == "trim":
_repl_trim(parts[1:], skin, pt_session)
elif cmd == "crop":
if len(parts) > 1 and parts[1] == "set":
skin.info("Enter crop (normalized 0-1):")
x = float(skin.sub_input(" x: ", pt_session) or "0")
y = float(skin.sub_input(" y: ", pt_session) or "0")
w = float(skin.sub_input(" width: ", pt_session) or "1")
h = float(skin.sub_input(" height: ", pt_session) or "1")
tl_mod.set_crop(_session, x, y, w, h)
skin.success("Crop updated")
else:
result = tl_mod.get_crop(_session)
output(result)
elif cmd == "probe":
if len(parts) < 2:
skin.error("Usage: probe <path>")
continue
result = media_mod.probe(parts[1])
output(result)
elif cmd == "export":
if len(parts) < 2:
skin.error("Usage: export <output_path>")
continue
def on_prog(stage, msg):
skin.info(f"[{stage}] {msg}")
result = export_mod.render(_session, parts[1], on_prog)
skin.success(f"Exported: {result['output']} ({result['file_size']} bytes)")
elif cmd == "preview":
recipe = parts[1] if len(parts) > 1 else "quick"
result = preview_mod.capture(_session, recipe=recipe)
output(result, f"Preview bundle: {result.get('_bundle_dir', '')}")
elif cmd == "preview-latest":
recipe = parts[1] if len(parts) > 1 else None
result = preview_mod.latest(project_path=_session.project_path, recipe=recipe)
output(result, f"Latest preview bundle: {result.get('_bundle_dir', '')}")
else:
skin.warning(f"Unknown command: {cmd}. Type 'help' for commands.")
except KeyboardInterrupt:
print()
continue
except EOFError:
break
except Exception as e:
skin.error(str(e))
skin.print_goodbye()
def _repl_zoom(args, skin, pt_session=None):
"""Handle zoom subcommands in REPL."""
if not args:
skin.error("Usage: zoom list|add|rm <id>")
return
sub = args[0]
if sub == "list":
result = tl_mod.list_zoom_regions(_session)
output(result)
elif sub == "add":
skin.info("Add zoom region:")
start = int(skin.sub_input(" start_ms: ", pt_session))
end = int(skin.sub_input(" end_ms: ", pt_session))
depth = int(skin.sub_input(" depth (1-6, default 3): ", pt_session) or "3")
fx = float(skin.sub_input(" focus_x (0-1, default 0.5): ", pt_session) or "0.5")
fy = float(skin.sub_input(" focus_y (0-1, default 0.5): ", pt_session) or "0.5")
result = tl_mod.add_zoom_region(_session, start, end, depth, fx, fy)
skin.success(f"Added zoom: {result['id']}")
elif sub == "rm" and len(args) > 1:
tl_mod.remove_zoom_region(_session, args[1])
skin.success(f"Removed: {args[1]}")
else:
skin.error("Usage: zoom list|add|rm <id>")
def _repl_speed(args, skin, pt_session=None):
"""Handle speed subcommands in REPL."""
if not args:
skin.error("Usage: speed list|add|rm <id>")
return
sub = args[0]
if sub == "list":
result = tl_mod.list_speed_regions(_session)
output(result)
elif sub == "add":
skin.info("Add speed region:")
start = int(skin.sub_input(" start_ms: ", pt_session))
end = int(skin.sub_input(" end_ms: ", pt_session))
spd = float(skin.sub_input(" speed (0.25-2.0, default 1.5): ", pt_session) or "1.5")
result = tl_mod.add_speed_region(_session, start, end, spd)
skin.success(f"Added speed: {result['id']}")
elif sub == "rm" and len(args) > 1:
tl_mod.remove_speed_region(_session, args[1])
skin.success(f"Removed: {args[1]}")
else:
skin.error("Usage: speed list|add|rm <id>")
def _repl_trim(args, skin, pt_session=None):
"""Handle trim subcommands in REPL."""
if not args:
skin.error("Usage: trim list|add|rm <id>")
return
sub = args[0]
if sub == "list":
result = tl_mod.list_trim_regions(_session)
output(result)
elif sub == "add":
skin.info("Add trim region:")
start = int(skin.sub_input(" start_ms: ", pt_session))
end = int(skin.sub_input(" end_ms: ", pt_session))
result = tl_mod.add_trim_region(_session, start, end)
skin.success(f"Added trim: {result['id']}")
elif sub == "rm" and len(args) > 1:
tl_mod.remove_trim_region(_session, args[1])
skin.success(f"Removed: {args[1]}")
else:
skin.error("Usage: trim list|add|rm <id>")
if __name__ == "__main__":
cli()
@@ -0,0 +1,211 @@
---
name: >-
cli-anything-openscreen
description: >-
Command-line interface for Openscreen — a screen recording editor.
A stateful CLI for editing screen recordings with zoom, speed ramps,
trim, crop, annotations, and polished exports. Built on the Openscreen
JSON project format with ffmpeg as the rendering backend. Designed for
AI agents and power users who need programmatic video editing.
---
# cli-anything-openscreen
A stateful command-line interface for editing screen recordings. Transform raw
captures into polished demo videos with zoom effects, speed adjustments,
trimming, annotations, and beautiful backgrounds.
## Installation
```bash
pip install cli-anything-openscreen
```
**Prerequisites:**
- Python 3.10+
- ffmpeg must be installed on your system
## Usage
### Basic Commands
```bash
cli-anything-openscreen --help
cli-anything-openscreen # REPL mode
cli-anything-openscreen project new -v recording.mp4 -o project.openscreen
cli-anything-openscreen --json project info
```
### REPL Mode
Run `cli-anything-openscreen` without arguments to enter interactive mode.
Type `help` for available commands, `quit` to exit.
## Command Groups
### project
Create, open, save, and configure projects.
| Command | Description |
|---------|-------------|
| `project new [-v VIDEO] [-o PATH]` | Create new project with optional video |
| `project open <path>` | Open existing .openscreen project |
| `project save [-o PATH]` | Save project to file |
| `project info` | Show project metadata and region counts |
| `project set-video <path>` | Set source video file |
| `project set <key> <value>` | Set editor setting (padding, wallpaper, etc.) |
### zoom
Manage zoom regions — smooth zoom effects on specific timeline areas.
| Command | Description |
|---------|-------------|
| `zoom list` | List all zoom regions |
| `zoom add --start MS --end MS [--depth 1-6] [--focus-x 0-1] [--focus-y 0-1]` | Add zoom |
| `zoom remove <id>` | Remove zoom region |
Zoom depths: 1=1.25x, 2=1.5x, 3=1.8x, 4=2.2x, 5=3.5x, 6=5.0x
### speed
Manage speed regions — speed up idle time, slow down important moments.
| Command | Description |
|---------|-------------|
| `speed list` | List all speed regions |
| `speed add --start MS --end MS [--speed 0.25-2.0]` | Add speed change |
| `speed remove <id>` | Remove speed region |
Valid speeds: 0.25, 0.5, 0.75, 1.25, 1.5, 1.75, 2.0
### trim
Manage trim regions — cut out sections of the recording.
| Command | Description |
|---------|-------------|
| `trim list` | List all trim regions |
| `trim add --start MS --end MS` | Cut out a section |
| `trim remove <id>` | Remove trim region |
### crop
Set the visible area of the recording.
| Command | Description |
|---------|-------------|
| `crop get` | Show current crop region |
| `crop set --x 0-1 --y 0-1 --width 0-1 --height 0-1` | Set crop (normalized) |
### annotation
Add text overlays to the recording.
| Command | Description |
|---------|-------------|
| `annotation list` | List all annotations |
| `annotation add-text --start MS --end MS --text "..." [--x 0-1] [--y 0-1]` | Add text |
| `annotation remove <id>` | Remove annotation |
### media
Inspect and validate media files.
| Command | Description |
|---------|-------------|
| `media probe <path>` | Show video metadata (resolution, duration, codec) |
| `media check <path>` | Validate a video file |
| `media thumbnail <input> <output> [-t TIME]` | Extract a frame |
### export
Render the final polished video.
| Command | Description |
|---------|-------------|
| `export presets` | List available export presets |
| `export render <output_path>` | Render project to video file |
### preview
Capture truthful review bundles from the current Openscreen project.
| Command | Description |
|---------|-------------|
| `preview recipes` | List available preview recipes |
| `preview capture --recipe quick` | Render a low-res preview bundle |
| `preview latest [--recipe quick]` | Return the latest existing bundle |
Current preview support is static rather than live. A `quick` bundle typically
contains:
- a low-res preview clip
- sampled frames
- a `hero` frame for quick inspection
- `summary.json` with trim/zoom/speed facts
Bundle capture also writes or updates a stable recipe-level `trajectory.json`
beside the preview root so agents can reconstruct preview history over time.
Viewer commands:
```bash
cli-hub previews inspect /path/to/bundle
cli-hub previews html /path/to/bundle -o page.html
cli-hub previews open /path/to/bundle
```
### session
Manage session state with undo/redo.
| Command | Description |
|---------|-------------|
| `session status` | Show session info |
| `session undo` | Undo last operation |
| `session redo` | Redo last undone operation |
| `session save` | Save session state to disk |
| `session list` | List all saved sessions |
## State Management
- **Undo/Redo**: Up to 50 levels of undo history
- **Project persistence**: JSON `.openscreen` files
- **Session tracking**: auto-tracks modifications
## Output Formats
- Human-readable (default): Formatted key-value pairs
- Machine-readable (`--json`): Structured JSON output
## Editor Settings
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `aspectRatio` | string | "16:9" | 16:9, 9:16, 1:1, 4:3, 4:5 |
| `wallpaper` | string | "gradient_dark" | Background preset |
| `padding` | int | 50 | 0-100, padding around video |
| `borderRadius` | int | 12 | Corner radius in pixels |
| `shadowIntensity` | float | 0 | 0-1, drop shadow strength |
| `motionBlurAmount` | float | 0 | 0-1, motion blur during zoom |
| `exportQuality` | string | "good" | medium, good, source |
| `exportFormat` | string | "mp4" | mp4, gif |
## For AI Agents
1. Always use `--json` for parseable output
2. Check return codes — 0 = success, non-zero = error
3. Parse stderr for error messages in non-JSON mode
4. Use absolute file paths
5. After `export render`, verify the output exists and probe it
6. Times are in **milliseconds** for all region commands
7. Coordinates (focus, crop, position) are **normalized 0-1**
8. Use `preview capture --json` to validate zoom, trim, annotation, and timing visually
9. Read returned bundle artifact paths from the JSON payload; images and clips live on disk
10. Use `cli-hub previews ...` only to inspect/open existing bundles
## Version
1.0.0
@@ -0,0 +1,765 @@
"""Unit tests for Openscreen CLI — no ffmpeg backend required.
Tests the core data model: session, project, timeline regions, crop.
All operations are in-memory JSON manipulation.
"""
import json
import os
import tempfile
from pathlib import Path
import pytest
from cli_anything.openscreen.core.session import Session
from cli_anything.openscreen.core import project as proj_mod
from cli_anything.openscreen.core import timeline as tl_mod
from cli_anything.openscreen.core import export as export_mod
from cli_anything.openscreen.core import media as media_mod
from cli_anything.openscreen.core import preview as preview_mod
# ── Session Tests ─────────────────────────────────────────────────────────
class TestSession:
def test_new_session(self):
s = Session()
assert s.session_id.startswith("session_")
assert not s.is_open
assert not s.is_modified
def test_new_session_with_id(self):
s = Session("my_session")
assert s.session_id == "my_session"
def test_new_project(self):
s = Session()
s.new_project()
assert s.is_open
assert not s.is_modified
assert s.editor["aspectRatio"] == "16:9"
assert s.editor["padding"] == 50
def test_new_project_with_video(self):
s = Session()
s.new_project("/tmp/test.mp4")
assert s.is_open
assert s.data["media"]["screenVideoPath"] == "/tmp/test.mp4"
def test_undo_redo(self):
s = Session()
s.new_project()
# No undo available on fresh project
assert not s.undo()
# Make a change
s.checkpoint()
s.editor["padding"] = 30
# Undo should restore
assert s.undo()
assert s.editor["padding"] == 50
# Redo should reapply
assert s.redo()
assert s.editor["padding"] == 30
def test_undo_clears_redo(self):
s = Session()
s.new_project()
s.checkpoint()
s.editor["padding"] = 30
s.checkpoint()
s.editor["padding"] = 20
assert s.undo()
assert s.editor["padding"] == 30
# New change should clear redo stack
s.checkpoint()
s.editor["padding"] = 40
assert not s.redo()
def test_save_load_project(self):
s = Session()
s.new_project()
s.checkpoint()
s.editor["padding"] = 42
with tempfile.NamedTemporaryFile(suffix=".openscreen", delete=False) as f:
path = f.name
try:
s.save_project(path)
assert not s.is_modified
s2 = Session()
s2.open_project(path)
assert s2.is_open
assert s2.editor["padding"] == 42
finally:
os.unlink(path)
def test_open_nonexistent(self):
s = Session()
with pytest.raises(FileNotFoundError):
s.open_project("/nonexistent/project.openscreen")
def test_save_without_path(self):
s = Session()
s.new_project()
with pytest.raises(RuntimeError, match="No save path"):
s.save_project()
def test_status(self):
s = Session()
status = s.status()
assert status["project_open"] is False
s.new_project()
status = s.status()
assert status["project_open"] is True
assert status["zoom_region_count"] == 0
# ── Extra tests from auto version ──────────────────────────────────
def test_editor_raises_when_not_open(self):
s = Session()
with pytest.raises(RuntimeError):
_ = s.editor
def test_checkpoint_adds_to_undo(self):
s = Session()
s.new_project()
before = len(s._undo_stack)
s.checkpoint()
assert len(s._undo_stack) == before + 1
def test_undo_stack_limit_50(self):
s = Session()
s.new_project()
for i in range(60):
s.checkpoint()
assert len(s._undo_stack) <= 50
def test_is_modified_after_checkpoint(self):
s = Session()
s.new_project()
assert not s.is_modified
s.checkpoint()
assert s.is_modified
def test_open_invalid_json_raises(self, tmp_path):
path = tmp_path / "bad.openscreen"
path.write_text("not json at all")
s = Session()
with pytest.raises((RuntimeError, ValueError, Exception)):
s.open_project(str(path))
# ── Project Tests ─────────────────────────────────────────────────────────
class TestProject:
def test_new_project(self):
s = Session()
result = proj_mod.new_project(s)
assert result["status"] == "created"
assert s.is_open
def test_info(self):
s = Session()
s.new_project()
result = proj_mod.info(s)
assert result["version"] == 2
assert result["aspect_ratio"] == "16:9"
assert result["zoom_regions"] == 0
def test_info_without_project(self):
s = Session()
with pytest.raises(RuntimeError, match="No project"):
proj_mod.info(s)
def test_set_setting(self):
s = Session()
s.new_project()
result = proj_mod.set_setting(s, "padding", 30)
assert result["value"] == 30
assert s.editor["padding"] == 30
def test_set_invalid_setting(self):
s = Session()
s.new_project()
with pytest.raises(ValueError, match="Unknown setting"):
proj_mod.set_setting(s, "nonexistent_key", 42)
# ── Extra tests from auto version ──────────────────────────────────
def test_set_setting_aspectRatio(self):
s = Session()
s.new_project()
proj_mod.set_setting(s, "aspectRatio", "9:16")
assert s.editor["aspectRatio"] == "9:16"
def test_set_setting_invalid_aspectRatio(self):
s = Session()
s.new_project()
with pytest.raises(ValueError):
proj_mod.set_setting(s, "aspectRatio", "invalid")
def test_set_setting_exportQuality(self):
s = Session()
s.new_project()
proj_mod.set_setting(s, "exportQuality", "source")
assert s.editor["exportQuality"] == "source"
def test_set_setting_exportFormat(self):
s = Session()
s.new_project()
proj_mod.set_setting(s, "exportFormat", "gif")
assert s.editor["exportFormat"] == "gif"
def test_set_setting_invalid_exportFormat(self):
s = Session()
s.new_project()
with pytest.raises(ValueError):
proj_mod.set_setting(s, "exportFormat", "avi")
def test_set_setting_padding_valid(self):
s = Session()
s.new_project()
proj_mod.set_setting(s, "padding", 30)
assert s.editor["padding"] == 30
def test_set_setting_padding_too_large(self):
s = Session()
s.new_project()
with pytest.raises(ValueError):
proj_mod.set_setting(s, "padding", 101)
def test_set_setting_calls_checkpoint(self):
s = Session()
s.new_project()
proj_mod.set_setting(s, "padding", 10)
assert len(s._undo_stack) >= 1
def test_crop_region_validation_in_settings(self):
s = Session()
s.new_project()
valid_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
proj_mod.set_setting(s, "cropRegion", valid_crop)
assert s.editor["cropRegion"] == valid_crop
def test_crop_region_overflow_raises(self):
s = Session()
s.new_project()
with pytest.raises(ValueError):
proj_mod.set_setting(s, "cropRegion", {"x": 0.5, "y": 0.0, "width": 0.8, "height": 1.0})
class TestPreview:
def test_list_recipes(self):
recipes = preview_mod.list_recipes()
assert recipes
assert recipes[0]["name"] == "quick"
def test_capture_bundle(self, tmp_path, monkeypatch):
session = Session("preview_test")
session.new_project("/tmp/source.mp4")
def fake_render(session_obj, output_path, on_progress=None):
Path(output_path).write_bytes(b"\x00\x00\x00\x18ftypmp42")
return {
"output": output_path,
"duration": 8.0,
"width": 1920,
"height": 1080,
"segments_rendered": 3,
}
def fake_probe(path):
return {"duration": 8.0, "width": 1920, "height": 1080}
def fake_thumb(input_path, output_path, time_s=0.0):
Path(output_path).write_bytes(b"\xff\xd8\xff\xe0preview")
return {"output": output_path, "time_s": time_s}
monkeypatch.setattr(export_mod, "render", fake_render)
monkeypatch.setattr(media_mod, "probe", fake_probe)
monkeypatch.setattr(media_mod, "extract_thumbnail", fake_thumb)
manifest = preview_mod.capture(session, root_dir=str(tmp_path))
assert manifest["software"] == "openscreen"
assert manifest["recipe"] == "quick"
assert manifest["status"] == "ok"
assert manifest["cached"] is False
assert any(item["role"] == "preview-clip" for item in manifest["artifacts"])
assert any(item["role"] == "hero" for item in manifest["artifacts"])
assert os.path.isfile(manifest["_manifest_path"])
assert os.path.isfile(manifest["_trajectory_path"])
trajectory = json.loads(Path(manifest["_trajectory_path"]).read_text(encoding="utf-8"))
assert trajectory["step_count"] == 1
assert trajectory["steps"][0]["bundle_id"] == manifest["bundle_id"]
assert trajectory["steps"][0]["publish_reason"] == "capture"
def test_latest_bundle(self, tmp_path, monkeypatch):
session = Session("preview_test")
session.new_project("/tmp/source.mp4")
def fake_render(session_obj, output_path, on_progress=None):
Path(output_path).write_bytes(b"\x00\x00\x00\x18ftypmp42")
return {
"output": output_path,
"duration": 5.0,
"width": 1280,
"height": 720,
"segments_rendered": 2,
}
def fake_probe(path):
return {"duration": 5.0, "width": 1280, "height": 720}
def fake_thumb(input_path, output_path, time_s=0.0):
Path(output_path).write_bytes(b"\xff\xd8\xff\xe0preview")
return {"output": output_path, "time_s": time_s}
monkeypatch.setattr(export_mod, "render", fake_render)
monkeypatch.setattr(media_mod, "probe", fake_probe)
monkeypatch.setattr(media_mod, "extract_thumbnail", fake_thumb)
created = preview_mod.capture(session, root_dir=str(tmp_path))
latest = preview_mod.latest(root_dir=str(tmp_path))
assert latest["bundle_id"] == created["bundle_id"]
assert os.path.isfile(latest["_trajectory_path"])
# ── Zoom Tests ────────────────────────────────────────────────────────────
class TestZoom:
def test_add_zoom(self):
s = Session()
s.new_project()
result = tl_mod.add_zoom_region(s, 1000, 5000, depth=3, focus_x=0.5, focus_y=0.5)
assert result["startMs"] == 1000
assert result["endMs"] == 5000
assert result["depth"] == 3
assert "id" in result
def test_list_zoom(self):
s = Session()
s.new_project()
assert len(tl_mod.list_zoom_regions(s)) == 0
tl_mod.add_zoom_region(s, 1000, 2000)
assert len(tl_mod.list_zoom_regions(s)) == 1
def test_remove_zoom(self):
s = Session()
s.new_project()
z = tl_mod.add_zoom_region(s, 1000, 2000)
tl_mod.remove_zoom_region(s, z["id"])
assert len(tl_mod.list_zoom_regions(s)) == 0
def test_remove_nonexistent_zoom(self):
s = Session()
s.new_project()
with pytest.raises(ValueError, match="not found"):
tl_mod.remove_zoom_region(s, "nonexistent_id")
def test_invalid_depth(self):
s = Session()
s.new_project()
with pytest.raises(ValueError, match="Invalid depth"):
tl_mod.add_zoom_region(s, 1000, 2000, depth=7)
def test_invalid_focus(self):
s = Session()
s.new_project()
with pytest.raises(ValueError, match="Focus coordinates"):
tl_mod.add_zoom_region(s, 1000, 2000, focus_x=1.5)
def test_invalid_time_range(self):
s = Session()
s.new_project()
with pytest.raises(ValueError, match="end_ms.*must be"):
tl_mod.add_zoom_region(s, 5000, 1000)
def test_zoom_undo(self):
s = Session()
s.new_project()
tl_mod.add_zoom_region(s, 1000, 2000)
assert len(tl_mod.list_zoom_regions(s)) == 1
s.undo()
assert len(tl_mod.list_zoom_regions(s)) == 0
# ── Extra tests from auto version ──────────────────────────────────
def test_add_zoom_focus(self):
s = Session()
s.new_project()
region = tl_mod.add_zoom_region(s, 0, 2000, depth=3, focus_x=0.3, focus_y=0.7)
assert region["focus"]["cx"] == 0.3
assert region["focus"]["cy"] == 0.7
def test_list_zoom_sorted(self):
s = Session()
s.new_project()
tl_mod.add_zoom_region(s, 5000, 8000, depth=1)
tl_mod.add_zoom_region(s, 1000, 3000, depth=2)
regions = tl_mod.list_zoom_regions(s)
starts = [r["startMs"] for r in regions]
assert starts == sorted(starts)
def test_update_zoom_region(self):
s = Session()
s.new_project()
region = tl_mod.add_zoom_region(s, 1000, 3000, depth=1)
updated = tl_mod.update_zoom_region(s, region["id"], depth=4, focus_x=0.8)
assert updated["depth"] == 4
assert updated["focus"]["cx"] == 0.8
def test_update_zoom_nonexistent(self):
s = Session()
s.new_project()
with pytest.raises((ValueError, KeyError)):
tl_mod.update_zoom_region(s, "fake-id")
def test_zoom_depth_map_values(self):
# ZOOM_DEPTHS maps depth int -> scale factor
from cli_anything.openscreen.core.timeline import ZOOM_DEPTHS
assert ZOOM_DEPTHS[1] == 1.25
assert ZOOM_DEPTHS[6] == 5.0
def test_add_zoom_calls_checkpoint(self):
s = Session()
s.new_project()
initial = len(s._undo_stack)
tl_mod.add_zoom_region(s, 0, 1000, depth=2)
assert len(s._undo_stack) > initial
# ── Speed Tests ───────────────────────────────────────────────────────────
class TestSpeed:
def test_add_speed(self):
s = Session()
s.new_project()
result = tl_mod.add_speed_region(s, 5000, 10000, speed=2.0)
assert result["speed"] == 2.0
def test_invalid_speed(self):
s = Session()
s.new_project()
with pytest.raises(ValueError, match="Invalid speed"):
tl_mod.add_speed_region(s, 1000, 2000, speed=3.0)
def test_list_and_remove(self):
s = Session()
s.new_project()
r = tl_mod.add_speed_region(s, 1000, 2000, speed=1.5)
assert len(tl_mod.list_speed_regions(s)) == 1
tl_mod.remove_speed_region(s, r["id"])
assert len(tl_mod.list_speed_regions(s)) == 0
# ── Extra tests from auto version ──────────────────────────────────
def test_add_speed_all_valid_values(self):
from cli_anything.openscreen.core.timeline import VALID_SPEEDS
s = Session()
s.new_project()
for speed in VALID_SPEEDS:
region = tl_mod.add_speed_region(s, 0, 1000, speed=speed)
assert region["speed"] == speed
def test_list_speed_sorted(self):
s = Session()
s.new_project()
tl_mod.add_speed_region(s, 5000, 8000, speed=1.5)
tl_mod.add_speed_region(s, 1000, 3000, speed=2.0)
regions = tl_mod.list_speed_regions(s)
starts = [r["startMs"] for r in regions]
assert starts == sorted(starts)
def test_remove_speed_nonexistent(self):
s = Session()
s.new_project()
with pytest.raises((ValueError, KeyError)):
tl_mod.remove_speed_region(s, "nonexistent")
# ── Trim Tests ────────────────────────────────────────────────────────────
class TestTrim:
def test_add_trim(self):
s = Session()
s.new_project()
result = tl_mod.add_trim_region(s, 0, 1000)
assert result["startMs"] == 0
assert result["endMs"] == 1000
def test_list_and_remove(self):
s = Session()
s.new_project()
r = tl_mod.add_trim_region(s, 0, 1000)
assert len(tl_mod.list_trim_regions(s)) == 1
tl_mod.remove_trim_region(s, r["id"])
assert len(tl_mod.list_trim_regions(s)) == 0
# ── Extra tests from auto version ──────────────────────────────────
def test_add_trim_zero_start(self):
s = Session()
s.new_project()
region = tl_mod.add_trim_region(s, 0, 1000)
assert region["startMs"] == 0
def test_list_trim_sorted(self):
s = Session()
s.new_project()
tl_mod.add_trim_region(s, 8000, 10000)
tl_mod.add_trim_region(s, 1000, 3000)
regions = tl_mod.list_trim_regions(s)
starts = [r["startMs"] for r in regions]
assert starts == sorted(starts)
def test_remove_trim_nonexistent(self):
s = Session()
s.new_project()
with pytest.raises((ValueError, KeyError)):
tl_mod.remove_trim_region(s, "fake")
# ── Crop Tests ────────────────────────────────────────────────────────────
class TestCrop:
def test_default_crop(self):
s = Session()
s.new_project()
crop = tl_mod.get_crop(s)
assert crop == {"x": 0, "y": 0, "width": 1, "height": 1}
def test_set_crop(self):
s = Session()
s.new_project()
result = tl_mod.set_crop(s, 0.1, 0.1, 0.8, 0.8)
assert result["x"] == 0.1
assert result["width"] == 0.8
def test_invalid_crop(self):
s = Session()
s.new_project()
with pytest.raises(ValueError):
tl_mod.set_crop(s, 0, 0, 1.5, 1)
def test_crop_out_of_bounds(self):
s = Session()
s.new_project()
with pytest.raises(ValueError, match="beyond frame"):
tl_mod.set_crop(s, 0.5, 0.5, 0.6, 0.6)
# ── Extra tests from auto version ──────────────────────────────────
def test_set_crop_overflow_x(self):
s = Session()
s.new_project()
with pytest.raises(ValueError):
tl_mod.set_crop(s, 0.5, 0.0, 0.6, 1.0)
def test_set_crop_overflow_y(self):
s = Session()
s.new_project()
with pytest.raises(ValueError):
tl_mod.set_crop(s, 0.0, 0.5, 1.0, 0.6)
def test_set_crop_negative_raises(self):
s = Session()
s.new_project()
with pytest.raises(ValueError):
tl_mod.set_crop(s, -0.1, 0.0, 1.0, 1.0)
def test_set_crop_calls_checkpoint(self):
s = Session()
s.new_project()
initial = len(s._undo_stack)
tl_mod.set_crop(s, 0.0, 0.0, 0.5, 0.5)
assert len(s._undo_stack) > initial
def test_crop_full_frame_valid(self):
s = Session()
s.new_project()
crop = tl_mod.set_crop(s, 0.0, 0.0, 1.0, 1.0)
assert crop["x"] == 0.0
# ── Annotation Tests ──────────────────────────────────────────────────────
class TestAnnotation:
def test_add_text_annotation(self):
s = Session()
s.new_project()
result = tl_mod.add_text_annotation(s, 1000, 3000, "Hello World")
assert result["type"] == "text"
assert result["textContent"] == "Hello World"
def test_list_and_remove(self):
s = Session()
s.new_project()
a = tl_mod.add_text_annotation(s, 1000, 3000, "Test")
assert len(tl_mod.list_annotations(s)) == 1
tl_mod.remove_annotation(s, a["id"])
assert len(tl_mod.list_annotations(s)) == 0
# ── Extra tests from auto version ──────────────────────────────────
def test_add_annotation_position(self):
s = Session()
s.new_project()
region = tl_mod.add_text_annotation(s, 0, 2000, "Positioned", x=0.25, y=0.75)
assert region["position"]["x"] == 0.25
assert region["position"]["y"] == 0.75
def test_list_annotations_sorted(self):
s = Session()
s.new_project()
tl_mod.add_text_annotation(s, 5000, 8000, "Later")
tl_mod.add_text_annotation(s, 1000, 3000, "Earlier")
regions = tl_mod.list_annotations(s)
starts = [r["startMs"] for r in regions]
assert starts == sorted(starts)
def test_update_annotation_text(self):
s = Session()
s.new_project()
region = tl_mod.add_text_annotation(s, 0, 2000, "original")
tl_mod.update_annotation(s, region["id"], text_content="updated")
regions = tl_mod.list_annotations(s)
assert regions[0]["textContent"] == "updated"
def test_update_annotation_nonexistent(self):
s = Session()
s.new_project()
with pytest.raises((ValueError, KeyError)):
tl_mod.update_annotation(s, "fake-id", text_content="x")
def test_annotation_style_fields(self):
s = Session()
s.new_project()
region = tl_mod.add_text_annotation(s, 0, 1000, "Styled", color="#ff0000", font_size=32)
assert region["style"]["color"] == "#ff0000"
assert region["style"]["fontSize"] == 32
# ── Integration Tests ─────────────────────────────────────────────────────
class TestIntegration:
def test_full_workflow(self):
"""Test a complete project workflow: create, edit, save, reopen."""
s = Session()
s.new_project()
# Set settings
proj_mod.set_setting(s, "aspectRatio", "16:9")
proj_mod.set_setting(s, "wallpaper", "gradient_dark")
proj_mod.set_setting(s, "padding", 40)
# Add regions
z1 = tl_mod.add_zoom_region(s, 2000, 5000, depth=3, focus_x=0.7, focus_y=0.3)
z2 = tl_mod.add_zoom_region(s, 8000, 12000, depth=4, focus_x=0.5, focus_y=0.5)
sp = tl_mod.add_speed_region(s, 15000, 20000, speed=2.0)
tr = tl_mod.add_trim_region(s, 0, 500)
ann = tl_mod.add_text_annotation(s, 5000, 7000, "Click here!")
tl_mod.set_crop(s, 0, 0, 1, 1)
# Verify counts
info = proj_mod.info(s)
assert info["zoom_regions"] == 2
assert info["speed_regions"] == 1
assert info["trim_regions"] == 1
assert info["annotations"] == 1
# Save and reopen
with tempfile.NamedTemporaryFile(suffix=".openscreen", delete=False) as f:
path = f.name
try:
s.save_project(path)
s2 = Session()
s2.open_project(path)
info2 = proj_mod.info(s2)
assert info2["zoom_regions"] == 2
assert info2["speed_regions"] == 1
assert info2["annotations"] == 1
finally:
os.unlink(path)
def test_export_presets(self):
"""Test that export presets are available."""
presets = export_mod.list_presets()
assert len(presets) > 0
assert any(p["name"] == "mp4_good" for p in presets)
# ── Extra tests from auto version ──────────────────────────────────
def test_undo_redo_zoom_workflow(self):
s = Session()
s.new_project()
tl_mod.add_zoom_region(s, 1000, 3000, depth=2)
assert len(tl_mod.list_zoom_regions(s)) == 1
s.undo()
assert len(tl_mod.list_zoom_regions(s)) == 0
s.redo()
assert len(tl_mod.list_zoom_regions(s)) == 1
def test_multiple_undo_levels(self):
s = Session()
s.new_project()
for i in range(5):
tl_mod.add_zoom_region(s, i * 1000, (i + 1) * 1000, depth=1)
assert len(tl_mod.list_zoom_regions(s)) == 5
for _ in range(5):
s.undo()
assert len(tl_mod.list_zoom_regions(s)) == 0
def test_timeline_boundaries(self):
s = Session()
s.new_project()
tl_mod.add_zoom_region(s, 1000, 3000, depth=1)
tl_mod.add_speed_region(s, 2000, 5000, speed=1.5)
tl_mod.add_trim_region(s, 4000, 6000)
boundaries = tl_mod.get_timeline_boundaries(s)
assert 0 in boundaries
assert 1000 in boundaries
assert 2000 in boundaries
assert 3000 in boundaries
assert 4000 in boundaries
assert 5000 in boundaries
assert 6000 in boundaries
def test_active_regions_at(self):
s = Session()
s.new_project()
tl_mod.add_zoom_region(s, 1000, 5000, depth=2)
tl_mod.add_speed_region(s, 2000, 4000, speed=1.5)
tl_mod.add_trim_region(s, 6000, 8000)
active = tl_mod.get_active_regions_at(s, 3000)
assert len(active["zoom"]) == 1
assert len(active["speed"]) == 1
assert len(active["trim"]) == 0
active2 = tl_mod.get_active_regions_at(s, 7000)
assert len(active2["trim"]) == 1
assert len(active2["zoom"]) == 0
def test_project_set_triggers_undo(self):
s = Session()
s.new_project()
proj_mod.set_setting(s, "padding", 10)
proj_mod.set_setting(s, "padding", 30)
assert s.editor["padding"] == 30
s.undo()
assert s.editor["padding"] == 10
@@ -0,0 +1,524 @@
"""End-to-end tests for Openscreen CLI — requires ffmpeg installed.
These tests create real video files, run the full export pipeline,
and verify outputs with ffprobe.
Run with:
python3 -m pytest cli_anything/openscreen/tests/test_full_e2e.py -v -s
Force installed CLI:
CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest ... -v -s
Test classes:
TestMediaE2E - probe_real_video, check_video, check_invalid_video,
extract_thumbnail, extract_thumbnail_at_zero,
extract_frames, ffmpeg_and_ffprobe_found
TestExportE2E - basic_export, export_with_zoom, export_with_speed,
export_with_trim, export_complex,
export_no_video_raises, export_missing_video_raises
TestCLISubprocess - cli_help, cli_version, cli_export_presets,
cli_media_probe, cli_project_new_json, cli_zoom_add,
cli_full_workflow, cli_media_check_valid,
cli_session_status
"""
import json
import os
import subprocess
import sys
import tempfile
import pytest
from cli_anything.openscreen.core.session import Session
from cli_anything.openscreen.core import project as proj_mod
from cli_anything.openscreen.core import timeline as tl_mod
from cli_anything.openscreen.core import export as export_mod
from cli_anything.openscreen.core import media as media_mod
from cli_anything.openscreen.core import preview as preview_mod
from cli_anything.openscreen.utils import ffmpeg_backend
# ── CLI resolver ───────────────────────────────────────────────────────────
def _resolve_cli(name: str):
"""Resolve installed CLI command; falls back to python -m for dev.
Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command.
"""
import shutil
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
path = shutil.which(name)
if path:
print(f"[_resolve_cli] Using installed command: {path}")
return [path]
if force:
raise RuntimeError(
f"{name} not found in PATH. Install with: pip install -e ."
)
module = "cli_anything.openscreen.openscreen_cli"
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
return [sys.executable, "-m", module]
# ── Fixtures ──────────────────────────────────────────────────────────────
JPEG_MAGIC_PREFIX = b"\xff\xd8\xff"
@pytest.fixture(scope="module")
def test_video():
"""Create a 5-second test video with ffmpeg."""
tmpdir = tempfile.mkdtemp()
video_path = os.path.join(tmpdir, "test_recording.mp4")
try:
subprocess.run([
"ffmpeg", "-y",
"-f", "lavfi", "-i",
"testsrc=duration=5:size=1920x1080:rate=30",
"-f", "lavfi", "-i", "sine=frequency=440:duration=5",
"-c:v", "libx264", "-c:a", "aac", "-shortest",
video_path,
], capture_output=True, check=True, timeout=30)
except (FileNotFoundError, subprocess.CalledProcessError):
pytest.skip("ffmpeg not available")
yield video_path
# Cleanup
try:
os.remove(video_path)
os.rmdir(tmpdir)
except OSError:
pass
@pytest.fixture
def session(test_video):
"""Create a session with a project and test video attached."""
s = Session()
s.new_project(test_video)
return s
def _artifact_path(manifest, artifact_id):
for artifact in manifest["artifacts"]:
if artifact["artifact_id"] == artifact_id:
return os.path.join(manifest["_bundle_dir"], artifact["path"])
raise KeyError(f"Artifact not found: {artifact_id}")
def _assert_jpeg(path):
assert os.path.isfile(path), f"Missing JPEG artifact: {path}"
with open(path, "rb") as fh:
assert fh.read(3) == JPEG_MAGIC_PREFIX, f"Invalid JPEG header: {path}"
assert os.path.getsize(path) > 0, f"Empty JPEG artifact: {path}"
# ── Media Tests ───────────────────────────────────────────────────────────
class TestMediaE2E:
def test_probe_real_video(self, test_video):
result = media_mod.probe(test_video)
assert result["width"] == 1920
assert result["height"] == 1080
assert result["duration"] > 4.0
assert result["codec"] == "h264"
assert result["has_audio"] is True
def test_check_video(self, test_video):
result = media_mod.check_video(test_video)
assert result["valid"] is True
assert result["width"] == 1920
def test_check_invalid_video(self):
result = media_mod.check_video("/nonexistent/file.mp4")
assert result["valid"] is False
def test_extract_thumbnail(self, test_video):
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
thumb_path = f.name
try:
result = media_mod.extract_thumbnail(test_video, thumb_path, time_s=1.0)
assert os.path.exists(thumb_path)
assert result["file_size"] > 0
finally:
os.unlink(thumb_path)
def test_extract_thumbnail_at_zero(self, test_video):
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
thumb_path = f.name
try:
result = media_mod.extract_thumbnail(test_video, thumb_path, time_s=0.0)
assert os.path.exists(thumb_path)
assert result["file_size"] > 0
finally:
os.unlink(thumb_path)
def test_extract_frames(self, test_video):
tmpdir = tempfile.mkdtemp()
try:
frames = ffmpeg_backend.extract_frames(test_video, tmpdir, fps=2, max_frames=10)
assert len(frames) > 0
assert all(f.endswith(".jpg") for f in frames)
assert all(os.path.getsize(f) > 0 for f in frames)
finally:
import shutil
shutil.rmtree(tmpdir)
def test_ffmpeg_and_ffprobe_found(self):
ffmpeg = ffmpeg_backend.find_ffmpeg()
ffprobe = ffmpeg_backend.find_ffprobe()
assert os.path.exists(ffmpeg)
assert os.path.exists(ffprobe)
print(f"\n ffmpeg: {ffmpeg}")
print(f" ffprobe: {ffprobe}")
# ── Export Tests ──────────────────────────────────────────────────────────
class TestExportE2E:
def test_basic_export(self, session):
"""Export with default settings (no regions)."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
out_path = f.name
try:
result = export_mod.render(session, out_path)
assert os.path.exists(out_path)
assert result["file_size"] > 0
assert result["width"] > 0
assert result["codec"] == "h264"
assert result["segments_rendered"] >= 1
finally:
os.unlink(out_path)
def test_export_with_zoom(self, session):
"""Export with a zoom region."""
tl_mod.add_zoom_region(session, 1000, 3000, depth=3, focus_x=0.7, focus_y=0.3)
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
out_path = f.name
try:
result = export_mod.render(session, out_path)
assert os.path.exists(out_path)
assert result["file_size"] > 0
assert result["segments_rendered"] >= 2 # before, during, after zoom
finally:
os.unlink(out_path)
def test_export_with_speed(self, session):
"""Export with a speed region."""
tl_mod.add_speed_region(session, 2000, 4000, speed=2.0)
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
out_path = f.name
try:
result = export_mod.render(session, out_path)
assert os.path.exists(out_path)
assert result["file_size"] > 0
# Output should be shorter than source due to 2x speed section
assert result["duration"] < 5.0
finally:
os.unlink(out_path)
def test_export_with_trim(self, session):
"""Export with a trim region."""
tl_mod.add_trim_region(session, 0, 1000)
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
out_path = f.name
try:
result = export_mod.render(session, out_path)
assert os.path.exists(out_path)
# Should be shorter due to trimmed 1 second
assert result["duration"] < 5.0
finally:
os.unlink(out_path)
def test_export_complex(self, session):
"""Export with multiple regions and settings."""
proj_mod.set_setting(session, "padding", 40)
proj_mod.set_setting(session, "wallpaper", "solid_dark")
tl_mod.add_zoom_region(session, 500, 2000, depth=4, focus_x=0.5, focus_y=0.5)
tl_mod.add_speed_region(session, 3000, 4500, speed=1.5)
tl_mod.add_trim_region(session, 0, 200)
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
out_path = f.name
try:
result = export_mod.render(session, out_path)
assert os.path.exists(out_path)
assert result["file_size"] > 0
assert result["width"] == 1920
assert result["height"] == 1080
finally:
os.unlink(out_path)
def test_export_no_video_raises(self):
"""Export fails if no source video is set."""
s = Session()
s.new_project()
with pytest.raises(Exception):
export_mod.render(s, "/tmp/out.mp4")
def test_export_missing_video_raises(self):
"""Export fails if source video file is missing."""
import tempfile as _tempfile
with _tempfile.TemporaryDirectory() as tmp_dir:
s = Session()
s.new_project(video_path="/tmp/nonexistent_12345.mp4")
with pytest.raises(Exception):
export_mod.render(s, os.path.join(tmp_dir, "out.mp4"))
class TestPreviewE2E:
def test_capture_preview_bundle(self, session):
tl_mod.add_zoom_region(session, 800, 2400, depth=2, focus_x=0.65, focus_y=0.35)
tl_mod.add_speed_region(session, 2500, 3800, speed=1.5)
with tempfile.TemporaryDirectory() as tmp_dir:
project_path = os.path.join(tmp_dir, "preview.openscreen")
proj_mod.save_project(session, project_path)
manifest = preview_mod.capture(session, root_dir=tmp_dir, force=True)
assert manifest["software"] == "openscreen"
assert manifest["bundle_kind"] == "capture"
assert manifest["status"] in ("ok", "partial")
clip_path = _artifact_path(manifest, "clip")
hero_path = _artifact_path(manifest, "frame_03")
probe = media_mod.probe(clip_path)
assert os.path.isfile(clip_path)
assert os.path.getsize(clip_path) > 0
assert probe["width"] > 0
assert probe["height"] > 0
assert probe["duration"] > 0
_assert_jpeg(hero_path)
latest = preview_mod.latest(project_path=project_path, recipe="quick", root_dir=tmp_dir)
assert latest["bundle_id"] == manifest["bundle_id"]
print(f"\n Openscreen preview bundle: {manifest['_bundle_dir']}")
print(f" Openscreen preview clip: {clip_path}")
print(f" Openscreen preview hero: {hero_path}")
# ── CLI Subprocess Tests ──────────────────────────────────────────────────
class TestCLISubprocess:
CLI_BASE = _resolve_cli("cli-anything-openscreen")
def _run(self, args, check=True, timeout=30):
return subprocess.run(
self.CLI_BASE + args,
capture_output=True, text=True,
timeout=timeout,
check=check,
)
def test_cli_help(self):
result = self._run(["--help"], check=False)
assert result.returncode == 0
assert "Openscreen CLI" in result.stdout
def test_cli_version(self):
result = self._run(["--version"], check=False)
assert result.returncode == 0
assert "1.0.0" in result.stdout
def test_cli_export_presets(self):
result = self._run(["--json", "export", "presets"], check=False)
assert result.returncode == 0
data = json.loads(result.stdout)
assert isinstance(data, list)
assert len(data) > 0
def test_cli_media_probe(self, test_video):
result = self._run(["--json", "media", "probe", test_video], check=False)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["width"] == 1920
def test_cli_project_new_json(self):
"""CLI project new --json returns project info."""
with tempfile.TemporaryDirectory() as tmp_dir:
proj_path = os.path.join(tmp_dir, "cli_test.openscreen")
result = self._run(["--json", "project", "new", "-o", proj_path], check=False)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data.get("status") == "created"
assert "saved_to" in data
assert os.path.exists(proj_path)
print(f"\n Project created: {proj_path}")
def test_cli_zoom_add(self):
"""CLI zoom add works and persists to file."""
with tempfile.TemporaryDirectory() as tmp_dir:
proj_path = os.path.join(tmp_dir, "zoom_test.openscreen")
# Create project
self._run(["project", "new", "-o", proj_path], check=False)
# Add zoom
result = self._run([
"--json", "--project", proj_path,
"zoom", "add",
"--start", "1000", "--end", "3000", "--depth", "3",
], check=False)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["depth"] == 3
assert data["startMs"] == 1000
# Verify saved
result2 = self._run(
["--json", "--project", proj_path, "zoom", "list"], check=False
)
assert result2.returncode == 0
print(f"\n Zoom add result: depth={data['depth']}, id={data['id']}")
def test_cli_full_workflow(self, test_video):
"""Full CLI workflow: create project, add regions, export."""
with tempfile.TemporaryDirectory() as tmp_dir:
proj_path = os.path.join(tmp_dir, "workflow.openscreen")
output_path = os.path.join(tmp_dir, "workflow_output.mp4")
# 1. Create project with video
result = self._run(
["project", "new", "-v", test_video, "-o", proj_path], check=False
)
assert result.returncode == 0
assert os.path.exists(proj_path)
# 2. Add zoom region
result = self._run([
"--project", proj_path,
"zoom", "add",
"--start", "1000", "--end", "2000", "--depth", "2",
], check=False)
assert result.returncode == 0
# 3. Set quality
result = self._run([
"--project", proj_path,
"project", "set", "exportQuality", "medium",
], check=False)
assert result.returncode == 0
# 4. Export
result = self._run([
"--json", "--project", proj_path,
"export", "render", output_path,
], check=False)
assert result.returncode == 0
data = json.loads(result.stdout)
assert os.path.exists(output_path)
assert data["file_size"] > 1000
# 5. Probe output
result = self._run(
["--json", "media", "probe", output_path], check=False
)
assert result.returncode == 0
probe_data = json.loads(result.stdout)
assert probe_data["width"] > 0
assert probe_data["duration"] > 0
print(f"\n Full workflow output: {output_path}")
print(f" Size: {data['file_size']:,} bytes")
print(f" Duration: {data['duration']:.2f}s")
print(f" Dimensions: {probe_data['width']}x{probe_data['height']}")
def test_cli_media_check_valid(self, test_video):
"""CLI media check returns valid=True for a real video."""
result = self._run(["--json", "media", "check", test_video], check=False)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["valid"] is True
def test_cli_session_status(self):
"""CLI session status works."""
with tempfile.TemporaryDirectory() as tmp_dir:
proj_path = os.path.join(tmp_dir, "status_test.openscreen")
self._run(["project", "new", "-o", proj_path], check=False)
result = self._run(
["--json", "--project", proj_path, "session", "status"], check=False
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "project_open" in data
assert "undo_available" in data
def test_cli_preview_capture(self, test_video):
with tempfile.TemporaryDirectory() as tmp_dir:
proj_path = os.path.join(tmp_dir, "preview.openscreen")
result = self._run(
["project", "new", "-v", test_video, "-o", proj_path],
check=False,
timeout=60,
)
assert result.returncode == 0
self._run(
[
"--project",
proj_path,
"zoom",
"add",
"--start",
"700",
"--end",
"2200",
"--depth",
"2",
],
check=False,
timeout=60,
)
result = self._run(
[
"--json",
"--project",
proj_path,
"preview",
"capture",
"--root-dir",
tmp_dir,
],
check=False,
timeout=180,
)
assert result.returncode == 0, result.stderr
manifest = json.loads(result.stdout)
assert manifest["software"] == "openscreen"
clip_path = _artifact_path(manifest, "clip")
hero_path = _artifact_path(manifest, "frame_03")
assert os.path.isfile(clip_path)
_assert_jpeg(hero_path)
latest = self._run(
[
"--json",
"preview",
"latest",
"--recipe",
"quick",
"--root-dir",
tmp_dir,
],
check=False,
timeout=60,
)
assert latest.returncode == 0, latest.stderr
latest_manifest = json.loads(latest.stdout)
assert latest_manifest["bundle_id"] == manifest["bundle_id"]
print(f"\n Openscreen preview bundle: {manifest['_bundle_dir']}")
print(f" Openscreen preview clip: {clip_path}")
print(f" Openscreen preview hero: {hero_path}")
@@ -0,0 +1,312 @@
"""ffmpeg backend — subprocess wrapper for video processing.
Openscreen's GUI uses WebCodecs + PixiJS for rendering, but the CLI
harness delegates to ffmpeg for all video operations: probe, crop,
zoom (via crop+scale), speed changes, trim, background compositing,
and final export.
"""
import json
import os
import shutil
import subprocess
from typing import Optional
def find_ffmpeg() -> str:
"""Find the ffmpeg executable. Raises RuntimeError if not found."""
path = shutil.which("ffmpeg")
if path:
return path
raise RuntimeError(
"ffmpeg is not installed.\n"
" macOS: brew install ffmpeg\n"
" Linux: apt install ffmpeg\n"
" Windows: winget install ffmpeg"
)
def find_ffprobe() -> str:
"""Find the ffprobe executable. Raises RuntimeError if not found."""
path = shutil.which("ffprobe")
if path:
return path
raise RuntimeError(
"ffprobe is not installed (usually bundled with ffmpeg).\n"
" macOS: brew install ffmpeg\n"
" Linux: apt install ffmpeg"
)
def probe(input_path: str) -> dict:
"""Probe a media file and return its metadata.
Returns dict with keys: width, height, duration, fps, codec,
has_audio, file_size, path.
"""
exe = find_ffprobe()
if not os.path.isfile(input_path):
raise FileNotFoundError(f"File not found: {input_path}")
cmd = [
exe, "-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams",
input_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"ffprobe failed: {result.stderr[:500]}")
data = json.loads(result.stdout)
video_stream = None
has_audio = False
for s in data.get("streams", []):
if s.get("codec_type") == "video" and video_stream is None:
video_stream = s
if s.get("codec_type") == "audio":
has_audio = True
if not video_stream:
raise ValueError(f"No video stream in {input_path}")
width = int(video_stream["width"])
height = int(video_stream["height"])
duration = float(data["format"].get("duration", 0))
if duration == 0 and "duration" in video_stream:
duration = float(video_stream["duration"])
r_frame_rate = video_stream.get("r_frame_rate", "30/1")
num, den = r_frame_rate.split("/")
fps = float(num) / float(den) if float(den) != 0 else 30.0
return {
"width": width,
"height": height,
"duration": round(duration, 3),
"fps": round(fps, 2),
"codec": video_stream.get("codec_name", "unknown"),
"has_audio": has_audio,
"file_size": os.path.getsize(input_path),
"path": os.path.abspath(input_path),
}
def render_segment(
input_path: str,
output_path: str,
start_s: float,
end_s: float,
target_w: int,
target_h: int,
fps: int = 30,
speed: float = 1.0,
crop: Optional[dict] = None,
overwrite: bool = True,
timeout: int = 300,
) -> dict:
"""Render a video segment with optional crop and speed change.
Args:
input_path: Source video file.
output_path: Destination MP4 file.
start_s: Start time in seconds.
end_s: End time in seconds.
target_w: Output width.
target_h: Output height.
fps: Output frame rate.
speed: Playback speed multiplier.
crop: Optional dict with keys w, h, x, y (pixel values).
overwrite: Whether to overwrite existing output.
timeout: Subprocess timeout in seconds.
Returns:
Dict with output path, file_size, method.
"""
exe = find_ffmpeg()
if not os.path.isfile(input_path):
raise FileNotFoundError(f"Input not found: {input_path}")
if os.path.exists(output_path) and not overwrite:
raise FileExistsError(f"Output exists: {output_path}")
vf_parts = []
if crop:
vf_parts.append(f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}")
vf_parts.append(f"scale={target_w}:{target_h}:flags=lanczos")
if speed != 1.0:
vf_parts.append(f"setpts={1.0/speed}*PTS")
af = None
if speed != 1.0:
af_parts = []
s = speed
while s > 2.0:
af_parts.append("atempo=2.0")
s /= 2.0
while s < 0.5:
af_parts.append("atempo=0.5")
s *= 2.0
af_parts.append(f"atempo={s:.4f}")
af = ",".join(af_parts)
cmd = [
exe, "-y" if overwrite else "-n",
"-ss", str(start_s), "-to", str(end_s),
"-i", input_path,
"-vf", ",".join(vf_parts),
]
if af:
cmd += ["-af", af]
cmd += [
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
"-c:a", "aac", "-b:a", "128k",
"-r", str(fps), "-pix_fmt", "yuv420p",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
raise RuntimeError(
f"ffmpeg render failed (exit {result.returncode}):\n"
f" stderr: {result.stderr[-500:]}"
)
if not os.path.exists(output_path):
raise RuntimeError(f"ffmpeg produced no output: {output_path}")
return {
"output": os.path.abspath(output_path),
"file_size": os.path.getsize(output_path),
"method": "ffmpeg",
}
def concat_segments(
segment_paths: list[str],
output_path: str,
overwrite: bool = True,
timeout: int = 300,
) -> dict:
"""Concatenate multiple video segments into one file.
Args:
segment_paths: List of MP4 file paths to concatenate in order.
output_path: Output MP4 path.
Returns:
Dict with output path, file_size, segment_count.
"""
exe = find_ffmpeg()
if not segment_paths:
raise ValueError("No segments to concatenate")
import tempfile
concat_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
)
try:
for sp in segment_paths:
concat_file.write(f"file '{os.path.abspath(sp)}'\n")
concat_file.close()
cmd = [
exe, "-y" if overwrite else "-n",
"-f", "concat", "-safe", "0",
"-i", concat_file.name,
"-c", "copy",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg concat failed: {result.stderr[-500:]}")
finally:
os.unlink(concat_file.name)
return {
"output": os.path.abspath(output_path),
"file_size": os.path.getsize(output_path),
"segment_count": len(segment_paths),
"method": "ffmpeg-concat",
}
def composite_on_background(
input_path: str,
output_path: str,
canvas_w: int,
canvas_h: int,
video_w: int,
video_h: int,
bg_color: str = "#1a1a2e",
fps: int = 30,
overwrite: bool = True,
timeout: int = 300,
) -> dict:
"""Composite a video centered on a solid-color background.
Args:
input_path: Source video (already scaled to video_w x video_h).
output_path: Output MP4 path.
canvas_w, canvas_h: Full output canvas size.
video_w, video_h: Video size within canvas.
bg_color: Hex color for background.
fps: Output frame rate.
"""
exe = find_ffmpeg()
x_off = (canvas_w - video_w) // 2
y_off = (canvas_h - video_h) // 2
cmd = [
exe, "-y" if overwrite else "-n",
"-f", "lavfi", "-i",
f"color=c='{bg_color}':s={canvas_w}x{canvas_h}:r={fps}",
"-i", input_path,
"-filter_complex",
f"[1:v]scale={video_w}:{video_h}[fg];"
f"[0:v][fg]overlay={x_off}:{y_off}:shortest=1",
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-c:a", "aac", "-b:a", "192k",
"-map", "1:a?",
"-pix_fmt", "yuv420p",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg composite failed: {result.stderr[-500:]}")
return {
"output": os.path.abspath(output_path),
"file_size": os.path.getsize(output_path),
"method": "ffmpeg-composite",
}
def extract_frames(
input_path: str,
output_dir: str,
fps: float = 2.0,
max_frames: int = 60,
scale_width: int = 960,
) -> list[str]:
"""Extract frames from a video for analysis.
Returns list of JPEG file paths.
"""
exe = find_ffmpeg()
os.makedirs(output_dir, exist_ok=True)
cmd = [
exe, "-y", "-i", input_path,
"-vf", f"fps={fps},scale={scale_width}:-1",
"-frames:v", str(max_frames),
"-q:v", "3",
os.path.join(output_dir, "frame_%04d.jpg"),
]
subprocess.run(cmd, capture_output=True, check=True, timeout=120)
return sorted([
os.path.join(output_dir, f)
for f in os.listdir(output_dir)
if f.startswith("frame_") and f.endswith(".jpg")
])
@@ -0,0 +1,468 @@
"""Shared helpers for CLI-Anything preview bundles."""
from __future__ import annotations
import hashlib
import json
import mimetypes
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, Optional
PROTOCOL_VERSION = "preview-bundle/v1"
TRAJECTORY_PROTOCOL_VERSION = "preview-trajectory/v1"
def _slug(value: str) -> str:
text = (value or "preview").strip().lower()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = re.sub(r"-{2,}", "-", text).strip("-")
return text or "preview"
def _json_dumps(data: Any) -> str:
return json.dumps(
data,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
def hash_data(data: Any) -> str:
return hashlib.sha256(_json_dumps(data).encode("utf-8")).hexdigest()
def fingerprint_data(data: Any) -> str:
return f"sha256:{hash_data(data)}"
def fingerprint_file(path: str) -> str:
resolved = os.path.abspath(path)
stat = os.stat(resolved)
return fingerprint_data(
{
"path": resolved,
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
}
)
def bundle_root(
software: str,
recipe: str,
project_path: Optional[str] = None,
root_dir: Optional[str] = None,
) -> Path:
if root_dir:
base = Path(root_dir).expanduser().resolve()
elif project_path:
base = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews"
else:
base = Path.home() / ".cli-anything" / "previews"
return base / _slug(software) / _slug(recipe)
def build_cache_key(
software: str,
recipe: str,
bundle_kind: str,
source_fingerprint: str,
options: Optional[Dict[str, Any]] = None,
harness_version: Optional[str] = None,
protocol_version: str = PROTOCOL_VERSION,
) -> str:
return fingerprint_data(
{
"protocol_version": protocol_version,
"software": software,
"recipe": recipe,
"bundle_kind": bundle_kind,
"source_fingerprint": source_fingerprint,
"options": options or {},
"harness_version": harness_version or "",
}
)
def _iter_manifests(search_root: Path) -> Iterable[Path]:
if not search_root.exists():
return []
return sorted(search_root.rglob("manifest.json"), reverse=True)
def _load_json(path: Path) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def find_cached_manifest(
software: str,
recipe: str,
bundle_kind: str,
cache_key: str,
project_path: Optional[str] = None,
root_dir: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
root = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir)
for manifest_path in _iter_manifests(root):
try:
manifest = _load_json(manifest_path)
except (OSError, json.JSONDecodeError):
continue
if (
manifest.get("protocol_version") == PROTOCOL_VERSION
and manifest.get("software") == software
and manifest.get("recipe") == recipe
and manifest.get("bundle_kind") == bundle_kind
and manifest.get("status") in {"ok", "partial"}
and manifest.get("cache_key") == cache_key
):
manifest["_manifest_path"] = str(manifest_path.resolve())
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
manifest["_summary_path"] = str(
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
)
return manifest
return None
def find_latest_manifest(
software: str,
recipe: Optional[str] = None,
bundle_kind: Optional[str] = None,
project_path: Optional[str] = None,
root_dir: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if root_dir:
search_root = Path(root_dir).expanduser().resolve() / _slug(software)
elif project_path:
search_root = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews" / _slug(software)
else:
search_root = Path.home() / ".cli-anything" / "previews" / _slug(software)
if recipe:
search_root = search_root / _slug(recipe)
for manifest_path in _iter_manifests(search_root):
try:
manifest = _load_json(manifest_path)
except (OSError, json.JSONDecodeError):
continue
if manifest.get("software") != software:
continue
if recipe and manifest.get("recipe") != recipe:
continue
if bundle_kind and manifest.get("bundle_kind") != bundle_kind:
continue
if manifest.get("status") not in {"ok", "partial"}:
continue
manifest["_manifest_path"] = str(manifest_path.resolve())
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
manifest["_summary_path"] = str(
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
)
return manifest
return None
def prepare_bundle(
software: str,
recipe: str,
bundle_kind: str,
source_fingerprint: str,
options: Optional[Dict[str, Any]] = None,
harness_version: Optional[str] = None,
project_path: Optional[str] = None,
root_dir: Optional[str] = None,
force: bool = False,
) -> Dict[str, Any]:
cache_key = build_cache_key(
software=software,
recipe=recipe,
bundle_kind=bundle_kind,
source_fingerprint=source_fingerprint,
options=options or {},
harness_version=harness_version,
)
if not force:
cached = find_cached_manifest(
software=software,
recipe=recipe,
bundle_kind=bundle_kind,
cache_key=cache_key,
project_path=project_path,
root_dir=root_dir,
)
if cached:
return {
"cached": True,
"cache_key": cache_key,
"bundle_id": cached.get("bundle_id"),
"bundle_dir": cached["_bundle_dir"],
"manifest_path": cached["_manifest_path"],
"summary_path": os.path.join(cached["_bundle_dir"], cached.get("summary_path", "summary.json")),
"manifest": cached,
}
now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
bundle_id = f"{now}_{cache_key.split(':', 1)[-1][:8]}_{_slug(recipe)}"
out_dir = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir) / bundle_id
artifacts_dir = out_dir / "artifacts"
artifacts_dir.mkdir(parents=True, exist_ok=False)
return {
"cached": False,
"cache_key": cache_key,
"bundle_id": bundle_id,
"bundle_dir": str(out_dir.resolve()),
"artifacts_dir": str(artifacts_dir.resolve()),
"manifest_path": str((out_dir / "manifest.json").resolve()),
"summary_path": str((out_dir / "summary.json").resolve()),
}
def artifact_record(
bundle_dir: str,
path: str,
artifact_id: str,
role: str,
kind: str,
label: str,
media_type: Optional[str] = None,
**extra: Any,
) -> Dict[str, Any]:
bundle_path = Path(bundle_dir).resolve()
file_path = Path(path).resolve()
rel_path = file_path.relative_to(bundle_path).as_posix()
record: Dict[str, Any] = {
"artifact_id": artifact_id,
"role": role,
"kind": kind,
"label": label,
"media_type": media_type or mimetypes.guess_type(str(file_path))[0] or "application/octet-stream",
"path": rel_path,
}
if file_path.exists():
record["bytes"] = file_path.stat().st_size
record.update({k: v for k, v in extra.items() if v is not None})
return record
def write_json(path: str, data: Any) -> str:
output_path = Path(path).resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False, default=str)
fh.write("\n")
return str(output_path)
def finalize_bundle(
bundle_dir: str,
bundle_id: str,
bundle_kind: str,
software: str,
recipe: str,
source: Dict[str, Any],
artifacts: list[Dict[str, Any]],
summary: Dict[str, Any],
cache_key: str,
generator: Dict[str, Any],
status: str = "ok",
warnings: Optional[list[str]] = None,
context: Optional[Dict[str, Any]] = None,
metrics: Optional[Dict[str, Any]] = None,
labels: Optional[list[str]] = None,
source_bundles: Optional[list[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
bundle_path = Path(bundle_dir).resolve()
summary_rel = "summary.json"
summary_path = write_json(str(bundle_path / summary_rel), summary)
manifest = {
"protocol_version": PROTOCOL_VERSION,
"bundle_id": bundle_id,
"bundle_kind": bundle_kind,
"software": software,
"recipe": recipe,
"status": status,
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"cache_key": cache_key,
"generator": generator,
"source": source,
"summary_path": summary_rel,
"artifacts": artifacts,
}
if warnings:
manifest["warnings"] = warnings
if context:
manifest["context"] = context
if metrics:
manifest["metrics"] = metrics
if labels:
manifest["labels"] = labels
if source_bundles:
manifest["source_bundles"] = source_bundles
manifest_path = write_json(str(bundle_path / "manifest.json"), manifest)
manifest["_manifest_path"] = manifest_path
manifest["_bundle_dir"] = str(bundle_path)
manifest["_summary_path"] = summary_path
return manifest
def _clean_none_fields(data: Dict[str, Any]) -> Dict[str, Any]:
return {key: value for key, value in data.items() if value is not None}
def live_trajectory_path(session_dir: str | Path) -> Path:
return Path(session_dir).expanduser().resolve() / "trajectory.json"
def load_live_trajectory(session_dir: str | Path) -> Dict[str, Any]:
trajectory_path = live_trajectory_path(session_dir)
if not trajectory_path.is_file():
return {}
return _load_json(trajectory_path)
def summarize_trajectory(trajectory: Dict[str, Any], *, recent_steps: int = 3) -> Dict[str, Any]:
steps = list(trajectory.get("steps") or [])
latest = steps[-1] if steps else {}
recent = steps[-max(1, int(recent_steps)):] if steps else []
return _clean_none_fields(
{
"protocol_version": trajectory.get("protocol_version"),
"software": trajectory.get("software"),
"recipe": trajectory.get("recipe"),
"step_count": trajectory.get("step_count", len(steps)),
"current_step_id": trajectory.get("current_step_id"),
"latest_command": latest.get("command"),
"latest_publish_reason": latest.get("publish_reason"),
"latest_bundle_id": latest.get("bundle_id"),
"recent_steps": [
_clean_none_fields(
{
"step_id": item.get("step_id"),
"step_index": item.get("step_index"),
"bundle_id": item.get("bundle_id"),
"publish_reason": item.get("publish_reason"),
"command": item.get("command"),
"command_finished_at": item.get("command_finished_at"),
"status": item.get("status"),
"cached": item.get("cached"),
}
)
for item in recent
],
}
)
def build_live_history_item(
bundle_manifest: Dict[str, Any],
*,
step_id: Optional[str] = None,
step_index: Optional[int] = None,
publish_reason: Optional[str] = None,
command: Optional[str] = None,
command_started_at: Optional[str] = None,
command_finished_at: Optional[str] = None,
source_fingerprint: Optional[str] = None,
stage_label: Optional[str] = None,
note: Optional[str] = None,
) -> Dict[str, Any]:
source = bundle_manifest.get("source") or {}
resolved_command = command or (bundle_manifest.get("generator") or {}).get("command")
resolved_fingerprint = (
source_fingerprint
or source.get("project_fingerprint")
or source.get("capture_fingerprint")
)
created_at = bundle_manifest.get("created_at")
return _clean_none_fields(
{
"step_id": step_id,
"step_index": step_index,
"bundle_id": bundle_manifest.get("bundle_id"),
"bundle_dir": bundle_manifest.get("_bundle_dir"),
"manifest_path": bundle_manifest.get("_manifest_path"),
"summary_path": bundle_manifest.get("_summary_path"),
"created_at": created_at,
"status": bundle_manifest.get("status"),
"cached": bool(bundle_manifest.get("cached")),
"publish_reason": publish_reason,
"command": resolved_command,
"command_started_at": command_started_at or created_at,
"command_finished_at": command_finished_at or created_at,
"source_fingerprint": resolved_fingerprint,
"stage_label": stage_label,
"note": note,
}
)
def append_live_trajectory(
session_dir: str | Path,
*,
software: str,
recipe: str,
bundle_manifest: Dict[str, Any],
publish_reason: str,
project_path: Optional[str] = None,
project_name: Optional[str] = None,
session_name: Optional[str] = None,
command: Optional[str] = None,
command_started_at: Optional[str] = None,
command_finished_at: Optional[str] = None,
source_fingerprint: Optional[str] = None,
stage_label: Optional[str] = None,
note: Optional[str] = None,
) -> Dict[str, Any]:
session_path = Path(session_dir).expanduser().resolve()
existing = load_live_trajectory(session_path)
steps = list(existing.get("steps") or [])
finished_at = (
command_finished_at
or bundle_manifest.get("created_at")
or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
)
started_at = command_started_at or finished_at
step_index = len(steps) + 1
step_id = f"step-{step_index:04d}"
step = build_live_history_item(
bundle_manifest,
step_id=step_id,
step_index=step_index,
publish_reason=publish_reason,
command=command,
command_started_at=started_at,
command_finished_at=finished_at,
source_fingerprint=source_fingerprint,
stage_label=stage_label,
note=note,
)
steps.append(step)
trajectory: Dict[str, Any] = dict(existing)
trajectory.update(
_clean_none_fields(
{
"protocol_version": TRAJECTORY_PROTOCOL_VERSION,
"software": software,
"recipe": recipe,
"session_name": session_name or session_path.name,
"project_path": project_path,
"project_name": project_name,
"created_at": existing.get("created_at", finished_at),
"updated_at": finished_at,
"step_count": len(steps),
"current_step_id": step_id,
}
)
)
trajectory["steps"] = steps
trajectory_path = write_json(str(live_trajectory_path(session_path)), trajectory)
trajectory["_trajectory_path"] = trajectory_path
trajectory["latest_step"] = step
return trajectory
@@ -0,0 +1,567 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
from pathlib import Path
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
def _display_home_path(path: str) -> str:
"""Display a path relative to the home directory when possible."""
expanded = Path(path).expanduser().resolve()
home = Path.home().resolve()
try:
relative = expanded.relative_to(home)
return f"~/{relative.as_posix()}"
except ValueError:
return str(expanded)
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the repo-root skills/ tree when present,
otherwise from the package's skills/ directory.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
software_aliases = {"iterm2_ctl": "iterm2"}
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
self.skill_id = f"cli-anything-{self.skill_slug}"
self.skill_install_cmd = (
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
)
global_skill_root = Path(
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
).expanduser()
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
# inside the CLI-Anything monorepo. Fall back to the packaged
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
if skill_path is None:
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
repo_skill = None
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / self.skill_id / "SKILL.md"
if candidate.is_file():
repo_skill = candidate
break
if repo_skill and repo_skill.is_file():
skill_path = str(repo_skill)
elif package_skill.is_file():
skill_path = str(package_skill)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
import textwrap
inner = 72
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
def _meta_lines(label: str, value: str) -> list[str]:
"""Wrap a metadata line for the banner box."""
icon = self._c(_MAGENTA, "")
label_text = self._c(_DARK_GRAY, label)
prefix = f" {icon} {label_text} "
available = max(12, inner - _visible_len(prefix))
wrapped = textwrap.wrap(
value,
width=available,
break_long_words=True,
break_on_hyphens=False,
) or [""]
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
continuation_prefix = " " * _visible_len(prefix)
for chunk in wrapped[1:]:
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
return lines
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
meta_lines: list[str] = []
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
print(top)
print(_box_line(title))
print(_box_line(ver))
for line in meta_lines:
print(_box_line(line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
from setuptools import setup, find_namespace_packages
with open("cli_anything/openscreen/README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="cli-anything-openscreen",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description=(
"CLI harness for Openscreen — screen recording editor. "
"Record, zoom, speed-ramp, annotate, crop, and export "
"screen captures via CLI with full undo/redo."
),
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Multimedia :: Video",
"Topic :: Multimedia :: Video :: Non-Linear Editor",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": ["pytest>=7.0.0", "pytest-cov>=4.0.0"],
},
entry_points={
"console_scripts": [
"cli-anything-openscreen=cli_anything.openscreen.openscreen_cli:cli",
],
},
package_data={
"cli_anything.openscreen": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)