chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user