Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4d6caacf | |||
| 7c75610fce | |||
| 0e22fcfbb3 | |||
| e734dbfcac | |||
| 757d966803 | |||
| 6949124fa7 | |||
| b6bc5a0463 | |||
| b21ca6b394 | |||
| 3dcd7961c6 | |||
| fdd275ac0e |
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
[S1]
|
||||
What if the universe isn’t continuous?
|
||||
What if it only looks smooth… because we’ve never zoomed in the right way?
|
||||
|
||||
[S2]
|
||||
At Glitch University, our first public course asks a strange question:
|
||||
Is the universe fundamentally pixelated?
|
||||
Blocky?
|
||||
Like Minecraft - just with absurdly tiny blocks?
|
||||
|
||||
[S3]
|
||||
This question has been around forever.
|
||||
But it's always been filed under "too weird to bother."
|
||||
That's about to change.
|
||||
|
||||
[S4]
|
||||
Explore the tech-tree.
|
||||
Level up.
|
||||
Run experiments on real data from space.
|
||||
We're committed to scientific rigour.
|
||||
Falsifiability. Truth-seeking.
|
||||
And not being a complete bore.
|
||||
|
||||
|
||||
[S5]
|
||||
Don’t enroll now.
|
||||
Enroll later.
|
||||
|
||||
[S6]
|
||||
Glitch University
|
||||
Later is now.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"S1": {
|
||||
"image": "GlitchTrailer.001.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S2": {
|
||||
"image": "GlitchTrailer.002.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S3": {
|
||||
"image": "GlitchTrailer.003.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S4": {
|
||||
"image": "GlitchTrailer.004.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S5": {
|
||||
"image": "GlitchTrailer.005.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S6": {
|
||||
"image": "GlitchTrailer.006.png",
|
||||
"type": "fullscreen"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"id": "GlitchTrailer",
|
||||
"coursecode": "TRAILER",
|
||||
"name": "Glitch University trailer",
|
||||
"description": "Welcome to Glitch University.",
|
||||
"hook": null,
|
||||
"platform_targets": ["youtube"],
|
||||
"status": "scripted",
|
||||
"youtube_url": null,
|
||||
"resolution": [1960, 1080],
|
||||
"fps": 30,
|
||||
"duration_seconds": null,
|
||||
"default_filters": {
|
||||
"audioonly": [
|
||||
{
|
||||
"type": "audio_normalize",
|
||||
"compress": false,
|
||||
"normalize": true,
|
||||
"target_lufs": -14,
|
||||
"target_lra": 11,
|
||||
"target_tp": -1.5
|
||||
}
|
||||
],
|
||||
"talkinghead": [
|
||||
{
|
||||
"type": "audio_normalize",
|
||||
"normalize": true,
|
||||
"target_lufs": -14,
|
||||
"target_lra": 11,
|
||||
"target_tp": -1.5
|
||||
},
|
||||
{
|
||||
"type": "color_grade",
|
||||
"saturation": 1.15,
|
||||
"contrast": 1.05,
|
||||
"bm": -0.1,
|
||||
"rm": 0.04
|
||||
},
|
||||
{
|
||||
"type": "gnommokey",
|
||||
"screen_color": [
|
||||
81,
|
||||
137,
|
||||
65
|
||||
],
|
||||
"screen_gain": 175,
|
||||
"screen_balance": 58,
|
||||
"despill_bias": [
|
||||
217,
|
||||
240,
|
||||
255
|
||||
],
|
||||
"despill_strength": 5.0,
|
||||
"edge_erode": 1.0,
|
||||
"clip_black": 0,
|
||||
"clip_white": 100
|
||||
},
|
||||
{
|
||||
"type": "mask",
|
||||
"left": 0.05,
|
||||
"right": 0.1,
|
||||
"top": 0.1,
|
||||
"bottom": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"cutouts": {
|
||||
"talkinghead": {
|
||||
"x": "-23%",
|
||||
"y": "10%",
|
||||
"height": "90%"
|
||||
},
|
||||
"square": {
|
||||
"x": "46.5%",
|
||||
"y": "4.5%",
|
||||
"width": "50%",
|
||||
"height": "90%"
|
||||
},
|
||||
"fullscreen": {
|
||||
"x": "0%",
|
||||
"y": "0%",
|
||||
"height": "100%"
|
||||
}
|
||||
},
|
||||
"manuscript": "manuscript.txt",
|
||||
"shorts": [],
|
||||
"output_video": "TRAILER.mp4"
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
# Gnommo
|
||||
|
||||
Gnommo is ADHD friendly video-editor for coders.
|
||||
|
||||
1. Design the presentation in keynote
|
||||
2. Set up the greenscreen and audio settings once
|
||||
3. Automatically times slides and videos to your voice.
|
||||
4. Limited options means you waste less time on stuff that isn't important.
|
||||
|
||||
A code-first video editing pipeline for creating narrated presentations with slides, video overlays, and synchronized audio.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Create a project
|
||||
gnommo -p myproject init
|
||||
|
||||
# Import slides and presenter notes from Keynote file
|
||||
gnommo -p myproject import
|
||||
|
||||
# Process the narration videos with video and audio filters
|
||||
gnommo -p myproject pre
|
||||
|
||||
# Stitch together the narration segments to one full length narration.
|
||||
gnommo -p myproject stitch
|
||||
|
||||
# Transcribe the actual narrated content
|
||||
gnommo -p myproject transcribe
|
||||
|
||||
# Generate the final video
|
||||
gnommo -p myproject render
|
||||
|
||||
# Generate the final youtube assets. Manuscript file, description
|
||||
gnommo -p myproject youtubeready
|
||||
|
||||
# Free up disk space locally by saving your project to an external drive
|
||||
gnommo -p myproject archive
|
||||
```
|
||||
|
||||
## Resolution modes
|
||||
|
||||
All commands accept `--res` to trade quality for speed during iteration:
|
||||
|
||||
| Flag | Resolution | Use case |
|
||||
|---|---|---|
|
||||
| `--res full` | Project resolution (default) | Final output |
|
||||
| `--res low` | 490×270 | Fast preview render |
|
||||
| `--res tiny` | 320×180 | Ultrafast iteration (preprocess, stitch, render) |
|
||||
|
||||
`--res tiny` and `--res low` create downscaled copies of source files in subdirectories (`proxy/` and `low/` respectively) and work from those. The originals are never modified.
|
||||
|
||||
```bash
|
||||
gnommo -p myproject pre --res tiny # fast preprocess
|
||||
gnommo -p myproject stitch --res tiny # fast stitch
|
||||
gnommo -p myproject render --res tiny # fast preview render
|
||||
gnommo -p myproject render --res low # medium preview render
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
myproject/
|
||||
├── project.json # Project configuration
|
||||
├── manuscript.txt # Narration script with [markers]
|
||||
├── media/
|
||||
│ ├── slides/
|
||||
│ │ ├── slides.json # Slide definitions
|
||||
│ │ └── *.png # Slide images
|
||||
│ ├── videos/
|
||||
│ │ ├── videos.json # Video source definitions
|
||||
│ │ └── *.mov # Video files
|
||||
│ ├── narration/
|
||||
│ │ ├── narration.json # Narration segment definitions
|
||||
│ │ └── *.mov # Raw narration recordings
|
||||
│ └── audio/
|
||||
│ ├── audio.json # Audio effect definitions
|
||||
│ └── *.mp3 # Sound effects
|
||||
└── output/
|
||||
└── final.mp4 # Rendered output
|
||||
└── preview.mp4 # Preview (lower resolution, faster render)
|
||||
```
|
||||
|
||||
## The Five Stages
|
||||
|
||||
Gnommo uses a five-stage pipeline for processing video projects:
|
||||
|
||||
### Stage 1: Init
|
||||
|
||||
Creates a folder and a default project.json file inside it.
|
||||
```bash
|
||||
gnommo -p myproject init
|
||||
```
|
||||
|
||||
### Stage 2: Import
|
||||
First : Place the myproject.key Keynote presentation in the myproject folder.
|
||||
Place videos, audio and narration you want to use in their respective folders in side myproject/media
|
||||
Then : This command media scans directories and generates JSON definition files.
|
||||
|
||||
```bash
|
||||
gnommo -p myproject import
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Opens the keynote presentation and exports all slides a PNG images into media/slides/
|
||||
- Scans `media/slides/` for images → generates `slides.json`
|
||||
- Scans `media/videos/` for video files → generates `videos.json`
|
||||
- Scans `media/narration/` for recordings → generates `narration.json`
|
||||
- Scans `media/audio/` for sound effects → generates `audio.json`
|
||||
|
||||
**When to use:** After adding new media files to populate the JSON definitions with the actual files in the folders
|
||||
|
||||
---
|
||||
|
||||
### Stage 3: Preprocess
|
||||
|
||||
Applies video filters (chroma key, scaling, etc.) to narration segments.
|
||||
|
||||
```bash
|
||||
gnommo -p myproject pre
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Reads filter definitions from `project.json` and `narration.json`
|
||||
- Processes each narration segment with its configured filters
|
||||
- Outputs processed files (e.g., `segment1_processed.mov`)
|
||||
|
||||
**When to use:** After recording narration that needs background removal, sound normalization or other processing.
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: stitch
|
||||
First : Go through the source videos, and add trim settings to `begin` and `end` parameters in `narration.json`
|
||||
|
||||
Then : Run command to sticth the usable parts of narration segments into a single continuous video
|
||||
|
||||
```bash
|
||||
gnommo -p myproject stitch
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Reads segments from `narration.json`
|
||||
- Concatenates them in order, respecting `begin`/`end` trim points
|
||||
- Outputs `narration_combined.mov` in `media/videos/`
|
||||
- Adds `narration_combined` entry to `videos.json` with volume settings
|
||||
- Generates word-level timestamps from the narration using Whisper speech recognition.
|
||||
|
||||
**When to use:** After preprocessing, or adjusting trim settings, to create the main narration scaffolding.
|
||||
|
||||
### Stage 5: Render
|
||||
|
||||
Composites all elements into the final video.
|
||||
|
||||
```bash
|
||||
gnommo -p myproject render
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Parses `manuscript.txt` for slide/video markers
|
||||
- Aligns markers to transcription timestamps
|
||||
- Composites background, narration, slides, and video overlays
|
||||
- Outputs `final.mp4`
|
||||
|
||||
**Options:**
|
||||
```bash
|
||||
gnommo -p myproject render --dry-run # Show FFmpeg command without running
|
||||
gnommo -p myproject render --slides S1:S10 # Render only slides S1 through S10
|
||||
gnommo -p myproject render --res low # Fast preview at 490x270
|
||||
gnommo -p myproject render --res tiny # Ultrafast preview at 320x180
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shortcut: All Stages
|
||||
|
||||
Run all stages 2-5 and render in one command:
|
||||
|
||||
```bash
|
||||
gnommo -p myproject all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manuscript Format
|
||||
|
||||
The manuscript is plain text with embedded markers:
|
||||
|
||||
```
|
||||
[S1] Welcome to this presentation.
|
||||
|
||||
[S2] Let me show you how this works.
|
||||
|
||||
[video:demo] Here's a quick demonstration.
|
||||
|
||||
[Zoom1] Notice this important detail.
|
||||
|
||||
[Reset] And that concludes our overview.
|
||||
```
|
||||
|
||||
**Marker types:**
|
||||
- `[S1]`, `[S2]` - Slide markers (reference slides.json)
|
||||
- `[video:id]` - Triggered video overlay
|
||||
- `[narration:id]` - Start continuous narration video
|
||||
- `[Zoom1]`, `[Reset]` - Camera presets
|
||||
- `[Awoosh]` - Audio effect trigger
|
||||
|
||||
---
|
||||
|
||||
## External Storage (GnommoCache)
|
||||
|
||||
For large projects, gnommo supports transparent external storage fallback.
|
||||
|
||||
**Setup:** Create `~/.gnommo.conf`:
|
||||
```ini
|
||||
[cache]
|
||||
path = /Volumes/ExternalDrive/gnommo
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Files are first looked up locally in the project directory
|
||||
- If not found, gnommo checks `{cache_path}/{project_name}/...`
|
||||
- The 📁 indicator shows files loaded from external storage
|
||||
|
||||
**Archive to external storage:**
|
||||
```bash
|
||||
gnommo -p myproject archive # Sync project to cache
|
||||
gnommo -p myproject archive --dry-run # Preview what would sync
|
||||
```
|
||||
|
||||
This allows you to move large preprocessed files to external storage while keeping the project functional.
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### New Project Setup
|
||||
```bash
|
||||
# 1. Create project structure and add media files
|
||||
mkdir -p myproject/media/{slides,videos,narration,audio}
|
||||
|
||||
# 2. Create project.json with basic config
|
||||
|
||||
# 3. Import media to generate JSON definitions
|
||||
gnommo -p myproject import
|
||||
|
||||
# 4. Edit JSON files to configure filters, trim points, etc.
|
||||
|
||||
# 5. Run full pipeline
|
||||
gnommo -p myproject all
|
||||
```
|
||||
|
||||
### Re-render After Editing Manuscript
|
||||
```bash
|
||||
gnommo -p myproject render
|
||||
```
|
||||
|
||||
### Re-process After Recording New Narration
|
||||
```bash
|
||||
gnommo -p myproject pre
|
||||
gnommo -p myproject stitch
|
||||
gnommo -p myproject transcribe
|
||||
gnommo -p myproject render
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Commands
|
||||
|
||||
```bash
|
||||
gnommo -p myproject validate # Check for errors without rendering
|
||||
gnommo -p myproject description # Generate YouTube description with chapters
|
||||
gnommo -p myproject transcribe --final # Transcribe final.mp4 for subtitles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Glitch University — Server Sync
|
||||
|
||||
Gnommo can push project metadata and short scripts to a gnommoweb server,
|
||||
and pull changes back. This keeps the platform database in sync with your
|
||||
local project files without manual copy-paste.
|
||||
|
||||
**Setup** — add to `gnommo/.env`:
|
||||
```ini
|
||||
GNOMMOWEB_URL=http://localhost:3001
|
||||
GNOMMOWEB_API_KEY=your_content_api_key
|
||||
```
|
||||
|
||||
### Push
|
||||
|
||||
Registers the project on the server and syncs all defined shorts (including
|
||||
their scripts). Creates a filming task for each new short.
|
||||
|
||||
```bash
|
||||
gnommo -p myproject push # push local → server
|
||||
gnommo -p myproject push --force # overwrite server even if it has newer changes
|
||||
```
|
||||
|
||||
On the first push, gnommo creates:
|
||||
- A stub video record in the platform database
|
||||
- One short record per entry in `project.json["shorts"]`
|
||||
- One task per new short ("Film short: …")
|
||||
|
||||
Re-running push is safe — existing records are updated, no duplicate tasks.
|
||||
Scripts are only overwritten on the server if the local file has changed;
|
||||
edits made in the staff UI are preserved.
|
||||
|
||||
### Pull
|
||||
|
||||
Fetches the current project state from the server and merges the `shorts`
|
||||
array back into `project.json`. Useful after editing short titles or hooks
|
||||
in the web interface.
|
||||
|
||||
```bash
|
||||
gnommo -p myproject pull # pull server → local
|
||||
gnommo -p myproject pull --force # overwrite local even if it has unsaved changes
|
||||
```
|
||||
|
||||
Pull preserves local `script` file paths — it won't overwrite your `.md`
|
||||
script files.
|
||||
|
||||
### Conflict guards
|
||||
|
||||
Both commands check for conflicts before writing:
|
||||
|
||||
| Situation | Push behaviour | Pull behaviour |
|
||||
|---|---|---|
|
||||
| Server has changes you haven't pulled | Blocked — pull first | Proceeds (that's the point) |
|
||||
| Local has changes you haven't pushed | Proceeds (that's the point) | Blocked — push first |
|
||||
| `--force` flag | Overrides | Overrides |
|
||||
|
||||
Sync state is stored in `<project>/.gnommo_sync.json` (tracked by git,
|
||||
so collaborators share the same reference point).
|
||||
|
||||
### Defining shorts in `project.json`
|
||||
|
||||
Add a `shorts` array to your project:
|
||||
|
||||
```json
|
||||
"shorts": [
|
||||
{
|
||||
"id": "short_pixelated_universe",
|
||||
"title": "Is the universe pixelated?",
|
||||
"hook": "What if space is made of tiny blocks?",
|
||||
"script": "shorts/short_pixelated_universe.md",
|
||||
"platform_targets": ["youtube"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `id` — unique slug within the project, used as the upsert key
|
||||
- `script` — relative path to a markdown file with the full short narration
|
||||
- `hook` — opening line / thumbnail caption
|
||||
- `platform_targets` — list of platforms (currently `["youtube"]`)
|
||||
|
||||
Scripts are plain markdown with the same `[SLIDE: name]` markers and
|
||||
`{word}` whisper timestamp tags used elsewhere in gnommo.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- FFmpeg
|
||||
- OpenAI Whisper (for transcription)
|
||||
|
||||
```bash
|
||||
pip install openai-whisper
|
||||
```
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"drives": {
|
||||
"lacie": {
|
||||
"mount_path": "/Volumes/LaCie Jens",
|
||||
"backups": {
|
||||
"small": {
|
||||
"last_attempt": "2026-03-26T09:48:05Z",
|
||||
"last_status": "success",
|
||||
"last_completed": "2026-03-26T09:48:13Z"
|
||||
},
|
||||
"big": {
|
||||
"last_attempt": "2026-02-27T12:17:30Z",
|
||||
"last_status": "failed"
|
||||
},
|
||||
"all": {
|
||||
"last_attempt": "2026-03-26T10:32:56Z",
|
||||
"last_status": "success",
|
||||
"last_completed": "2026-03-26T10:36:24Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
#!/bin/zsh
|
||||
#
|
||||
# Gnommo Backup Utility
|
||||
# Syncs project files to an external drive using rsync
|
||||
#
|
||||
# Usage: ./backup.sh <mode> <drive> [options]
|
||||
#
|
||||
# Modes:
|
||||
# small - Keynotes, images, metadata, code (excludes large media)
|
||||
# big - Large video/audio files only (for offloading)
|
||||
# all - Complete mirror of entire project
|
||||
#
|
||||
# Drives:
|
||||
# lacie - /Volumes/LaCie Jens/gnommo
|
||||
# gnommodisk - /Volumes/gnommodisk/gnommo
|
||||
# status - Show backup status for all drives
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Show what would be transferred without copying
|
||||
# --delete Delete files on destination that don't exist in source
|
||||
# --progress Show detailed transfer progress (default: on)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="/Users/jenstandstad/Projects/gnommo"
|
||||
BACKUP_JSON="$PROJECT_DIR/backup.json"
|
||||
BIG_FILE_SIZE="100M"
|
||||
|
||||
# Known drives (name -> mount path)
|
||||
typeset -A KNOWN_DRIVES
|
||||
KNOWN_DRIVES=(
|
||||
lacie "/Volumes/LaCie Jens"
|
||||
gnommodisk "/Volumes/gnommodisk"
|
||||
)
|
||||
|
||||
# Big file extensions (video/audio that tend to be large)
|
||||
BIG_EXTENSIONS=("mov" "mp4" "m4v" "avi" "mkv" "aifc" "aiff" "wav")
|
||||
|
||||
# Initialize backup.json if it doesn't exist
|
||||
init_backup_json() {
|
||||
if [[ ! -f "$BACKUP_JSON" ]]; then
|
||||
cat > "$BACKUP_JSON" << 'EOF'
|
||||
{
|
||||
"drives": {}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Update backup.json using Python (reliable JSON handling)
|
||||
update_backup_json() {
|
||||
local drive_name="$1"
|
||||
local backup_mode="$2"
|
||||
local backup_status="$3" # "started" or "completed"
|
||||
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
python3 << PYTHON
|
||||
import json
|
||||
import os
|
||||
|
||||
backup_file = "$BACKUP_JSON"
|
||||
drive_name = "$drive_name"
|
||||
mode = "$backup_mode"
|
||||
status = "$backup_status"
|
||||
timestamp = "$timestamp"
|
||||
|
||||
# Load existing data
|
||||
if os.path.exists(backup_file):
|
||||
with open(backup_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {"drives": {}}
|
||||
|
||||
# Ensure drive entry exists
|
||||
if drive_name not in data["drives"]:
|
||||
data["drives"][drive_name] = {
|
||||
"mount_path": "${KNOWN_DRIVES[$drive_name]:-$DESTINATION}",
|
||||
"backups": {}
|
||||
}
|
||||
|
||||
# Ensure mode entry exists
|
||||
if mode not in data["drives"][drive_name]["backups"]:
|
||||
data["drives"][drive_name]["backups"][mode] = {}
|
||||
|
||||
# Update based on status
|
||||
backup_entry = data["drives"][drive_name]["backups"][mode]
|
||||
if status == "started":
|
||||
backup_entry["last_attempt"] = timestamp
|
||||
backup_entry["last_status"] = "in_progress"
|
||||
elif status == "completed":
|
||||
backup_entry["last_completed"] = timestamp
|
||||
backup_entry["last_status"] = "success"
|
||||
elif status == "failed":
|
||||
backup_entry["last_status"] = "failed"
|
||||
|
||||
# Write back
|
||||
with open(backup_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
PYTHON
|
||||
}
|
||||
|
||||
# Show backup status
|
||||
show_status() {
|
||||
echo "========================================"
|
||||
echo "Gnommo Backup Status"
|
||||
echo "========================================"
|
||||
|
||||
if [[ ! -f "$BACKUP_JSON" ]]; then
|
||||
echo "No backups recorded yet."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 << 'PYTHON'
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
backup_file = os.environ.get('BACKUP_JSON', 'backup.json')
|
||||
known_drives = {"lacie": "/Volumes/LaCie Jens", "gnommodisk": "/Volumes/gnommodisk"}
|
||||
|
||||
with open(backup_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
for drive_name, drive_info in data.get("drives", {}).items():
|
||||
mount_path = drive_info.get("mount_path", "unknown")
|
||||
mounted = "CONNECTED" if os.path.exists(mount_path) else "not connected"
|
||||
|
||||
print(f"\n{drive_name} ({mounted})")
|
||||
print(f" Path: {mount_path}")
|
||||
|
||||
backups = drive_info.get("backups", {})
|
||||
if not backups:
|
||||
print(" No backups recorded")
|
||||
continue
|
||||
|
||||
for mode, info in backups.items():
|
||||
status = info.get("last_status", "unknown")
|
||||
completed = info.get("last_completed", "never")
|
||||
attempt = info.get("last_attempt", "never")
|
||||
|
||||
# Format the completed time nicely
|
||||
if completed != "never":
|
||||
try:
|
||||
dt = datetime.fromisoformat(completed.replace('Z', '+00:00'))
|
||||
completed = dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
except:
|
||||
pass
|
||||
|
||||
status_icon = "✓" if status == "success" else "⋯" if status == "in_progress" else "✗"
|
||||
print(f" {mode}: {status_icon} {completed}")
|
||||
|
||||
print()
|
||||
PYTHON
|
||||
|
||||
echo "========================================"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
Gnommo Backup Utility
|
||||
|
||||
Usage: $(basename "$0") <mode> <drive> [options]
|
||||
|
||||
Modes:
|
||||
small Sync small files only: Keynotes, images, JSON, code, manuscripts
|
||||
Excludes: .mov, .mp4, .aifc, and other large media files
|
||||
|
||||
big Sync large files only: video and audio media files
|
||||
Useful for offloading to free up local space
|
||||
|
||||
all Full mirror of the entire gnommo project
|
||||
|
||||
status Show backup status for all known drives
|
||||
|
||||
Drives:
|
||||
lacie /Volumes/LaCie Jens/gnommo
|
||||
gnommodisk /Volumes/gnommodisk/gnommo
|
||||
<path> Or specify a custom path
|
||||
|
||||
Options:
|
||||
--dry-run Preview what would be transferred (no actual copying)
|
||||
--delete Remove files on destination that no longer exist in source
|
||||
--no-progress Disable progress display
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") status
|
||||
$(basename "$0") small lacie
|
||||
$(basename "$0") big gnommodisk --delete
|
||||
$(basename "$0") all lacie --dry-run
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
MODE=""
|
||||
DRIVE=""
|
||||
DESTINATION=""
|
||||
DRY_RUN=""
|
||||
DELETE=""
|
||||
PROGRESS="--progress"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
small|big|all)
|
||||
MODE="$1"
|
||||
shift
|
||||
;;
|
||||
status)
|
||||
export BACKUP_JSON
|
||||
show_status
|
||||
exit 0
|
||||
;;
|
||||
lacie|gnommodisk)
|
||||
DRIVE="$1"
|
||||
DESTINATION="${KNOWN_DRIVES[$1]}/gnommo"
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN="--dry-run"
|
||||
shift
|
||||
;;
|
||||
--delete)
|
||||
DELETE="--delete"
|
||||
shift
|
||||
;;
|
||||
--no-progress)
|
||||
PROGRESS=""
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$DESTINATION" ]]; then
|
||||
DRIVE="custom"
|
||||
DESTINATION="$1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Handle status command
|
||||
if [[ "$MODE" == "status" ]]; then
|
||||
show_status
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate arguments
|
||||
if [[ -z "$MODE" ]]; then
|
||||
echo "Error: Mode is required (small, big, all, or status)"
|
||||
echo ""
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ -z "$DESTINATION" ]]; then
|
||||
echo "Error: Drive or destination path is required"
|
||||
echo ""
|
||||
usage
|
||||
fi
|
||||
|
||||
# Check if drive is mounted (get the volume path, handling spaces)
|
||||
MOUNT_PATH="${DESTINATION%/gnommo}"
|
||||
if [[ ! -d "$MOUNT_PATH" ]]; then
|
||||
echo "Error: Drive not mounted at: $MOUNT_PATH"
|
||||
echo ""
|
||||
echo "Available volumes:"
|
||||
ls /Volumes/ 2>/dev/null | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create destination directory if needed
|
||||
mkdir -p "$DESTINATION"
|
||||
|
||||
# Initialize backup tracking
|
||||
init_backup_json
|
||||
|
||||
# Build rsync command
|
||||
RSYNC_OPTS="-avh"
|
||||
[[ -n "$PROGRESS" ]] && RSYNC_OPTS="$RSYNC_OPTS --progress"
|
||||
[[ -n "$DRY_RUN" ]] && RSYNC_OPTS="$RSYNC_OPTS --dry-run"
|
||||
[[ -n "$DELETE" ]] && RSYNC_OPTS="$RSYNC_OPTS --delete"
|
||||
|
||||
# Always exclude these
|
||||
EXCLUDE_ALWAYS=(
|
||||
".DS_Store"
|
||||
"__pycache__"
|
||||
"*.pyc"
|
||||
".git"
|
||||
".env"
|
||||
"*.egg-info"
|
||||
".venv"
|
||||
"venv"
|
||||
"node_modules"
|
||||
)
|
||||
|
||||
# Build exclusion patterns for big files
|
||||
build_big_excludes() {
|
||||
local excludes=""
|
||||
for ext in "${BIG_EXTENSIONS[@]}"; do
|
||||
excludes="$excludes --exclude='*.$ext'"
|
||||
done
|
||||
echo "$excludes"
|
||||
}
|
||||
|
||||
# Build inclusion patterns for big files only
|
||||
build_big_includes() {
|
||||
local includes=""
|
||||
for ext in "${BIG_EXTENSIONS[@]}"; do
|
||||
includes="$includes --include='*.$ext'"
|
||||
done
|
||||
echo "$includes"
|
||||
}
|
||||
|
||||
# Build common exclusions
|
||||
build_common_excludes() {
|
||||
local excludes=""
|
||||
for pattern in "${EXCLUDE_ALWAYS[@]}"; do
|
||||
excludes="$excludes --exclude='$pattern'"
|
||||
done
|
||||
echo "$excludes"
|
||||
}
|
||||
|
||||
echo "========================================"
|
||||
echo "Gnommo Backup Utility"
|
||||
echo "========================================"
|
||||
echo "Mode: $MODE"
|
||||
echo "Drive: $DRIVE"
|
||||
echo "Source: $PROJECT_DIR"
|
||||
echo "Destination: $DESTINATION"
|
||||
[[ -n "$DRY_RUN" ]] && echo "DRY RUN: Yes (no files will be copied)"
|
||||
[[ -n "$DELETE" ]] && echo "Delete: Yes (will remove orphaned files)"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Record backup attempt (skip for dry-run)
|
||||
if [[ -z "$DRY_RUN" ]]; then
|
||||
update_backup_json "$DRIVE" "$MODE" "started"
|
||||
fi
|
||||
|
||||
# Track success
|
||||
BACKUP_SUCCESS=false
|
||||
|
||||
run_backup() {
|
||||
case "$MODE" in
|
||||
small)
|
||||
echo "Syncing SMALL files (excluding large media)..."
|
||||
echo "Excludes: ${BIG_EXTENSIONS[*]}"
|
||||
echo ""
|
||||
|
||||
EXCLUDES=$(build_common_excludes)
|
||||
BIG_EXCLUDES=$(build_big_excludes)
|
||||
|
||||
eval rsync $RSYNC_OPTS $EXCLUDES $BIG_EXCLUDES "'$PROJECT_DIR/'" "'$DESTINATION/'"
|
||||
;;
|
||||
|
||||
big)
|
||||
echo "Syncing BIG files only (large media)..."
|
||||
echo "Includes: ${BIG_EXTENSIONS[*]}"
|
||||
echo ""
|
||||
|
||||
EXCLUDES=$(build_common_excludes)
|
||||
INCLUDES="--include='*/' $(build_big_includes)"
|
||||
|
||||
eval rsync $RSYNC_OPTS $EXCLUDES $INCLUDES --exclude="'*'" "'$PROJECT_DIR/'" "'$DESTINATION/'"
|
||||
;;
|
||||
|
||||
all)
|
||||
echo "Syncing ALL files (complete mirror)..."
|
||||
echo ""
|
||||
|
||||
EXCLUDES=$(build_common_excludes)
|
||||
|
||||
eval rsync $RSYNC_OPTS $EXCLUDES "'$PROJECT_DIR/'" "'$DESTINATION/'"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Run backup and track result
|
||||
# Exit codes: 0=success, 23=partial transfer (files changed during sync, usually OK), 24=vanished files
|
||||
run_backup && BACKUP_SUCCESS=true || {
|
||||
local exit_code=$?
|
||||
if [[ $exit_code -eq 23 || $exit_code -eq 24 ]]; then
|
||||
echo "Note: Some files changed during transfer (rsync exit $exit_code) - backup completed"
|
||||
BACKUP_SUCCESS=true
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
if [[ -n "$DRY_RUN" ]]; then
|
||||
echo "DRY RUN complete. No files were copied."
|
||||
else
|
||||
if [[ "$BACKUP_SUCCESS" == true ]]; then
|
||||
update_backup_json "$DRIVE" "$MODE" "completed"
|
||||
echo "Backup complete!"
|
||||
else
|
||||
update_backup_json "$DRIVE" "$MODE" "failed"
|
||||
echo "Backup FAILED!"
|
||||
fi
|
||||
fi
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,317 @@
|
||||
# Partial Rendering Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Enable rendering of specific sections of a video (e.g., slides 1-10, then 10-20) instead of the full video. This is useful for:
|
||||
- Faster iteration during development
|
||||
- Re-rendering specific sections after fixes
|
||||
- Parallel rendering of segments that can be concatenated later
|
||||
|
||||
## Scope (v1)
|
||||
|
||||
**In scope:**
|
||||
- Camera state tracking (cumulative state must be computed from t=0)
|
||||
- Time offset adjustment for all events
|
||||
- Slide range filtering
|
||||
- Input video seeking
|
||||
|
||||
**Out of scope (v1):**
|
||||
- Audio events crossing range boundaries
|
||||
- Triggered video duration edge cases
|
||||
- Events are assumed to begin at their marker timestamp and never "carry over"
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### 1. Camera State Management
|
||||
|
||||
**Current behavior** (`transformer.py:250-332`):
|
||||
- Camera state is **cumulative** across the transcript
|
||||
- `_extract_camera_events()` walks through ALL markers sequentially
|
||||
- Each marker type (Zoom/Tilt/Pan) only modifies its property while preserving others
|
||||
- Example: `[Zoom2]` then `[TiltLeft]` = both zoom AND tilt active
|
||||
|
||||
**Problem for partial rendering**:
|
||||
If we start rendering at slide 10, we need the camera state AS IT WOULD BE after processing slides 1-9.
|
||||
|
||||
**Solution**:
|
||||
Separate "state computation" from "event generation":
|
||||
1. Always walk through ALL transcript markers to compute cumulative state
|
||||
2. Track the "initial state" at the start of the render range
|
||||
3. Only emit CameraEvents for markers WITHIN the render range
|
||||
4. First event in partial render must transition FROM the computed initial state
|
||||
|
||||
### 2. Time Signature Adjustment
|
||||
|
||||
**Current behavior**:
|
||||
All timing uses absolute timestamps from `transcript.csv`:
|
||||
- `SlideEvent.start_time/end_time`
|
||||
- `VideoEvent.start_time/end_time`
|
||||
- `AudioEvent.start_time`
|
||||
- `CameraEvent.time`
|
||||
- FFmpeg expressions: `enable=between(t, start, end)`
|
||||
- Camera animation: `if(between(t, 1.000, 1.200), ...)`
|
||||
|
||||
**Problem for partial rendering**:
|
||||
If slide 10 starts at t=10.0s and we render from there, FFmpeg expects t=0 at the start of output.
|
||||
|
||||
**Solution**:
|
||||
Apply a `time_offset` to all events after extraction:
|
||||
```
|
||||
new_time = original_time - time_offset
|
||||
```
|
||||
Where `time_offset` = start time of first slide/event in range.
|
||||
|
||||
### 3. Input Video Seeking
|
||||
|
||||
**Current behavior**:
|
||||
- Always-visible videos (talking head) start from the beginning
|
||||
- FFmpeg processes entire input duration
|
||||
|
||||
**Problem for partial rendering**:
|
||||
Need to seek into source videos to the correct position.
|
||||
|
||||
**Solution**:
|
||||
Add `-ss <seek_time>` before input files for always-visible videos:
|
||||
```
|
||||
ffmpeg -ss 10.0 -i talking_head.mov ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proposed API
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
```bash
|
||||
# Render full video (current behavior)
|
||||
gnommo render example/project.json output.mp4
|
||||
|
||||
# Render specific slide range
|
||||
gnommo render example/project.json output.mp4 --slides S1:S10
|
||||
gnommo render example/project.json output.mp4 --slides S10:S20
|
||||
gnommo render example/project.json output.mp4 --slides S5: # S5 to end
|
||||
|
||||
# Render specific time range (alternative)
|
||||
gnommo render example/project.json output.mp4 --time 0:60
|
||||
gnommo render example/project.json output.mp4 --time 60:120
|
||||
```
|
||||
|
||||
### Internal API
|
||||
|
||||
New parameters for `build_render_plan()`:
|
||||
```python
|
||||
def build_render_plan(
|
||||
...
|
||||
slide_range: Optional[tuple[str, Optional[str]]] = None, # (start_slide, end_slide)
|
||||
# OR
|
||||
time_range: Optional[tuple[float, Optional[float]]] = None, # (start_time, end_time)
|
||||
) -> RenderPlan:
|
||||
```
|
||||
|
||||
New field on `RenderPlan`:
|
||||
```python
|
||||
@dataclass
|
||||
class RenderPlan:
|
||||
...
|
||||
time_offset: float = 0.0 # Offset to subtract from all timestamps
|
||||
initial_camera_state: CameraState = field(default_factory=CameraState) # State at render start
|
||||
input_seek_time: float = 0.0 # Seek position for input videos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Phase 1: Compute Full State, Filter Events
|
||||
|
||||
Modify `_extract_camera_events()` to accept a time range:
|
||||
|
||||
```python
|
||||
def _extract_camera_events(
|
||||
transcript: list[TimedWord],
|
||||
time_range: Optional[tuple[float, float]] = None, # (start, end)
|
||||
) -> tuple[list[CameraEvent], CameraState]:
|
||||
"""
|
||||
Returns:
|
||||
- List of CameraEvents within time_range
|
||||
- Initial CameraState at start of time_range
|
||||
"""
|
||||
events: list[CameraEvent] = []
|
||||
current_state = CameraState()
|
||||
initial_state = CameraState()
|
||||
start_time, end_time = time_range or (0.0, float('inf'))
|
||||
|
||||
found_start = False
|
||||
|
||||
for timed_word in transcript:
|
||||
if not timed_word.is_marker:
|
||||
continue
|
||||
|
||||
marker_id = timed_word.marker_id
|
||||
if not marker_id or marker_id not in CAMERA_PRESETS:
|
||||
continue
|
||||
|
||||
# Always update current_state (full walk)
|
||||
preset = CAMERA_PRESETS[marker_id]
|
||||
new_state = _apply_preset(current_state, marker_id, preset)
|
||||
|
||||
# Capture state just before we enter the render range
|
||||
if not found_start and timed_word.time >= start_time:
|
||||
initial_state = current_state # State BEFORE this marker
|
||||
found_start = True
|
||||
|
||||
# Only emit events within range
|
||||
if start_time <= timed_word.time < end_time:
|
||||
events.append(CameraEvent(
|
||||
time=timed_word.time,
|
||||
target_state=new_state,
|
||||
duration=0.2,
|
||||
easing="ease-out",
|
||||
))
|
||||
|
||||
current_state = new_state
|
||||
|
||||
return events, initial_state
|
||||
```
|
||||
|
||||
### Phase 2: Apply Time Offset
|
||||
|
||||
After extracting events, apply offset to all timestamps:
|
||||
|
||||
```python
|
||||
def _apply_time_offset(plan: RenderPlan, offset: float) -> RenderPlan:
|
||||
"""Shift all timestamps by offset (subtract offset from all times)."""
|
||||
|
||||
# Adjust slide events
|
||||
for event in plan.slide_events:
|
||||
event.start_time -= offset
|
||||
event.end_time -= offset
|
||||
|
||||
# Adjust video events
|
||||
for event in plan.video_events:
|
||||
event.start_time -= offset
|
||||
event.end_time -= offset
|
||||
|
||||
# Adjust audio events
|
||||
for event in plan.audio_events:
|
||||
event.start_time = max(0, event.start_time - offset)
|
||||
|
||||
# Adjust camera events
|
||||
for event in plan.camera_events:
|
||||
event.time -= offset
|
||||
|
||||
# Adjust total duration
|
||||
plan.total_duration -= offset
|
||||
plan.time_offset = offset
|
||||
plan.input_seek_time = offset
|
||||
|
||||
return plan
|
||||
```
|
||||
|
||||
### Phase 3: FFmpeg Seeking
|
||||
|
||||
Modify `build_ffmpeg_command()` to add seeking:
|
||||
|
||||
```python
|
||||
def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
|
||||
# Add seek for always-visible videos
|
||||
for video_id, video_source, cutout in plan.narration_videos:
|
||||
video_path = _resolve_video_path(videos_dir, video_source)
|
||||
if plan.input_seek_time > 0:
|
||||
cmd.extend(["-ss", str(plan.input_seek_time)]) # Seek BEFORE -i
|
||||
cmd.extend(["-i", str(video_path)])
|
||||
...
|
||||
```
|
||||
|
||||
### Phase 4: Initial Camera State Handling
|
||||
|
||||
If `initial_camera_state` is not default, inject a "virtual" camera event at t=0:
|
||||
|
||||
```python
|
||||
def build_camera_transform(
|
||||
camera_events: list[CameraEvent],
|
||||
initial_state: CameraState, # NEW PARAMETER
|
||||
...
|
||||
) -> str:
|
||||
# If initial state differs from default, prepend a virtual event
|
||||
if not initial_state.is_default():
|
||||
initial_event = CameraEvent(
|
||||
time=0.0,
|
||||
target_state=initial_state,
|
||||
duration=0.0, # Instant - no transition
|
||||
easing="linear",
|
||||
)
|
||||
camera_events = [initial_event] + camera_events
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FFmpeg Optimization
|
||||
|
||||
**Only emit filters for events within range.**
|
||||
|
||||
When rendering a partial range, the `RenderPlan` should only contain events within that range. This means:
|
||||
- Fewer inputs added to the FFmpeg command (only slides/videos/audio actually used)
|
||||
- Fewer overlay filters in filter_complex
|
||||
- Fewer `between(t, start, end)` enable expressions to evaluate per frame
|
||||
|
||||
Example: Full video has 50 slides, rendering S40:S50 only:
|
||||
- **Before**: 50 slide inputs, 50 overlay filters
|
||||
- **After**: 10 slide inputs, 10 overlay filters
|
||||
|
||||
This is achieved naturally by filtering events in `build_render_plan()` before constructing the plan - the renderer already only processes events present in the plan.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases (v1 Simplified)
|
||||
|
||||
### 1. Camera state from before range
|
||||
If rendering S5:S10 but there's a camera event at the S4 marker:
|
||||
- Camera state from S4 must be captured as `initial_camera_state`
|
||||
- Rendered output starts with that state already applied at t=0
|
||||
|
||||
### 2. Events filter by marker position
|
||||
All events (slides, videos, audio) are filtered by whether their START marker falls within the range.
|
||||
- Events beginning outside range are excluded
|
||||
- No "carry over" or boundary-crossing logic needed
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
1. Camera state computation maintains state across full transcript
|
||||
2. Time offset correctly shifts all event types
|
||||
3. Initial camera state correctly captured at boundary
|
||||
|
||||
### Integration Tests
|
||||
1. Render slides 1-5, then 5-10, concatenate, compare to full render
|
||||
2. Camera state continuity across segment boundaries
|
||||
3. Audio alignment after seeking
|
||||
|
||||
### Manual Verification
|
||||
1. Visual inspection of camera state at segment boundaries
|
||||
2. Audio sync verification
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Parallel Rendering Pipeline
|
||||
```bash
|
||||
# Render in parallel, then stitch
|
||||
gnommo render proj.json seg1.mp4 --slides S1:S10 &
|
||||
gnommo render proj.json seg2.mp4 --slides S10:S20 &
|
||||
gnommo render proj.json seg3.mp4 --slides S20: &
|
||||
wait
|
||||
ffmpeg -f concat -i segments.txt -c copy final.mp4
|
||||
```
|
||||
|
||||
### Smart Re-rendering
|
||||
Track which slides changed and only re-render affected segments.
|
||||
|
||||
### Preview Mode
|
||||
Quick low-quality render of specific section for review.
|
||||
@@ -0,0 +1,265 @@
|
||||
# Virtual Camera Effects
|
||||
|
||||
Ideas for "stuff happening" to keep viewers engaged in edutainment videos.
|
||||
These effects are triggered by markers in the manuscript, just like slides.
|
||||
|
||||
## Zoom Effects
|
||||
|
||||
| Marker | Description |
|
||||
|--------|-------------|
|
||||
| `[Zoom1]` | Zoom to 110% - subtle emphasis |
|
||||
| `[Zoom2]` | Zoom to 125% - moderate emphasis |
|
||||
| `[Zoom3]` | Zoom to 150% - strong emphasis |
|
||||
| `[Zoom0]` | Return to 100% (default) |
|
||||
| `[ZoomPunch]` | Quick zoom in + out (single beat emphasis) |
|
||||
|
||||
**Use case:** Rapid `[Zoom1][Zoom2][Zoom3]` for comedic/dramatic triple emphasis.
|
||||
|
||||
## Tilt/Rotation Effects
|
||||
|
||||
| Marker | Description |
|
||||
|--------|-------------|
|
||||
| `[TiltLeft]` | Rotate -15 degrees |
|
||||
| `[TiltRight]` | Rotate +15 degrees |
|
||||
| `[NoTilt]` | Return to 0 degrees |
|
||||
| `[TiltShake]` | Quick left-right shake (confusion/emphasis) |
|
||||
|
||||
**Use case:** Tilt when saying something "off" or wrong, return to flat for correction.
|
||||
|
||||
## Pan/Position Effects
|
||||
|
||||
| Marker | Description |
|
||||
|--------|-------------|
|
||||
| `[PanLeft]` | Shift frame left (subject moves right) |
|
||||
| `[PanRight]` | Shift frame right (subject moves left) |
|
||||
| `[PanUp]` | Shift frame up |
|
||||
| `[PanDown]` | Shift frame down |
|
||||
| `[PanCenter]` | Return to center |
|
||||
|
||||
**Use case:** Pan to make room for a slide appearing on one side.
|
||||
|
||||
## Shake/Movement Effects
|
||||
|
||||
| Marker | Description |
|
||||
|--------|-------------|
|
||||
| `[Shake]` | Brief screen shake (impact, surprise) |
|
||||
| `[ShakeHard]` | Intense shake (explosion, error) |
|
||||
| `[Wobble]` | Gentle continuous wobble |
|
||||
| `[NoWobble]` | Stop wobble |
|
||||
|
||||
**Use case:** Shake on "WRONG!" or when something crashes/fails.
|
||||
|
||||
## Speed/Rhythm Effects
|
||||
|
||||
| Marker | Description |
|
||||
|--------|-------------|
|
||||
| `[Beat]` | Single visual pulse (scale bump) |
|
||||
| `[BeatStart]` | Start pulsing to rhythm |
|
||||
| `[BeatStop]` | Stop pulsing |
|
||||
|
||||
**Use case:** Rhythmic emphasis during lists or key points.
|
||||
|
||||
## Transition Effects
|
||||
|
||||
| Marker | Description |
|
||||
|--------|-------------|
|
||||
| `[Flash]` | Quick white flash |
|
||||
| `[Blackout]` | Brief black frame |
|
||||
| `[Glitch]` | Digital glitch effect |
|
||||
|
||||
**Use case:** Transition between topics or for "record scratch" moments.
|
||||
|
||||
## Picture-in-Picture Variations
|
||||
|
||||
| Marker | Description |
|
||||
|--------|-------------|
|
||||
| `[PipGrow]` | Enlarge talking head cutout |
|
||||
| `[PipShrink]` | Shrink talking head cutout |
|
||||
| `[PipHide]` | Temporarily hide talking head |
|
||||
| `[PipShow]` | Restore talking head |
|
||||
| `[PipMove:corner]` | Move pip to different corner |
|
||||
|
||||
**Use case:** Shrink self when showing important diagram, grow when making personal point.
|
||||
|
||||
## Combination Presets
|
||||
|
||||
| Marker | Description |
|
||||
|--------|-------------|
|
||||
| `[Emphasis]` | Zoom2 + slight tilt (general emphasis) |
|
||||
| `[Surprise]` | Quick zoom + shake |
|
||||
| `[Sarcasm]` | Slow zoom + tilt |
|
||||
| `[Reset]` | Return all effects to default |
|
||||
|
||||
---
|
||||
|
||||
## Architecture: The Camera Abstraction
|
||||
|
||||
### The Core Insight
|
||||
|
||||
All visual elements (slides, cutouts, talking head, background) exist in a **scene**.
|
||||
The **camera** views the scene. When the camera zooms, tilts, or pans - everything
|
||||
moves together, just like a real camera filming a physical set.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ SCENE │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Background Layer │ │
|
||||
│ │ ┌─────────────┐ │ │
|
||||
│ │ │ Talking Head│ ┌──────────────────┐ │ │
|
||||
│ │ │ (cutout) │ │ Slide │ │ │
|
||||
│ │ └─────────────┘ │ (from .png) │ │ │
|
||||
│ │ └──────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ CAMERA │
|
||||
│ zoom: 1.25 │
|
||||
│ tilt: -15° │
|
||||
│ pan: 0, 0 │
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Final Output │
|
||||
│ (1920x1080) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
**Keynote slides are designed for a specific frame.** If you create a slide with
|
||||
an arrow pointing at where the talking head cutout will be, that spatial
|
||||
relationship must be preserved when the camera zooms or tilts.
|
||||
|
||||
If we zoomed only the background and not the slides, the arrow would point to
|
||||
the wrong place. The camera abstraction ensures everything transforms together.
|
||||
|
||||
### Camera Properties
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CameraState:
|
||||
zoom: float = 1.0 # 1.0 = 100%, 1.25 = 125%
|
||||
rotation: float = 0.0 # degrees, positive = clockwise
|
||||
pan_x: float = 0.0 # -1.0 to 1.0, percentage of frame
|
||||
pan_y: float = 0.0 # -1.0 to 1.0, percentage of frame
|
||||
|
||||
@dataclass
|
||||
class CameraKeyframe:
|
||||
time: float # timestamp in seconds
|
||||
state: CameraState
|
||||
easing: str = "linear" # linear, ease-in, ease-out, ease-in-out
|
||||
```
|
||||
|
||||
### Rendering Pipeline (Updated)
|
||||
|
||||
```
|
||||
Current Pipeline:
|
||||
Parse → Validate → Transform → Render
|
||||
│
|
||||
▼
|
||||
build_filter_complex()
|
||||
│
|
||||
[bg] → overlays → [vout]
|
||||
|
||||
New Pipeline:
|
||||
Parse → Validate → Transform → Render
|
||||
│
|
||||
Extract camera
|
||||
keyframes from
|
||||
markers
|
||||
│
|
||||
▼
|
||||
build_filter_complex()
|
||||
│
|
||||
[bg] → overlays → [scene]
|
||||
│
|
||||
apply_camera_transform()
|
||||
│
|
||||
[scene] → zoom/rotate/pan → [vout]
|
||||
```
|
||||
|
||||
### FFmpeg Implementation
|
||||
|
||||
The camera transform is a **final filter stage** applied to the composed scene:
|
||||
|
||||
```
|
||||
# Compose scene (existing code)
|
||||
[0:v]scale=1920:1080[bg];
|
||||
[bg][slide1]overlay=...[s1];
|
||||
[s1][talkinghead]overlay=...[scene];
|
||||
|
||||
# Camera transform (new)
|
||||
[scene]scale=iw*{zoom}:ih*{zoom},
|
||||
rotate={rotation}*PI/180:fillcolor=black,
|
||||
crop=1920:1080:(iw-1920)/2:(ih-1080)/2[vout]
|
||||
```
|
||||
|
||||
For smooth animated zoom (using expressions):
|
||||
```
|
||||
[scene]zoompan=z='if(between(t,5,8), 1+0.25*(t-5)/3, 1)':
|
||||
x='iw/2-(iw/zoom/2)':
|
||||
y='ih/2-(ih/zoom/2)':
|
||||
d=1:s=1920x1080:fps=30[vout]
|
||||
```
|
||||
|
||||
### Camera Events in Timeline
|
||||
|
||||
New model for camera changes:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CameraEvent:
|
||||
time: float
|
||||
target_state: CameraState
|
||||
duration: float = 0.0 # 0 = instant snap
|
||||
easing: str = "ease-out"
|
||||
```
|
||||
|
||||
Markers map to camera events:
|
||||
- `[Zoom2]` → `CameraEvent(time=t, target_state=CameraState(zoom=1.25), duration=0.2)`
|
||||
- `[TiltLeft]` → `CameraEvent(time=t, target_state=CameraState(rotation=-15), duration=0.3)`
|
||||
- `[Reset]` → `CameraEvent(time=t, target_state=CameraState(), duration=0.2)`
|
||||
|
||||
### Considerations
|
||||
|
||||
1. **Overscan**: When zoomed in, we're cropping. The scene must be rendered
|
||||
larger than output (e.g., 2x) to have room for zoom without quality loss.
|
||||
|
||||
2. **Rotation center**: Rotate around frame center, not corner.
|
||||
|
||||
3. **State accumulation**: `[Zoom2]` then `[TiltLeft]` means zoom AND tilt
|
||||
are both active. `[Reset]` clears all.
|
||||
|
||||
4. **Interaction with cutouts**: Cutout positions are in scene-space, so they
|
||||
transform naturally with the camera. No special handling needed.
|
||||
|
||||
5. **Slides stay synced**: Keynote exports are positioned for the base frame.
|
||||
Camera zoom/tilt transforms them identically to everything else.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Camera Data Model ✓
|
||||
- [x] Add `CameraState` and `CameraEvent` to models.py
|
||||
- [x] Add camera effect markers to transformer.py
|
||||
- [x] Generate camera keyframes from markers
|
||||
|
||||
### Phase 2: Render Pipeline ✓
|
||||
- [x] Modify renderer to compose to `[scene]` instead of `[vout]`
|
||||
- [x] Add camera transform stage after composition
|
||||
- [ ] Handle overscan (render larger, crop to output) - deferred, upsampling OK for now
|
||||
|
||||
### Phase 3: Smooth Animation (partial)
|
||||
- [x] Support animated transitions between keyframes (linear interpolation)
|
||||
- [ ] Implement easing functions as FFmpeg expressions (ease-in, ease-out)
|
||||
- [ ] Test with rapid zoom sequences
|
||||
|
||||
### Phase 4: Effect Presets ✓
|
||||
- [x] Define presets (Zoom0/1/2/3, TiltLeft/Right/NoTilt, Pan*, Reset)
|
||||
- [x] Presets defined in `CAMERA_PRESETS` dict in models.py
|
||||
- [ ] Support custom parameterized markers `[Zoom:1.35]` - future enhancement
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"reference": "Gnommo Documentation - https://github.com/example/gnommo",
|
||||
"context": ""
|
||||
},
|
||||
{
|
||||
"reference": "FFmpeg Documentation - https://ffmpeg.org/documentation.html",
|
||||
"context": ""
|
||||
}
|
||||
]
|
||||
+37
-3
@@ -1,5 +1,39 @@
|
||||
Welcome to GnommoEditor, a code-first video editing system. [S1]
|
||||
[S1]
|
||||
This is the first slide. It appears immediately.
|
||||
|
||||
In this example, we demonstrate how slides appear at specific timestamps based on markers in the transcript. [S2]
|
||||
[S2]
|
||||
However, this is the second slide. It should appear 1 second prior to when I say “however”
|
||||
|
||||
And that's the end of our demo.
|
||||
[S3]
|
||||
[video:KnightRotating]
|
||||
This is me talking alongside a video. The video is constrained within the red square. Notice how the video stops immediately when we make the transition to the next slide.
|
||||
|
||||
[S4]
|
||||
I will continue to talk without pause, but in the finished recording - there will be a pause before the narration continues. Now a video will play that pauses the narration
|
||||
|
||||
[S5]
|
||||
[video:gnommologo]
|
||||
|
||||
Notice how my voice continues after the video finished
|
||||
|
||||
[S6]
|
||||
|
||||
[S7]
|
||||
This is the first slide. It appears immediately.
|
||||
|
||||
[S8]
|
||||
However, this is the second slide. It should appear 1 second prior to when I say “however”
|
||||
|
||||
[S9]
|
||||
[video:KnightRotating]
|
||||
This is me talking alongside a video. The video is constrained within the red square. Notice how the video stops immediately when we make the transition to the next slide.
|
||||
|
||||
[S10]
|
||||
I will continue to talk without pause, but in the finished recording - there will be a pause before the narration continues. Now a video will play that pauses the narration
|
||||
|
||||
[S11]
|
||||
[video:gnommologo]
|
||||
|
||||
Notice how my voice continues after the video finished
|
||||
|
||||
[S12]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"talking_head_S1": {
|
||||
"source_file": "talking_head_S1.mov",
|
||||
"output_file": "talking_head_S1_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": "talkinghead"
|
||||
},
|
||||
"talking_head_S3": {
|
||||
"source_file": "talking_head_S3.mov",
|
||||
"output_file": "talking_head_S3_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": "talkinghead"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"S1": {
|
||||
"image": "example.001.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S2": {
|
||||
"image": "example.002.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S3": {
|
||||
"image": "example.003.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S4": {
|
||||
"image": "example.004.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S5": {
|
||||
"image": "example.005.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S6": {
|
||||
"image": "example.006.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S7": {
|
||||
"image": "example.007.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S8": {
|
||||
"image": "example.008.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S9": {
|
||||
"image": "example.009.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S10": {
|
||||
"image": "example.010.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S11": {
|
||||
"image": "example.011.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S12": {
|
||||
"image": "example.012.png",
|
||||
"type": "fullscreen"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
file '/Users/jenstandstad/Projects/gnommo/example/media/videos/intermediate/talking_head_batch0.mov'
|
||||
file '/Users/jenstandstad/Projects/gnommo/example/media/videos/intermediate/segments/segment_0002.mov'
|
||||
@@ -0,0 +1,992 @@
|
||||
[
|
||||
{
|
||||
"word": "This",
|
||||
"start": 10.739999999999997,
|
||||
"end": 11.44
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 11.44,
|
||||
"end": 11.64
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 11.64,
|
||||
"end": 11.82
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 11.82,
|
||||
"end": 12.04
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 12.04,
|
||||
"end": 12.44
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 12.92,
|
||||
"end": 13.34
|
||||
},
|
||||
{
|
||||
"word": "appears",
|
||||
"start": 13.34,
|
||||
"end": 13.7
|
||||
},
|
||||
{
|
||||
"word": "immediate.",
|
||||
"start": 13.7,
|
||||
"end": 14.18
|
||||
},
|
||||
{
|
||||
"word": "However,",
|
||||
"start": 15.36,
|
||||
"end": 16.06
|
||||
},
|
||||
{
|
||||
"word": "this",
|
||||
"start": 16.38,
|
||||
"end": 16.48
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 16.48,
|
||||
"end": 16.62
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 16.62,
|
||||
"end": 16.8
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 16.8,
|
||||
"end": 17.08
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 17.08,
|
||||
"end": 17.42
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 17.78,
|
||||
"end": 18.02
|
||||
},
|
||||
{
|
||||
"word": "should",
|
||||
"start": 18.02,
|
||||
"end": 18.24
|
||||
},
|
||||
{
|
||||
"word": "appear",
|
||||
"start": 18.24,
|
||||
"end": 18.56
|
||||
},
|
||||
{
|
||||
"word": "one",
|
||||
"start": 18.56,
|
||||
"end": 19.02
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 19.02,
|
||||
"end": 19.5
|
||||
},
|
||||
{
|
||||
"word": "prior",
|
||||
"start": 19.5,
|
||||
"end": 19.92
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 19.92,
|
||||
"end": 20.16
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 20.16,
|
||||
"end": 20.26
|
||||
},
|
||||
{
|
||||
"word": "word",
|
||||
"start": 20.26,
|
||||
"end": 20.54
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 20.54,
|
||||
"end": 21.24
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 21.24,
|
||||
"end": 21.32
|
||||
},
|
||||
{
|
||||
"word": "say",
|
||||
"start": 21.32,
|
||||
"end": 21.5
|
||||
},
|
||||
{
|
||||
"word": "whoever",
|
||||
"start": 21.5,
|
||||
"end": 21.86
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 21.86,
|
||||
"end": 22.44
|
||||
},
|
||||
{
|
||||
"word": "time.",
|
||||
"start": 22.44,
|
||||
"end": 22.7
|
||||
},
|
||||
{
|
||||
"word": "This",
|
||||
"start": 24.3,
|
||||
"end": 25.0
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 25.0,
|
||||
"end": 25.14
|
||||
},
|
||||
{
|
||||
"word": "me",
|
||||
"start": 25.14,
|
||||
"end": 25.38
|
||||
},
|
||||
{
|
||||
"word": "taking,",
|
||||
"start": 25.38,
|
||||
"end": 25.78
|
||||
},
|
||||
{
|
||||
"word": "talking",
|
||||
"start": 26.14,
|
||||
"end": 27.18
|
||||
},
|
||||
{
|
||||
"word": "alongside",
|
||||
"start": 27.18,
|
||||
"end": 27.66
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 27.66,
|
||||
"end": 27.92
|
||||
},
|
||||
{
|
||||
"word": "video.",
|
||||
"start": 27.92,
|
||||
"end": 28.16
|
||||
},
|
||||
{
|
||||
"word": "The",
|
||||
"start": 28.68,
|
||||
"end": 28.96
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 28.96,
|
||||
"end": 29.2
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 29.2,
|
||||
"end": 29.4
|
||||
},
|
||||
{
|
||||
"word": "constrained",
|
||||
"start": 29.4,
|
||||
"end": 29.82
|
||||
},
|
||||
{
|
||||
"word": "within",
|
||||
"start": 29.82,
|
||||
"end": 30.18
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 30.18,
|
||||
"end": 30.36
|
||||
},
|
||||
{
|
||||
"word": "red",
|
||||
"start": 30.36,
|
||||
"end": 30.52
|
||||
},
|
||||
{
|
||||
"word": "square.",
|
||||
"start": 30.52,
|
||||
"end": 30.94
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 31.3,
|
||||
"end": 31.48
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 31.48,
|
||||
"end": 31.78
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 31.78,
|
||||
"end": 31.96
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 31.96,
|
||||
"end": 32.16
|
||||
},
|
||||
{
|
||||
"word": "stops",
|
||||
"start": 32.16,
|
||||
"end": 32.48
|
||||
},
|
||||
{
|
||||
"word": "immediately",
|
||||
"start": 32.48,
|
||||
"end": 32.98
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 32.98,
|
||||
"end": 33.4
|
||||
},
|
||||
{
|
||||
"word": "we",
|
||||
"start": 33.4,
|
||||
"end": 33.58
|
||||
},
|
||||
{
|
||||
"word": "make",
|
||||
"start": 33.58,
|
||||
"end": 33.76
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 33.76,
|
||||
"end": 34.0
|
||||
},
|
||||
{
|
||||
"word": "transition",
|
||||
"start": 34.0,
|
||||
"end": 34.42
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 34.42,
|
||||
"end": 34.72
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 34.72,
|
||||
"end": 34.84
|
||||
},
|
||||
{
|
||||
"word": "next",
|
||||
"start": 34.84,
|
||||
"end": 35.06
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 35.06,
|
||||
"end": 35.48
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 37.2,
|
||||
"end": 37.76
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 37.76,
|
||||
"end": 37.82
|
||||
},
|
||||
{
|
||||
"word": "continue",
|
||||
"start": 37.82,
|
||||
"end": 38.12
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 38.12,
|
||||
"end": 38.34
|
||||
},
|
||||
{
|
||||
"word": "talk",
|
||||
"start": 38.34,
|
||||
"end": 38.58
|
||||
},
|
||||
{
|
||||
"word": "without",
|
||||
"start": 38.58,
|
||||
"end": 38.92
|
||||
},
|
||||
{
|
||||
"word": "pause,",
|
||||
"start": 38.92,
|
||||
"end": 39.26
|
||||
},
|
||||
{
|
||||
"word": "but",
|
||||
"start": 39.5,
|
||||
"end": 39.6
|
||||
},
|
||||
{
|
||||
"word": "in",
|
||||
"start": 39.6,
|
||||
"end": 39.72
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 39.72,
|
||||
"end": 39.8
|
||||
},
|
||||
{
|
||||
"word": "finished",
|
||||
"start": 39.8,
|
||||
"end": 40.0
|
||||
},
|
||||
{
|
||||
"word": "recording",
|
||||
"start": 40.0,
|
||||
"end": 40.48
|
||||
},
|
||||
{
|
||||
"word": "there",
|
||||
"start": 40.48,
|
||||
"end": 41.22
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 41.22,
|
||||
"end": 41.38
|
||||
},
|
||||
{
|
||||
"word": "be",
|
||||
"start": 41.38,
|
||||
"end": 41.58
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 41.58,
|
||||
"end": 41.68
|
||||
},
|
||||
{
|
||||
"word": "pause",
|
||||
"start": 41.68,
|
||||
"end": 41.96
|
||||
},
|
||||
{
|
||||
"word": "before",
|
||||
"start": 41.96,
|
||||
"end": 42.32
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 42.32,
|
||||
"end": 42.52
|
||||
},
|
||||
{
|
||||
"word": "narration",
|
||||
"start": 42.52,
|
||||
"end": 43.06
|
||||
},
|
||||
{
|
||||
"word": "continues.",
|
||||
"start": 43.06,
|
||||
"end": 43.66
|
||||
},
|
||||
{
|
||||
"word": "Now",
|
||||
"start": 44.44,
|
||||
"end": 44.56
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 44.56,
|
||||
"end": 44.7
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 44.7,
|
||||
"end": 44.94
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 44.94,
|
||||
"end": 45.12
|
||||
},
|
||||
{
|
||||
"word": "play",
|
||||
"start": 45.12,
|
||||
"end": 45.4
|
||||
},
|
||||
{
|
||||
"word": "that",
|
||||
"start": 45.4,
|
||||
"end": 45.8
|
||||
},
|
||||
{
|
||||
"word": "pauses",
|
||||
"start": 45.8,
|
||||
"end": 46.52
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 46.52,
|
||||
"end": 46.8
|
||||
},
|
||||
{
|
||||
"word": "narration.",
|
||||
"start": 46.8,
|
||||
"end": 47.22
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 48.66,
|
||||
"end": 49.22
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 49.22,
|
||||
"end": 49.44
|
||||
},
|
||||
{
|
||||
"word": "my",
|
||||
"start": 49.44,
|
||||
"end": 49.6
|
||||
},
|
||||
{
|
||||
"word": "voice",
|
||||
"start": 49.6,
|
||||
"end": 49.84
|
||||
},
|
||||
{
|
||||
"word": "continues",
|
||||
"start": 49.84,
|
||||
"end": 50.38
|
||||
},
|
||||
{
|
||||
"word": "after",
|
||||
"start": 50.38,
|
||||
"end": 50.88
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 50.88,
|
||||
"end": 51.04
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 51.04,
|
||||
"end": 51.28
|
||||
},
|
||||
{
|
||||
"word": "finished.",
|
||||
"start": 51.28,
|
||||
"end": 51.8
|
||||
},
|
||||
{
|
||||
"word": "This",
|
||||
"start": 65.46000000000001,
|
||||
"end": 66.14
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 66.14,
|
||||
"end": 66.34
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 66.34,
|
||||
"end": 66.52
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 66.52,
|
||||
"end": 66.74
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 66.74,
|
||||
"end": 67.14
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 67.68,
|
||||
"end": 68.02
|
||||
},
|
||||
{
|
||||
"word": "appears",
|
||||
"start": 68.02,
|
||||
"end": 68.38
|
||||
},
|
||||
{
|
||||
"word": "immediate.",
|
||||
"start": 68.38,
|
||||
"end": 68.86
|
||||
},
|
||||
{
|
||||
"word": "However,",
|
||||
"start": 70.28,
|
||||
"end": 70.76
|
||||
},
|
||||
{
|
||||
"word": "this",
|
||||
"start": 71.1,
|
||||
"end": 71.18
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 71.18,
|
||||
"end": 71.32
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 71.32,
|
||||
"end": 71.48
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 71.48,
|
||||
"end": 71.78
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 71.78,
|
||||
"end": 72.12
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 72.4,
|
||||
"end": 72.7
|
||||
},
|
||||
{
|
||||
"word": "should",
|
||||
"start": 72.7,
|
||||
"end": 72.94
|
||||
},
|
||||
{
|
||||
"word": "appear",
|
||||
"start": 72.94,
|
||||
"end": 73.26
|
||||
},
|
||||
{
|
||||
"word": "one",
|
||||
"start": 73.26,
|
||||
"end": 73.72
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 73.72,
|
||||
"end": 74.2
|
||||
},
|
||||
{
|
||||
"word": "prior",
|
||||
"start": 74.2,
|
||||
"end": 74.62
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 74.62,
|
||||
"end": 74.86
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 74.86,
|
||||
"end": 74.98
|
||||
},
|
||||
{
|
||||
"word": "word",
|
||||
"start": 74.98,
|
||||
"end": 75.24
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 75.24,
|
||||
"end": 75.94
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 75.94,
|
||||
"end": 76.02
|
||||
},
|
||||
{
|
||||
"word": "say",
|
||||
"start": 76.02,
|
||||
"end": 76.18
|
||||
},
|
||||
{
|
||||
"word": "whoever",
|
||||
"start": 76.18,
|
||||
"end": 76.56
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 76.56,
|
||||
"end": 77.14
|
||||
},
|
||||
{
|
||||
"word": "time.",
|
||||
"start": 77.14,
|
||||
"end": 77.42
|
||||
},
|
||||
{
|
||||
"word": "This",
|
||||
"start": 79.36,
|
||||
"end": 79.7
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 79.7,
|
||||
"end": 79.86
|
||||
},
|
||||
{
|
||||
"word": "me",
|
||||
"start": 79.86,
|
||||
"end": 80.08
|
||||
},
|
||||
{
|
||||
"word": "taking,",
|
||||
"start": 80.08,
|
||||
"end": 80.48
|
||||
},
|
||||
{
|
||||
"word": "talking",
|
||||
"start": 80.92,
|
||||
"end": 81.88
|
||||
},
|
||||
{
|
||||
"word": "alongside",
|
||||
"start": 81.88,
|
||||
"end": 82.36
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 82.36,
|
||||
"end": 82.62
|
||||
},
|
||||
{
|
||||
"word": "video.",
|
||||
"start": 82.62,
|
||||
"end": 82.88
|
||||
},
|
||||
{
|
||||
"word": "The",
|
||||
"start": 83.48,
|
||||
"end": 83.66
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 83.66,
|
||||
"end": 83.92
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 83.92,
|
||||
"end": 84.1
|
||||
},
|
||||
{
|
||||
"word": "constrained",
|
||||
"start": 84.1,
|
||||
"end": 84.54
|
||||
},
|
||||
{
|
||||
"word": "within",
|
||||
"start": 84.54,
|
||||
"end": 84.88
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 84.88,
|
||||
"end": 85.06
|
||||
},
|
||||
{
|
||||
"word": "red",
|
||||
"start": 85.06,
|
||||
"end": 85.22
|
||||
},
|
||||
{
|
||||
"word": "square.",
|
||||
"start": 85.22,
|
||||
"end": 85.62
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 85.62,
|
||||
"end": 86.18
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 86.18,
|
||||
"end": 86.48
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 86.48,
|
||||
"end": 86.66
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 86.66,
|
||||
"end": 86.86
|
||||
},
|
||||
{
|
||||
"word": "stops",
|
||||
"start": 86.86,
|
||||
"end": 87.2
|
||||
},
|
||||
{
|
||||
"word": "immediately",
|
||||
"start": 87.2,
|
||||
"end": 87.68
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 87.68,
|
||||
"end": 88.1
|
||||
},
|
||||
{
|
||||
"word": "we",
|
||||
"start": 88.1,
|
||||
"end": 88.28
|
||||
},
|
||||
{
|
||||
"word": "make",
|
||||
"start": 88.28,
|
||||
"end": 88.46
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 88.46,
|
||||
"end": 88.7
|
||||
},
|
||||
{
|
||||
"word": "transition",
|
||||
"start": 88.7,
|
||||
"end": 89.12
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 89.12,
|
||||
"end": 89.42
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 89.42,
|
||||
"end": 89.54
|
||||
},
|
||||
{
|
||||
"word": "next",
|
||||
"start": 89.54,
|
||||
"end": 89.76
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 89.76,
|
||||
"end": 90.22
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 91.94,
|
||||
"end": 92.46
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 92.46,
|
||||
"end": 92.52
|
||||
},
|
||||
{
|
||||
"word": "continue",
|
||||
"start": 92.52,
|
||||
"end": 92.82
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 92.82,
|
||||
"end": 93.04
|
||||
},
|
||||
{
|
||||
"word": "talk",
|
||||
"start": 93.04,
|
||||
"end": 93.28
|
||||
},
|
||||
{
|
||||
"word": "without",
|
||||
"start": 93.28,
|
||||
"end": 93.62
|
||||
},
|
||||
{
|
||||
"word": "pause,",
|
||||
"start": 93.62,
|
||||
"end": 93.96
|
||||
},
|
||||
{
|
||||
"word": "but",
|
||||
"start": 94.2,
|
||||
"end": 94.3
|
||||
},
|
||||
{
|
||||
"word": "in",
|
||||
"start": 94.3,
|
||||
"end": 94.42
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 94.42,
|
||||
"end": 94.48
|
||||
},
|
||||
{
|
||||
"word": "finished",
|
||||
"start": 94.48,
|
||||
"end": 94.7
|
||||
},
|
||||
{
|
||||
"word": "recording",
|
||||
"start": 94.7,
|
||||
"end": 95.18
|
||||
},
|
||||
{
|
||||
"word": "there",
|
||||
"start": 95.18,
|
||||
"end": 95.92
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 95.92,
|
||||
"end": 96.08
|
||||
},
|
||||
{
|
||||
"word": "be",
|
||||
"start": 96.08,
|
||||
"end": 96.28
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 96.28,
|
||||
"end": 96.38
|
||||
},
|
||||
{
|
||||
"word": "pause",
|
||||
"start": 96.38,
|
||||
"end": 96.64
|
||||
},
|
||||
{
|
||||
"word": "before",
|
||||
"start": 96.64,
|
||||
"end": 97.02
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 97.02,
|
||||
"end": 97.22
|
||||
},
|
||||
{
|
||||
"word": "narration",
|
||||
"start": 97.22,
|
||||
"end": 97.76
|
||||
},
|
||||
{
|
||||
"word": "continues.",
|
||||
"start": 97.76,
|
||||
"end": 98.38
|
||||
},
|
||||
{
|
||||
"word": "Now",
|
||||
"start": 99.06,
|
||||
"end": 99.26
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 99.26,
|
||||
"end": 99.4
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 99.4,
|
||||
"end": 99.64
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 99.64,
|
||||
"end": 99.8
|
||||
},
|
||||
{
|
||||
"word": "play",
|
||||
"start": 99.8,
|
||||
"end": 100.1
|
||||
},
|
||||
{
|
||||
"word": "that",
|
||||
"start": 100.1,
|
||||
"end": 100.5
|
||||
},
|
||||
{
|
||||
"word": "pauses",
|
||||
"start": 100.5,
|
||||
"end": 101.24
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 101.24,
|
||||
"end": 101.5
|
||||
},
|
||||
{
|
||||
"word": "narration.",
|
||||
"start": 101.5,
|
||||
"end": 101.92
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 103.18,
|
||||
"end": 103.92
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 103.92,
|
||||
"end": 104.14
|
||||
},
|
||||
{
|
||||
"word": "my",
|
||||
"start": 104.14,
|
||||
"end": 104.32
|
||||
},
|
||||
{
|
||||
"word": "voice",
|
||||
"start": 104.32,
|
||||
"end": 104.58
|
||||
},
|
||||
{
|
||||
"word": "continues",
|
||||
"start": 104.58,
|
||||
"end": 105.1
|
||||
},
|
||||
{
|
||||
"word": "after",
|
||||
"start": 105.1,
|
||||
"end": 105.58
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 105.58,
|
||||
"end": 105.76
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 105.76,
|
||||
"end": 105.98
|
||||
},
|
||||
{
|
||||
"word": "finished.",
|
||||
"start": 105.98,
|
||||
"end": 106.48
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,992 @@
|
||||
[
|
||||
{
|
||||
"word": "This",
|
||||
"start": 10.739999999999997,
|
||||
"end": 11.44
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 11.44,
|
||||
"end": 11.64
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 11.64,
|
||||
"end": 11.82
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 11.82,
|
||||
"end": 12.04
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 12.04,
|
||||
"end": 12.44
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 12.92,
|
||||
"end": 13.34
|
||||
},
|
||||
{
|
||||
"word": "appears",
|
||||
"start": 13.34,
|
||||
"end": 13.7
|
||||
},
|
||||
{
|
||||
"word": "immediate.",
|
||||
"start": 13.7,
|
||||
"end": 14.18
|
||||
},
|
||||
{
|
||||
"word": "However,",
|
||||
"start": 15.36,
|
||||
"end": 16.06
|
||||
},
|
||||
{
|
||||
"word": "this",
|
||||
"start": 16.38,
|
||||
"end": 16.48
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 16.48,
|
||||
"end": 16.62
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 16.62,
|
||||
"end": 16.8
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 16.8,
|
||||
"end": 17.08
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 17.08,
|
||||
"end": 17.42
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 17.78,
|
||||
"end": 18.02
|
||||
},
|
||||
{
|
||||
"word": "should",
|
||||
"start": 18.02,
|
||||
"end": 18.24
|
||||
},
|
||||
{
|
||||
"word": "appear",
|
||||
"start": 18.24,
|
||||
"end": 18.56
|
||||
},
|
||||
{
|
||||
"word": "one",
|
||||
"start": 18.56,
|
||||
"end": 19.02
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 19.02,
|
||||
"end": 19.5
|
||||
},
|
||||
{
|
||||
"word": "prior",
|
||||
"start": 19.5,
|
||||
"end": 19.92
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 19.92,
|
||||
"end": 20.16
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 20.16,
|
||||
"end": 20.26
|
||||
},
|
||||
{
|
||||
"word": "word",
|
||||
"start": 20.26,
|
||||
"end": 20.54
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 20.54,
|
||||
"end": 21.24
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 21.24,
|
||||
"end": 21.32
|
||||
},
|
||||
{
|
||||
"word": "say",
|
||||
"start": 21.32,
|
||||
"end": 21.5
|
||||
},
|
||||
{
|
||||
"word": "whoever",
|
||||
"start": 21.5,
|
||||
"end": 21.86
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 21.86,
|
||||
"end": 22.44
|
||||
},
|
||||
{
|
||||
"word": "time.",
|
||||
"start": 22.44,
|
||||
"end": 22.7
|
||||
},
|
||||
{
|
||||
"word": "This",
|
||||
"start": 24.3,
|
||||
"end": 25.0
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 25.0,
|
||||
"end": 25.14
|
||||
},
|
||||
{
|
||||
"word": "me",
|
||||
"start": 25.14,
|
||||
"end": 25.38
|
||||
},
|
||||
{
|
||||
"word": "taking,",
|
||||
"start": 25.38,
|
||||
"end": 25.78
|
||||
},
|
||||
{
|
||||
"word": "talking",
|
||||
"start": 26.14,
|
||||
"end": 27.18
|
||||
},
|
||||
{
|
||||
"word": "alongside",
|
||||
"start": 27.18,
|
||||
"end": 27.66
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 27.66,
|
||||
"end": 27.92
|
||||
},
|
||||
{
|
||||
"word": "video.",
|
||||
"start": 27.92,
|
||||
"end": 28.16
|
||||
},
|
||||
{
|
||||
"word": "The",
|
||||
"start": 28.68,
|
||||
"end": 28.96
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 28.96,
|
||||
"end": 29.2
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 29.2,
|
||||
"end": 29.4
|
||||
},
|
||||
{
|
||||
"word": "constrained",
|
||||
"start": 29.4,
|
||||
"end": 29.82
|
||||
},
|
||||
{
|
||||
"word": "within",
|
||||
"start": 29.82,
|
||||
"end": 30.18
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 30.18,
|
||||
"end": 30.36
|
||||
},
|
||||
{
|
||||
"word": "red",
|
||||
"start": 30.36,
|
||||
"end": 30.52
|
||||
},
|
||||
{
|
||||
"word": "square.",
|
||||
"start": 30.52,
|
||||
"end": 30.94
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 31.3,
|
||||
"end": 31.48
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 31.48,
|
||||
"end": 31.78
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 31.78,
|
||||
"end": 31.96
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 31.96,
|
||||
"end": 32.16
|
||||
},
|
||||
{
|
||||
"word": "stops",
|
||||
"start": 32.16,
|
||||
"end": 32.48
|
||||
},
|
||||
{
|
||||
"word": "immediately",
|
||||
"start": 32.48,
|
||||
"end": 32.98
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 32.98,
|
||||
"end": 33.4
|
||||
},
|
||||
{
|
||||
"word": "we",
|
||||
"start": 33.4,
|
||||
"end": 33.58
|
||||
},
|
||||
{
|
||||
"word": "make",
|
||||
"start": 33.58,
|
||||
"end": 33.76
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 33.76,
|
||||
"end": 34.0
|
||||
},
|
||||
{
|
||||
"word": "transition",
|
||||
"start": 34.0,
|
||||
"end": 34.42
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 34.42,
|
||||
"end": 34.72
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 34.72,
|
||||
"end": 34.84
|
||||
},
|
||||
{
|
||||
"word": "next",
|
||||
"start": 34.84,
|
||||
"end": 35.06
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 35.06,
|
||||
"end": 35.48
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 37.2,
|
||||
"end": 37.76
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 37.76,
|
||||
"end": 37.82
|
||||
},
|
||||
{
|
||||
"word": "continue",
|
||||
"start": 37.82,
|
||||
"end": 38.12
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 38.12,
|
||||
"end": 38.34
|
||||
},
|
||||
{
|
||||
"word": "talk",
|
||||
"start": 38.34,
|
||||
"end": 38.58
|
||||
},
|
||||
{
|
||||
"word": "without",
|
||||
"start": 38.58,
|
||||
"end": 38.92
|
||||
},
|
||||
{
|
||||
"word": "pause,",
|
||||
"start": 38.92,
|
||||
"end": 39.26
|
||||
},
|
||||
{
|
||||
"word": "but",
|
||||
"start": 39.5,
|
||||
"end": 39.6
|
||||
},
|
||||
{
|
||||
"word": "in",
|
||||
"start": 39.6,
|
||||
"end": 39.72
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 39.72,
|
||||
"end": 39.8
|
||||
},
|
||||
{
|
||||
"word": "finished",
|
||||
"start": 39.8,
|
||||
"end": 40.0
|
||||
},
|
||||
{
|
||||
"word": "recording",
|
||||
"start": 40.0,
|
||||
"end": 40.48
|
||||
},
|
||||
{
|
||||
"word": "there",
|
||||
"start": 40.48,
|
||||
"end": 41.22
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 41.22,
|
||||
"end": 41.38
|
||||
},
|
||||
{
|
||||
"word": "be",
|
||||
"start": 41.38,
|
||||
"end": 41.58
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 41.58,
|
||||
"end": 41.68
|
||||
},
|
||||
{
|
||||
"word": "pause",
|
||||
"start": 41.68,
|
||||
"end": 41.96
|
||||
},
|
||||
{
|
||||
"word": "before",
|
||||
"start": 41.96,
|
||||
"end": 42.32
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 42.32,
|
||||
"end": 42.52
|
||||
},
|
||||
{
|
||||
"word": "narration",
|
||||
"start": 42.52,
|
||||
"end": 43.06
|
||||
},
|
||||
{
|
||||
"word": "continues.",
|
||||
"start": 43.06,
|
||||
"end": 43.66
|
||||
},
|
||||
{
|
||||
"word": "Now",
|
||||
"start": 44.44,
|
||||
"end": 44.56
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 44.56,
|
||||
"end": 44.7
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 44.7,
|
||||
"end": 44.94
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 44.94,
|
||||
"end": 45.12
|
||||
},
|
||||
{
|
||||
"word": "play",
|
||||
"start": 45.12,
|
||||
"end": 45.4
|
||||
},
|
||||
{
|
||||
"word": "that",
|
||||
"start": 45.4,
|
||||
"end": 45.8
|
||||
},
|
||||
{
|
||||
"word": "pauses",
|
||||
"start": 45.8,
|
||||
"end": 46.52
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 46.52,
|
||||
"end": 46.8
|
||||
},
|
||||
{
|
||||
"word": "narration.",
|
||||
"start": 46.8,
|
||||
"end": 47.22
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 48.66,
|
||||
"end": 49.22
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 49.22,
|
||||
"end": 49.44
|
||||
},
|
||||
{
|
||||
"word": "my",
|
||||
"start": 49.44,
|
||||
"end": 49.6
|
||||
},
|
||||
{
|
||||
"word": "voice",
|
||||
"start": 49.6,
|
||||
"end": 49.84
|
||||
},
|
||||
{
|
||||
"word": "continues",
|
||||
"start": 49.84,
|
||||
"end": 50.38
|
||||
},
|
||||
{
|
||||
"word": "after",
|
||||
"start": 50.38,
|
||||
"end": 50.88
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 50.88,
|
||||
"end": 51.04
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 51.04,
|
||||
"end": 51.28
|
||||
},
|
||||
{
|
||||
"word": "finished.",
|
||||
"start": 51.28,
|
||||
"end": 51.8
|
||||
},
|
||||
{
|
||||
"word": "This",
|
||||
"start": 65.46000000000001,
|
||||
"end": 66.14
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 66.14,
|
||||
"end": 66.34
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 66.34,
|
||||
"end": 66.52
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 66.52,
|
||||
"end": 66.74
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 66.74,
|
||||
"end": 67.14
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 67.68,
|
||||
"end": 68.02
|
||||
},
|
||||
{
|
||||
"word": "appears",
|
||||
"start": 68.02,
|
||||
"end": 68.38
|
||||
},
|
||||
{
|
||||
"word": "immediate.",
|
||||
"start": 68.38,
|
||||
"end": 68.86
|
||||
},
|
||||
{
|
||||
"word": "However,",
|
||||
"start": 70.28,
|
||||
"end": 70.76
|
||||
},
|
||||
{
|
||||
"word": "this",
|
||||
"start": 71.1,
|
||||
"end": 71.18
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 71.18,
|
||||
"end": 71.32
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 71.32,
|
||||
"end": 71.48
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 71.48,
|
||||
"end": 71.78
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 71.78,
|
||||
"end": 72.12
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 72.4,
|
||||
"end": 72.7
|
||||
},
|
||||
{
|
||||
"word": "should",
|
||||
"start": 72.7,
|
||||
"end": 72.94
|
||||
},
|
||||
{
|
||||
"word": "appear",
|
||||
"start": 72.94,
|
||||
"end": 73.26
|
||||
},
|
||||
{
|
||||
"word": "one",
|
||||
"start": 73.26,
|
||||
"end": 73.72
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 73.72,
|
||||
"end": 74.2
|
||||
},
|
||||
{
|
||||
"word": "prior",
|
||||
"start": 74.2,
|
||||
"end": 74.62
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 74.62,
|
||||
"end": 74.86
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 74.86,
|
||||
"end": 74.98
|
||||
},
|
||||
{
|
||||
"word": "word",
|
||||
"start": 74.98,
|
||||
"end": 75.24
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 75.24,
|
||||
"end": 75.94
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 75.94,
|
||||
"end": 76.02
|
||||
},
|
||||
{
|
||||
"word": "say",
|
||||
"start": 76.02,
|
||||
"end": 76.18
|
||||
},
|
||||
{
|
||||
"word": "whoever",
|
||||
"start": 76.18,
|
||||
"end": 76.56
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 76.56,
|
||||
"end": 77.14
|
||||
},
|
||||
{
|
||||
"word": "time.",
|
||||
"start": 77.14,
|
||||
"end": 77.42
|
||||
},
|
||||
{
|
||||
"word": "This",
|
||||
"start": 79.36,
|
||||
"end": 79.7
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 79.7,
|
||||
"end": 79.86
|
||||
},
|
||||
{
|
||||
"word": "me",
|
||||
"start": 79.86,
|
||||
"end": 80.08
|
||||
},
|
||||
{
|
||||
"word": "taking,",
|
||||
"start": 80.08,
|
||||
"end": 80.48
|
||||
},
|
||||
{
|
||||
"word": "talking",
|
||||
"start": 80.92,
|
||||
"end": 81.88
|
||||
},
|
||||
{
|
||||
"word": "alongside",
|
||||
"start": 81.88,
|
||||
"end": 82.36
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 82.36,
|
||||
"end": 82.62
|
||||
},
|
||||
{
|
||||
"word": "video.",
|
||||
"start": 82.62,
|
||||
"end": 82.88
|
||||
},
|
||||
{
|
||||
"word": "The",
|
||||
"start": 83.48,
|
||||
"end": 83.66
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 83.66,
|
||||
"end": 83.92
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 83.92,
|
||||
"end": 84.1
|
||||
},
|
||||
{
|
||||
"word": "constrained",
|
||||
"start": 84.1,
|
||||
"end": 84.54
|
||||
},
|
||||
{
|
||||
"word": "within",
|
||||
"start": 84.54,
|
||||
"end": 84.88
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 84.88,
|
||||
"end": 85.06
|
||||
},
|
||||
{
|
||||
"word": "red",
|
||||
"start": 85.06,
|
||||
"end": 85.22
|
||||
},
|
||||
{
|
||||
"word": "square.",
|
||||
"start": 85.22,
|
||||
"end": 85.62
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 85.62,
|
||||
"end": 86.18
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 86.18,
|
||||
"end": 86.48
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 86.48,
|
||||
"end": 86.66
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 86.66,
|
||||
"end": 86.86
|
||||
},
|
||||
{
|
||||
"word": "stops",
|
||||
"start": 86.86,
|
||||
"end": 87.2
|
||||
},
|
||||
{
|
||||
"word": "immediately",
|
||||
"start": 87.2,
|
||||
"end": 87.68
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 87.68,
|
||||
"end": 88.1
|
||||
},
|
||||
{
|
||||
"word": "we",
|
||||
"start": 88.1,
|
||||
"end": 88.28
|
||||
},
|
||||
{
|
||||
"word": "make",
|
||||
"start": 88.28,
|
||||
"end": 88.46
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 88.46,
|
||||
"end": 88.7
|
||||
},
|
||||
{
|
||||
"word": "transition",
|
||||
"start": 88.7,
|
||||
"end": 89.12
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 89.12,
|
||||
"end": 89.42
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 89.42,
|
||||
"end": 89.54
|
||||
},
|
||||
{
|
||||
"word": "next",
|
||||
"start": 89.54,
|
||||
"end": 89.76
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 89.76,
|
||||
"end": 90.22
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 91.94,
|
||||
"end": 92.46
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 92.46,
|
||||
"end": 92.52
|
||||
},
|
||||
{
|
||||
"word": "continue",
|
||||
"start": 92.52,
|
||||
"end": 92.82
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 92.82,
|
||||
"end": 93.04
|
||||
},
|
||||
{
|
||||
"word": "talk",
|
||||
"start": 93.04,
|
||||
"end": 93.28
|
||||
},
|
||||
{
|
||||
"word": "without",
|
||||
"start": 93.28,
|
||||
"end": 93.62
|
||||
},
|
||||
{
|
||||
"word": "pause,",
|
||||
"start": 93.62,
|
||||
"end": 93.96
|
||||
},
|
||||
{
|
||||
"word": "but",
|
||||
"start": 94.2,
|
||||
"end": 94.3
|
||||
},
|
||||
{
|
||||
"word": "in",
|
||||
"start": 94.3,
|
||||
"end": 94.42
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 94.42,
|
||||
"end": 94.48
|
||||
},
|
||||
{
|
||||
"word": "finished",
|
||||
"start": 94.48,
|
||||
"end": 94.7
|
||||
},
|
||||
{
|
||||
"word": "recording",
|
||||
"start": 94.7,
|
||||
"end": 95.18
|
||||
},
|
||||
{
|
||||
"word": "there",
|
||||
"start": 95.18,
|
||||
"end": 95.92
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 95.92,
|
||||
"end": 96.08
|
||||
},
|
||||
{
|
||||
"word": "be",
|
||||
"start": 96.08,
|
||||
"end": 96.28
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 96.28,
|
||||
"end": 96.38
|
||||
},
|
||||
{
|
||||
"word": "pause",
|
||||
"start": 96.38,
|
||||
"end": 96.64
|
||||
},
|
||||
{
|
||||
"word": "before",
|
||||
"start": 96.64,
|
||||
"end": 97.02
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 97.02,
|
||||
"end": 97.22
|
||||
},
|
||||
{
|
||||
"word": "narration",
|
||||
"start": 97.22,
|
||||
"end": 97.76
|
||||
},
|
||||
{
|
||||
"word": "continues.",
|
||||
"start": 97.76,
|
||||
"end": 98.38
|
||||
},
|
||||
{
|
||||
"word": "Now",
|
||||
"start": 99.06,
|
||||
"end": 99.26
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 99.26,
|
||||
"end": 99.4
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 99.4,
|
||||
"end": 99.64
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 99.64,
|
||||
"end": 99.8
|
||||
},
|
||||
{
|
||||
"word": "play",
|
||||
"start": 99.8,
|
||||
"end": 100.1
|
||||
},
|
||||
{
|
||||
"word": "that",
|
||||
"start": 100.1,
|
||||
"end": 100.5
|
||||
},
|
||||
{
|
||||
"word": "pauses",
|
||||
"start": 100.5,
|
||||
"end": 101.24
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 101.24,
|
||||
"end": 101.5
|
||||
},
|
||||
{
|
||||
"word": "narration.",
|
||||
"start": 101.5,
|
||||
"end": 101.92
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 103.18,
|
||||
"end": 103.92
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 103.92,
|
||||
"end": 104.14
|
||||
},
|
||||
{
|
||||
"word": "my",
|
||||
"start": 104.14,
|
||||
"end": 104.32
|
||||
},
|
||||
{
|
||||
"word": "voice",
|
||||
"start": 104.32,
|
||||
"end": 104.58
|
||||
},
|
||||
{
|
||||
"word": "continues",
|
||||
"start": 104.58,
|
||||
"end": 105.1
|
||||
},
|
||||
{
|
||||
"word": "after",
|
||||
"start": 105.1,
|
||||
"end": 105.58
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 105.58,
|
||||
"end": 105.76
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 105.76,
|
||||
"end": 105.98
|
||||
},
|
||||
{
|
||||
"word": "finished.",
|
||||
"start": 105.98,
|
||||
"end": 106.48
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,497 @@
|
||||
[
|
||||
{
|
||||
"word": "This",
|
||||
"start": 10.72,
|
||||
"end": 11.4
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 11.4,
|
||||
"end": 11.6
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 11.6,
|
||||
"end": 11.78
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 11.78,
|
||||
"end": 11.98
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 11.98,
|
||||
"end": 12.44
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 13.02,
|
||||
"end": 13.3
|
||||
},
|
||||
{
|
||||
"word": "appears",
|
||||
"start": 13.3,
|
||||
"end": 13.66
|
||||
},
|
||||
{
|
||||
"word": "immediately.",
|
||||
"start": 13.66,
|
||||
"end": 14.3
|
||||
},
|
||||
{
|
||||
"word": "However,",
|
||||
"start": 15.34,
|
||||
"end": 16.02
|
||||
},
|
||||
{
|
||||
"word": "this",
|
||||
"start": 16.34,
|
||||
"end": 16.46
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 16.46,
|
||||
"end": 16.58
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 16.58,
|
||||
"end": 16.76
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 16.76,
|
||||
"end": 17.04
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 17.04,
|
||||
"end": 17.4
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 17.74,
|
||||
"end": 17.96
|
||||
},
|
||||
{
|
||||
"word": "should",
|
||||
"start": 17.96,
|
||||
"end": 18.2
|
||||
},
|
||||
{
|
||||
"word": "appear",
|
||||
"start": 18.2,
|
||||
"end": 18.54
|
||||
},
|
||||
{
|
||||
"word": "one",
|
||||
"start": 18.54,
|
||||
"end": 18.98
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 18.98,
|
||||
"end": 19.46
|
||||
},
|
||||
{
|
||||
"word": "prior",
|
||||
"start": 19.46,
|
||||
"end": 19.88
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 19.88,
|
||||
"end": 20.1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 20.1,
|
||||
"end": 20.22
|
||||
},
|
||||
{
|
||||
"word": "word",
|
||||
"start": 20.22,
|
||||
"end": 20.52
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 20.52,
|
||||
"end": 21.14
|
||||
},
|
||||
{
|
||||
"word": "say",
|
||||
"start": 21.14,
|
||||
"end": 21.42
|
||||
},
|
||||
{
|
||||
"word": "whoever",
|
||||
"start": 21.42,
|
||||
"end": 21.8
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 21.8,
|
||||
"end": 22.16
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 22.16,
|
||||
"end": 22.4
|
||||
},
|
||||
{
|
||||
"word": "time.",
|
||||
"start": 22.4,
|
||||
"end": 22.68
|
||||
},
|
||||
{
|
||||
"word": "This",
|
||||
"start": 24.28,
|
||||
"end": 24.96
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 24.96,
|
||||
"end": 25.12
|
||||
},
|
||||
{
|
||||
"word": "me",
|
||||
"start": 25.12,
|
||||
"end": 25.36
|
||||
},
|
||||
{
|
||||
"word": "taking,",
|
||||
"start": 25.36,
|
||||
"end": 25.74
|
||||
},
|
||||
{
|
||||
"word": "talking",
|
||||
"start": 26.12,
|
||||
"end": 27.12
|
||||
},
|
||||
{
|
||||
"word": "alongside",
|
||||
"start": 27.12,
|
||||
"end": 27.64
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 27.64,
|
||||
"end": 27.88
|
||||
},
|
||||
{
|
||||
"word": "video.",
|
||||
"start": 27.88,
|
||||
"end": 28.16
|
||||
},
|
||||
{
|
||||
"word": "The",
|
||||
"start": 28.16,
|
||||
"end": 28.92
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 28.92,
|
||||
"end": 29.18
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 29.18,
|
||||
"end": 29.36
|
||||
},
|
||||
{
|
||||
"word": "constrained",
|
||||
"start": 29.36,
|
||||
"end": 29.76
|
||||
},
|
||||
{
|
||||
"word": "within",
|
||||
"start": 29.76,
|
||||
"end": 30.14
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 30.14,
|
||||
"end": 30.32
|
||||
},
|
||||
{
|
||||
"word": "red",
|
||||
"start": 30.32,
|
||||
"end": 30.48
|
||||
},
|
||||
{
|
||||
"word": "square.",
|
||||
"start": 30.48,
|
||||
"end": 30.9
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 31.26,
|
||||
"end": 31.44
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 31.44,
|
||||
"end": 31.74
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 31.74,
|
||||
"end": 31.92
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 31.92,
|
||||
"end": 32.14
|
||||
},
|
||||
{
|
||||
"word": "stops",
|
||||
"start": 32.14,
|
||||
"end": 32.44
|
||||
},
|
||||
{
|
||||
"word": "immediately",
|
||||
"start": 32.44,
|
||||
"end": 32.94
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 32.94,
|
||||
"end": 33.36
|
||||
},
|
||||
{
|
||||
"word": "we",
|
||||
"start": 33.36,
|
||||
"end": 33.54
|
||||
},
|
||||
{
|
||||
"word": "make",
|
||||
"start": 33.54,
|
||||
"end": 33.74
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 33.74,
|
||||
"end": 33.94
|
||||
},
|
||||
{
|
||||
"word": "transition",
|
||||
"start": 33.94,
|
||||
"end": 34.38
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 34.38,
|
||||
"end": 34.68
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 34.68,
|
||||
"end": 34.8
|
||||
},
|
||||
{
|
||||
"word": "next",
|
||||
"start": 34.8,
|
||||
"end": 35.02
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 35.02,
|
||||
"end": 35.48
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 37.18,
|
||||
"end": 37.72
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 37.72,
|
||||
"end": 37.78
|
||||
},
|
||||
{
|
||||
"word": "continue",
|
||||
"start": 37.78,
|
||||
"end": 38.08
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 38.08,
|
||||
"end": 38.32
|
||||
},
|
||||
{
|
||||
"word": "talk",
|
||||
"start": 38.32,
|
||||
"end": 38.56
|
||||
},
|
||||
{
|
||||
"word": "without",
|
||||
"start": 38.56,
|
||||
"end": 38.88
|
||||
},
|
||||
{
|
||||
"word": "pause,",
|
||||
"start": 38.88,
|
||||
"end": 39.24
|
||||
},
|
||||
{
|
||||
"word": "but",
|
||||
"start": 39.46,
|
||||
"end": 39.56
|
||||
},
|
||||
{
|
||||
"word": "in",
|
||||
"start": 39.56,
|
||||
"end": 39.68
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 39.68,
|
||||
"end": 39.74
|
||||
},
|
||||
{
|
||||
"word": "finished",
|
||||
"start": 39.74,
|
||||
"end": 39.98
|
||||
},
|
||||
{
|
||||
"word": "recording",
|
||||
"start": 39.98,
|
||||
"end": 40.46
|
||||
},
|
||||
{
|
||||
"word": "there",
|
||||
"start": 40.46,
|
||||
"end": 41.18
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 41.18,
|
||||
"end": 41.36
|
||||
},
|
||||
{
|
||||
"word": "be",
|
||||
"start": 41.36,
|
||||
"end": 41.54
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 41.54,
|
||||
"end": 41.64
|
||||
},
|
||||
{
|
||||
"word": "pause",
|
||||
"start": 41.64,
|
||||
"end": 41.92
|
||||
},
|
||||
{
|
||||
"word": "before",
|
||||
"start": 41.92,
|
||||
"end": 42.28
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 42.28,
|
||||
"end": 42.5
|
||||
},
|
||||
{
|
||||
"word": "narration",
|
||||
"start": 42.5,
|
||||
"end": 43.0
|
||||
},
|
||||
{
|
||||
"word": "continues.",
|
||||
"start": 43.0,
|
||||
"end": 43.64
|
||||
},
|
||||
{
|
||||
"word": "Now",
|
||||
"start": 44.38,
|
||||
"end": 44.52
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 44.52,
|
||||
"end": 44.68
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 44.68,
|
||||
"end": 44.9
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 44.9,
|
||||
"end": 45.08
|
||||
},
|
||||
{
|
||||
"word": "play",
|
||||
"start": 45.08,
|
||||
"end": 45.36
|
||||
},
|
||||
{
|
||||
"word": "that",
|
||||
"start": 45.36,
|
||||
"end": 45.76
|
||||
},
|
||||
{
|
||||
"word": "pauses",
|
||||
"start": 45.76,
|
||||
"end": 46.52
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 46.52,
|
||||
"end": 46.76
|
||||
},
|
||||
{
|
||||
"word": "narration.",
|
||||
"start": 46.76,
|
||||
"end": 47.2
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 48.64,
|
||||
"end": 49.18
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 49.18,
|
||||
"end": 49.42
|
||||
},
|
||||
{
|
||||
"word": "my",
|
||||
"start": 49.42,
|
||||
"end": 49.58
|
||||
},
|
||||
{
|
||||
"word": "voice",
|
||||
"start": 49.58,
|
||||
"end": 49.8
|
||||
},
|
||||
{
|
||||
"word": "continues",
|
||||
"start": 49.8,
|
||||
"end": 50.36
|
||||
},
|
||||
{
|
||||
"word": "after",
|
||||
"start": 50.36,
|
||||
"end": 50.84
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 50.84,
|
||||
"end": 51.02
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 51.02,
|
||||
"end": 51.24
|
||||
},
|
||||
{
|
||||
"word": "finished.",
|
||||
"start": 51.24,
|
||||
"end": 51.76
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"talking_head_S1": {
|
||||
"source_file": "talking_head_S1.mov",
|
||||
"output_file": "talking_head_S1_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": "talkinghead"
|
||||
},
|
||||
"talking_head_S3": {
|
||||
"source_file": "talking_head_S3.mov",
|
||||
"output_file": "talking_head_S3_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": "talkinghead"
|
||||
},
|
||||
"KnightRotating": {
|
||||
"description": "Knight model rotating in place",
|
||||
"source_file": "KnightRotating.mp4",
|
||||
"output_file": "KnightRotating.mp4",
|
||||
"cutout": "square",
|
||||
"filter": [],
|
||||
"is_shared": true
|
||||
},
|
||||
"gnommologo": {
|
||||
"source_file": "Logo.mov",
|
||||
"is_shared": true,
|
||||
"cutout": "fullscreen",
|
||||
"pause_narration": 17,
|
||||
"take": 25,
|
||||
"skip": 0
|
||||
},
|
||||
"Zoomin_MontageZoom": {
|
||||
"description": "Montage zoom",
|
||||
"source_file": "MontageZoom.mp4",
|
||||
"output_file": "MontageZoom.mp4",
|
||||
"pause_narration": 5,
|
||||
"cutout": "square",
|
||||
"is_shared": true,
|
||||
"filter": []
|
||||
},
|
||||
"narration_combined": {
|
||||
"source_file": "narration_combined.mov",
|
||||
"output_file": "narration_combined.mov",
|
||||
"cutout": "square",
|
||||
"filter": []
|
||||
}
|
||||
}
|
||||
+88
-6
@@ -1,11 +1,93 @@
|
||||
{
|
||||
"id": "VideoExample",
|
||||
"name": "Example",
|
||||
"description": "In this video, I demonstrate the Gnommo video editing pipeline - a code-first approach to creating presenter-mode videos from Keynote presentations.",
|
||||
"footer": "Subscribe for more tutorials!\nTwitter: @example",
|
||||
"resolution": [1920, 1080],
|
||||
"fps": 30,
|
||||
"talkinghead": {
|
||||
"x": 50,
|
||||
"y": 600,
|
||||
"targetheight": 400
|
||||
"gnommo_scratch": null,
|
||||
"defaultSlideType": "fullscreen",
|
||||
"keynote_file": "media/example.key",
|
||||
"transcript": "media/videos/talking_head.transcript.json",
|
||||
"narration": "media/narration/narration.json",
|
||||
"background": "shared_assets/solarpunk.png",
|
||||
"videos": "media/videos/videos.json",
|
||||
"slides": "media/slides/Example/slides.json",
|
||||
"audio": "media/audio/audio.json",
|
||||
"default_filters": {
|
||||
"talkinghead": [
|
||||
{
|
||||
"type": "audio_normalize",
|
||||
"eq_bands": [
|
||||
{"freq": 47, "gain": -15, "type": "lowshelf"},
|
||||
{"freq": 107, "gain": -1.3, "q": 1.2},
|
||||
{"freq": 597, "gain": -5.2, "q": 2},
|
||||
{"freq": 11811, "gain": 2.8, "q": 1},
|
||||
{"freq": 24000, "gain": 3.9, "type": "highshelf"}
|
||||
],
|
||||
"highpass": 0,
|
||||
"room_eq": false,
|
||||
"dereverb_model": "shared_assets/models/std.rnnn",
|
||||
"dereverb_mix": 0.8,
|
||||
"denoise": true,
|
||||
"noise_floor": -25,
|
||||
"gate": true,
|
||||
"gate_threshold": -35,
|
||||
"gate_range": -20,
|
||||
"compress": true,
|
||||
"threshold": -20,
|
||||
"ratio": 3,
|
||||
"attack": 12,
|
||||
"release": 100,
|
||||
"makeup": 2,
|
||||
"normalize": true,
|
||||
"target_lufs": -16,
|
||||
"target_lra": 11,
|
||||
"target_tp": -1.5
|
||||
},
|
||||
"defaultSlideType": "square",
|
||||
"background_video": ""
|
||||
{
|
||||
"type": "color_grade",
|
||||
"saturation": 1.15,
|
||||
"contrast": 1.05,
|
||||
"bm": -0.10,
|
||||
"rm": 0.04
|
||||
},
|
||||
{
|
||||
"type": "gnommokey",
|
||||
"screen_color": [81, 137, 65],
|
||||
"screen_gain": 175,
|
||||
"screen_balance": 58,
|
||||
"despill_bias": [217, 240, 255],
|
||||
"despill_strength": 5.0,
|
||||
"edge_erode": 1.0,
|
||||
"clip_black": 0,
|
||||
"clip_white": 100
|
||||
},
|
||||
{
|
||||
"type": "mask",
|
||||
"left": 0.05,
|
||||
"right": 0.1,
|
||||
"top": 0.1,
|
||||
"bottom": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"cutouts": {
|
||||
"talkinghead": {
|
||||
"x": "-10%",
|
||||
"y": "40%",
|
||||
"height": "60%"
|
||||
},
|
||||
"square": {
|
||||
"x": "45%",
|
||||
"y": "3%",
|
||||
"width": "53%",
|
||||
"height": "94%"
|
||||
},
|
||||
"fullscreen": {
|
||||
"x": "0%",
|
||||
"y": "0%",
|
||||
"height": "100%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"S1": {
|
||||
"image": "S1.png",
|
||||
"type": "square"
|
||||
},
|
||||
"S2": {
|
||||
"image": "S2.png",
|
||||
"type": "square"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
t,word
|
||||
0.00,Hello
|
||||
0.30,world
|
||||
0.60,[S1]
|
||||
1.50,Second
|
||||
1.80,slide
|
||||
2.00,[S2]
|
||||
2.50,End
|
||||
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"talking_head": {
|
||||
"file": "media/talking_head.mp4",
|
||||
"preprocess": []
|
||||
}
|
||||
}
|
||||
@@ -1,154 +1,21 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# GnommoEditor - Code-first video editing pipeline
|
||||
# This is a thin wrapper that activates the venv and runs the Python CLI.
|
||||
#
|
||||
# Usage:
|
||||
# gnommo.sh -p <project> Render project
|
||||
# gnommo.sh -p <project> import Generate slides.json from image files
|
||||
# gnommo.sh -p <project> validate Validate only
|
||||
# gnommo.sh -p <project> preprocess Apply video preprocessing filters
|
||||
# gnommo.sh -p <project> transcribe Transcribe video
|
||||
# gnommo.sh -p <project> align Align markers to transcript
|
||||
# gnommo.sh -p <project> all Full pipeline: transcribe → align → render
|
||||
# Usage: gnommo -p <project> [action] [options]
|
||||
# Run with -h for full help.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
|
||||
# Check for venv
|
||||
if [[ ! -f "$VENV_PYTHON" ]]; then
|
||||
echo "Error: Virtual environment not found at $SCRIPT_DIR/venv"
|
||||
echo "Create it with: python -m venv venv && ./venv/bin/pip install openai-whisper"
|
||||
echo "Create it with: python -m venv venv && ./venv/bin/pip install -e . openai-whisper"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
PROJECT=""
|
||||
COMMAND="render"
|
||||
VERBOSE=""
|
||||
FORCE=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: gnommo.sh -p <project> [command] [options]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " render Render video (default)"
|
||||
echo " import Generate slides.json from image files"
|
||||
echo " validate Validate project only"
|
||||
echo " preprocess Apply video preprocessing filters (chroma key, etc.)"
|
||||
echo " transcribe Transcribe video audio"
|
||||
echo " align Align manuscript to transcript"
|
||||
echo " all Full pipeline: transcribe → align → render"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -p <dir> Project directory (required)"
|
||||
echo " -v Verbose output"
|
||||
echo " -f Force overwrite existing files"
|
||||
echo " -h Show this help"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " gnommo.sh -p video1 # Render video1 project"
|
||||
echo " gnommo.sh -p video1 import # Generate slides.json"
|
||||
echo " gnommo.sh -p video1 import -f # Force overwrite slides.json"
|
||||
echo " gnommo.sh -p video1 validate # Validate only"
|
||||
echo " gnommo.sh -p video1 all # Full pipeline"
|
||||
exit 0
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-p|--project)
|
||||
PROJECT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-v|--verbose)
|
||||
VERBOSE="-v"
|
||||
shift
|
||||
;;
|
||||
-f|--force)
|
||||
FORCE="-f"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
import|validate|render|preprocess|transcribe|align|all)
|
||||
COMMAND="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate project argument
|
||||
if [[ -z "$PROJECT" ]]; then
|
||||
echo "Error: Project directory required (-p <project>)"
|
||||
echo ""
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ ! -d "$PROJECT" ]]; then
|
||||
echo "Error: Project directory not found: $PROJECT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$PROJECT/project.json" ]]; then
|
||||
echo "Error: project.json not found in $PROJECT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run commands using new CLI interface
|
||||
run_gnommo() {
|
||||
"$VENV_PYTHON" -m gnommo -p "$PROJECT" -a "$1" $VERBOSE
|
||||
}
|
||||
|
||||
run_gnommo_import() {
|
||||
"$VENV_PYTHON" -m gnommo -p "$PROJECT" -a validate -i $FORCE $VERBOSE
|
||||
}
|
||||
|
||||
case $COMMAND in
|
||||
import)
|
||||
echo "=== Importing assets for $PROJECT ==="
|
||||
run_gnommo_import
|
||||
;;
|
||||
|
||||
validate)
|
||||
echo "=== Validating $PROJECT ==="
|
||||
run_gnommo validate
|
||||
;;
|
||||
|
||||
transcribe)
|
||||
echo "=== Transcribing $PROJECT ==="
|
||||
run_gnommo transcribe
|
||||
;;
|
||||
|
||||
align)
|
||||
echo "=== Aligning $PROJECT ==="
|
||||
run_gnommo align
|
||||
;;
|
||||
|
||||
render)
|
||||
echo "=== Rendering $PROJECT ==="
|
||||
run_gnommo render
|
||||
;;
|
||||
|
||||
preprocess)
|
||||
echo "=== Preprocessing $PROJECT ==="
|
||||
run_gnommo preprocess
|
||||
;;
|
||||
|
||||
all)
|
||||
echo "=== Full Pipeline: $PROJECT ==="
|
||||
run_gnommo all
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown command: $COMMAND"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
# Pass all arguments directly to the Python CLI
|
||||
exec "$VENV_PYTHON" -m gnommo "$@"
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Alignment stage: match manuscript markers to transcript timestamps."""
|
||||
|
||||
import csv
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import GnommoError
|
||||
from .transcriber import TranscribedWord
|
||||
|
||||
|
||||
class AlignmentError(GnommoError):
|
||||
"""Error during alignment."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkerAlignment:
|
||||
"""A marker with its aligned timestamp."""
|
||||
marker_id: str
|
||||
timestamp: float
|
||||
matched_phrase: str
|
||||
confidence: float # 0-1, how confident the match is
|
||||
|
||||
|
||||
def extract_marker_contexts(manuscript_text: str) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Extract markers and the text immediately following them.
|
||||
|
||||
Returns:
|
||||
List of (marker_id, following_text) tuples
|
||||
"""
|
||||
# Split by markers, keeping the markers
|
||||
parts = re.split(r"\[([A-Za-z0-9_]+)\]", manuscript_text)
|
||||
|
||||
# parts will be: [text_before, marker1, text_after1, marker2, text_after2, ...]
|
||||
contexts = []
|
||||
|
||||
for i in range(1, len(parts), 2):
|
||||
marker_id = parts[i]
|
||||
if i + 1 < len(parts):
|
||||
following_text = parts[i + 1].strip()
|
||||
# Get first sentence or first N words
|
||||
following_text = _get_first_phrase(following_text)
|
||||
contexts.append((marker_id, following_text))
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def _get_first_phrase(text: str, max_words: int = 10) -> str:
|
||||
"""Extract first phrase (up to first sentence end or max_words)."""
|
||||
# Clean up the text
|
||||
text = text.replace("\n", " ").strip()
|
||||
|
||||
# Find first sentence boundary
|
||||
match = re.search(r"[.!?]", text)
|
||||
if match and match.start() < 200:
|
||||
text = text[: match.start()]
|
||||
|
||||
# Limit to max_words
|
||||
words = text.split()[:max_words]
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize text for matching (lowercase, remove punctuation)."""
|
||||
text = text.lower()
|
||||
text = re.sub(r"[^\w\s]", "", text)
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def find_phrase_in_transcript(
|
||||
phrase: str,
|
||||
transcript: list[TranscribedWord],
|
||||
start_from: int = 0,
|
||||
) -> tuple[int, float]:
|
||||
"""
|
||||
Find a phrase in the transcript and return the word index and timestamp.
|
||||
|
||||
Uses sliding window matching with normalization.
|
||||
|
||||
Returns:
|
||||
Tuple of (word_index, timestamp) or (-1, 0.0) if not found
|
||||
"""
|
||||
phrase_normalized = normalize_text(phrase)
|
||||
phrase_words = phrase_normalized.split()
|
||||
|
||||
if not phrase_words:
|
||||
return -1, 0.0
|
||||
|
||||
# Try to find increasingly shorter prefixes
|
||||
for length in range(len(phrase_words), 2, -1):
|
||||
target = " ".join(phrase_words[:length])
|
||||
|
||||
# Sliding window through transcript
|
||||
for i in range(start_from, len(transcript) - length + 1):
|
||||
window_words = [normalize_text(transcript[j].word) for j in range(i, i + length)]
|
||||
window_text = " ".join(window_words)
|
||||
|
||||
if target in window_text or window_text in target:
|
||||
return i, transcript[i].start
|
||||
|
||||
# Fallback: try to find just the first few words
|
||||
if len(phrase_words) >= 2:
|
||||
target = " ".join(phrase_words[:3])
|
||||
for i in range(start_from, len(transcript) - 2):
|
||||
window_words = [normalize_text(transcript[j].word) for j in range(i, min(i + 5, len(transcript)))]
|
||||
window_text = " ".join(window_words)
|
||||
if phrase_words[0] in window_text and phrase_words[1] in window_text:
|
||||
return i, transcript[i].start
|
||||
|
||||
return -1, 0.0
|
||||
|
||||
|
||||
def align_markers(
|
||||
manuscript_text: str,
|
||||
transcript: list[TranscribedWord],
|
||||
offset_seconds: float = -1.0,
|
||||
) -> list[MarkerAlignment]:
|
||||
"""
|
||||
Align manuscript markers to transcript timestamps.
|
||||
|
||||
Args:
|
||||
manuscript_text: Full manuscript text with [S1], [S2] etc.
|
||||
transcript: Word-level transcript with timestamps
|
||||
offset_seconds: Offset to apply to found timestamps (default -1.0)
|
||||
|
||||
Returns:
|
||||
List of MarkerAlignment with timestamps
|
||||
"""
|
||||
contexts = extract_marker_contexts(manuscript_text)
|
||||
alignments: list[MarkerAlignment] = []
|
||||
|
||||
last_index = 0
|
||||
|
||||
for marker_id, following_text in contexts:
|
||||
idx, timestamp = find_phrase_in_transcript(
|
||||
following_text, transcript, start_from=last_index
|
||||
)
|
||||
|
||||
if idx >= 0:
|
||||
# Apply offset (e.g., -1 second before the word)
|
||||
adjusted_time = max(0.0, timestamp + offset_seconds)
|
||||
alignments.append(MarkerAlignment(
|
||||
marker_id=marker_id,
|
||||
timestamp=adjusted_time,
|
||||
matched_phrase=following_text[:50],
|
||||
confidence=1.0,
|
||||
))
|
||||
last_index = idx
|
||||
else:
|
||||
# Could not find match - report but continue
|
||||
alignments.append(MarkerAlignment(
|
||||
marker_id=marker_id,
|
||||
timestamp=-1.0, # Indicates not found
|
||||
matched_phrase=following_text[:50],
|
||||
confidence=0.0,
|
||||
))
|
||||
|
||||
return alignments
|
||||
|
||||
|
||||
def save_aligned_transcript(
|
||||
alignments: list[MarkerAlignment],
|
||||
transcript: list[TranscribedWord],
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Save aligned transcript as CSV compatible with gnommo's transcript.csv format.
|
||||
|
||||
Format:
|
||||
t,word
|
||||
0.00,Hello
|
||||
1.50,[S1]
|
||||
1.51,This
|
||||
...
|
||||
"""
|
||||
# Build list of (timestamp, word) including markers
|
||||
entries: list[tuple[float, str]] = []
|
||||
|
||||
# Add all words from transcript
|
||||
for word in transcript:
|
||||
entries.append((word.start, word.word))
|
||||
|
||||
# Add markers at their aligned positions
|
||||
for alignment in alignments:
|
||||
if alignment.timestamp >= 0:
|
||||
entries.append((alignment.timestamp, f"[{alignment.marker_id}]"))
|
||||
|
||||
# Sort by timestamp
|
||||
entries.sort(key=lambda x: x[0])
|
||||
|
||||
# Write CSV
|
||||
with open(output_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["t", "word"])
|
||||
for timestamp, word in entries:
|
||||
writer.writerow([f"{timestamp:.2f}", word])
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""GnommoCache - External storage extension for large media files.
|
||||
|
||||
Provides transparent fallback to external storage when files are not found locally.
|
||||
Configure via ~/.gnommo.conf:
|
||||
|
||||
[cache]
|
||||
path = /Volumes/GnommoDisk/gnommo
|
||||
|
||||
Files are looked up first locally, then in the cache at:
|
||||
{cache_path}/{project_name}/{relative_path}
|
||||
"""
|
||||
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
_cache_config: Optional[dict] = None
|
||||
|
||||
|
||||
def load_cache_config() -> Optional[Path]:
|
||||
"""Load gnommo.conf and return cache path if configured.
|
||||
|
||||
Configuration file location: ~/.gnommo.conf
|
||||
|
||||
Returns:
|
||||
Path to the cache root directory, or None if not configured.
|
||||
"""
|
||||
global _cache_config
|
||||
if _cache_config is not None:
|
||||
return _cache_config.get("path")
|
||||
|
||||
config_path = Path.home() / ".gnommo.conf"
|
||||
if not config_path.exists():
|
||||
_cache_config = {}
|
||||
return None
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
if config.has_option("cache", "path"):
|
||||
cache_path = Path(config.get("cache", "path"))
|
||||
_cache_config = {"path": cache_path}
|
||||
return cache_path
|
||||
|
||||
_cache_config = {}
|
||||
return None
|
||||
|
||||
|
||||
def resolve_with_cache(
|
||||
local_path: Path,
|
||||
project_path: Path,
|
||||
) -> Tuple[Path, bool]:
|
||||
"""
|
||||
Resolve a file path with cache fallback (read-only).
|
||||
|
||||
Checks the local path first. If not found and cache is configured,
|
||||
checks the cache directory which mirrors the project structure.
|
||||
|
||||
Args:
|
||||
local_path: The expected local path to the file
|
||||
project_path: The project root directory
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_path, is_cached) where is_cached=True if
|
||||
the file was found in the external cache instead of locally.
|
||||
"""
|
||||
# Check local path first
|
||||
if local_path.exists():
|
||||
return local_path, False
|
||||
|
||||
# Check cache
|
||||
cache_base = load_cache_config()
|
||||
if cache_base is None:
|
||||
return local_path, False # No cache configured
|
||||
|
||||
# Build cache path: {cache_base}/{project_name}/{relative_path}
|
||||
try:
|
||||
relative = local_path.relative_to(project_path)
|
||||
cache_path = cache_base / project_path.name / relative
|
||||
if cache_path.exists():
|
||||
return cache_path, True
|
||||
except ValueError:
|
||||
pass # local_path is not relative to project_path
|
||||
|
||||
return local_path, False
|
||||
|
||||
|
||||
def is_cache_configured() -> bool:
|
||||
"""Check if cache is configured (for status messages)."""
|
||||
return load_cache_config() is not None
|
||||
|
||||
|
||||
def get_cache_info() -> Optional[str]:
|
||||
"""Get a human-readable cache configuration string."""
|
||||
cache_path = load_cache_config()
|
||||
if cache_path is None:
|
||||
return None
|
||||
if cache_path.exists():
|
||||
return f"{cache_path} (connected)"
|
||||
return f"{cache_path} (not connected)"
|
||||
+3226
-160
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
"""Description generator: Create YouTube description with chapters, citations, and attributions."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .models import (
|
||||
Attribution,
|
||||
Citation,
|
||||
ProjectConfig,
|
||||
SlideDefinition,
|
||||
VideoSource,
|
||||
)
|
||||
from .transcriber import TranscribedWord
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChapterMarker:
|
||||
"""A chapter marker with timestamp and title."""
|
||||
|
||||
slide_id: str
|
||||
timestamp: float
|
||||
title: str
|
||||
|
||||
|
||||
def _format_timestamp(seconds: float) -> str:
|
||||
"""Format seconds as M:SS or H:MM:SS for YouTube chapters."""
|
||||
if seconds < 0:
|
||||
return "0:00"
|
||||
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
else:
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
|
||||
def _extract_chapter_title(
|
||||
manuscript_text: str, slide_id: str, slides: dict[str, SlideDefinition]
|
||||
) -> str:
|
||||
"""
|
||||
Extract a chapter title for a slide.
|
||||
|
||||
Tries to find meaningful title from:
|
||||
1. First sentence/line after the slide marker
|
||||
2. Falls back to slide ID if nothing useful found
|
||||
"""
|
||||
# Find the marker and text after it
|
||||
pattern = rf"\[{re.escape(slide_id)}\]\s*(.+?)(?=\[S\d+\]|\[video:|\[narration:|\Z)"
|
||||
match = re.search(pattern, manuscript_text, re.DOTALL)
|
||||
|
||||
if match:
|
||||
text = match.group(1).strip()
|
||||
# Remove any other markers from the text
|
||||
text = re.sub(r"\[[^\]]+\]", "", text).strip()
|
||||
|
||||
if text:
|
||||
# Take first line or first sentence
|
||||
first_line = text.split("\n")[0].strip()
|
||||
# Truncate if too long
|
||||
if len(first_line) > 50:
|
||||
# Try to break at word boundary
|
||||
truncated = first_line[:47]
|
||||
last_space = truncated.rfind(" ")
|
||||
if last_space > 30:
|
||||
truncated = truncated[:last_space]
|
||||
first_line = truncated + "..."
|
||||
|
||||
if first_line:
|
||||
return first_line
|
||||
|
||||
# Fallback to slide number
|
||||
slide_num = slide_id[1:] if slide_id.startswith("S") else slide_id
|
||||
return f"Section {slide_num}"
|
||||
|
||||
|
||||
def _align_citation_to_transcription(
|
||||
citation: Citation,
|
||||
transcription: list[TranscribedWord],
|
||||
manuscript_text: str,
|
||||
) -> float:
|
||||
"""
|
||||
Align a citation to the transcription to find its timestamp.
|
||||
|
||||
Uses the context text following the citation to find the approximate
|
||||
position in the audio.
|
||||
|
||||
Returns timestamp in seconds, or -1 if not found.
|
||||
"""
|
||||
if not transcription or not citation.context:
|
||||
return -1.0
|
||||
|
||||
# Get more context from the manuscript for better matching
|
||||
# Find the citation in the manuscript and get surrounding text
|
||||
pattern = rf"\[cite:{re.escape(citation.reference)}\]\s*(.{{0,200}})"
|
||||
match = re.search(pattern, manuscript_text, re.DOTALL)
|
||||
|
||||
if not match:
|
||||
return -1.0
|
||||
|
||||
context_text = match.group(1).strip()
|
||||
# Clean up: remove markers, normalize whitespace
|
||||
context_text = re.sub(r"\[[^\]]+\]", "", context_text)
|
||||
context_text = " ".join(context_text.split())
|
||||
|
||||
if not context_text:
|
||||
return -1.0
|
||||
|
||||
# Normalize for matching
|
||||
context_words = context_text.lower().split()[:10] # Use up to 10 words
|
||||
if not context_words:
|
||||
return -1.0
|
||||
|
||||
# Build normalized transcription
|
||||
trans_words = [(w.word.lower(), w.start) for w in transcription]
|
||||
|
||||
# Simple sliding window match
|
||||
best_match_score = 0
|
||||
best_match_time = -1.0
|
||||
|
||||
for i in range(len(trans_words) - len(context_words) + 1):
|
||||
matches = 0
|
||||
for j, ctx_word in enumerate(context_words):
|
||||
trans_word = trans_words[i + j][0]
|
||||
# Allow partial matches for longer words
|
||||
if ctx_word == trans_word:
|
||||
matches += 1
|
||||
elif len(ctx_word) >= 4 and (
|
||||
ctx_word in trans_word or trans_word in ctx_word
|
||||
):
|
||||
matches += 0.5
|
||||
|
||||
score = matches / len(context_words)
|
||||
if score > best_match_score and score >= 0.5:
|
||||
best_match_score = score
|
||||
best_match_time = trans_words[i][1]
|
||||
|
||||
return best_match_time
|
||||
|
||||
|
||||
def generate_chapters(
|
||||
manuscript_text: str,
|
||||
slides: dict[str, SlideDefinition],
|
||||
marker_timings: list, # List of MarkerTiming from transformer
|
||||
min_chapter_duration: float = 30.0,
|
||||
) -> list[ChapterMarker]:
|
||||
"""
|
||||
Generate chapter markers from slide timings.
|
||||
|
||||
Args:
|
||||
manuscript_text: The manuscript content
|
||||
slides: Slide definitions
|
||||
marker_timings: Aligned marker timings from the transformer
|
||||
min_chapter_duration: Minimum seconds between chapters (merges short ones)
|
||||
|
||||
Returns:
|
||||
List of ChapterMarker objects
|
||||
"""
|
||||
chapters = []
|
||||
|
||||
# Build timing lookup
|
||||
timing_lookup = {
|
||||
t.marker_id: t.timestamp for t in marker_timings if t.timestamp >= 0
|
||||
}
|
||||
|
||||
# Process slides in order
|
||||
slide_ids = sorted(
|
||||
[s for s in slides.keys() if s.startswith("S")],
|
||||
key=lambda x: int(x[1:]) if x[1:].isdigit() else 0,
|
||||
)
|
||||
|
||||
for slide_id in slide_ids:
|
||||
if slide_id not in timing_lookup:
|
||||
continue
|
||||
timestamp = timing_lookup[slide_id]
|
||||
title = _extract_chapter_title(manuscript_text, slide_id, slides)
|
||||
if chapters and (timestamp - chapters[-1].timestamp) < min_chapter_duration:
|
||||
continue # Skip this chapter, previous one covers it
|
||||
|
||||
chapters.append(
|
||||
ChapterMarker(
|
||||
slide_id=slide_id,
|
||||
timestamp=timestamp,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
# Ensure first chapter starts at 0:00
|
||||
if chapters and chapters[0].timestamp > 0:
|
||||
chapters[0] = ChapterMarker(
|
||||
slide_id=chapters[0].slide_id,
|
||||
timestamp=0.0,
|
||||
title=chapters[0].title,
|
||||
)
|
||||
|
||||
return chapters
|
||||
|
||||
|
||||
def collect_attributions(
|
||||
videos: dict[str, VideoSource],
|
||||
video_events: list = None,
|
||||
) -> list[tuple[str, Attribution]]:
|
||||
"""
|
||||
Collect all video attributions.
|
||||
|
||||
Returns list of (video_id, Attribution) tuples for videos that have attribution.
|
||||
Only includes videos that are actually used in the project (via video_events)
|
||||
or videos from shared assets that have attribution.
|
||||
"""
|
||||
attributions = []
|
||||
|
||||
# Get set of used video IDs from events
|
||||
used_video_ids = set()
|
||||
if video_events:
|
||||
for event in video_events:
|
||||
used_video_ids.add(event.video_id)
|
||||
|
||||
for video_id, video_source in videos.items():
|
||||
if video_source.attribution:
|
||||
# Include if used in video or if it's a shared asset
|
||||
if video_id in used_video_ids or video_source.is_shared:
|
||||
attributions.append((video_id, video_source.attribution))
|
||||
|
||||
return attributions
|
||||
|
||||
|
||||
def generate_description(
|
||||
config: ProjectConfig,
|
||||
manuscript_text: str,
|
||||
slides: dict[str, SlideDefinition],
|
||||
videos: dict[str, VideoSource],
|
||||
marker_timings: list,
|
||||
transcription: list[TranscribedWord] = None,
|
||||
video_events: list = None,
|
||||
citations: list[Citation] = None,
|
||||
include_chapters: bool = True,
|
||||
include_citations: bool = True,
|
||||
include_attributions: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Generate complete YouTube description.
|
||||
|
||||
Combines:
|
||||
- Video description from project.json
|
||||
- Chapter markers (optional)
|
||||
- Citations from manuscript (optional)
|
||||
- Stock footage attributions (optional)
|
||||
- Footer from project.json
|
||||
|
||||
Returns formatted description text.
|
||||
"""
|
||||
sections = []
|
||||
|
||||
# 1. Video description
|
||||
if config.description:
|
||||
sections.append(config.description.strip())
|
||||
|
||||
# 2. Chapters
|
||||
if include_chapters:
|
||||
chapters = generate_chapters(manuscript_text, slides, marker_timings)
|
||||
if chapters:
|
||||
chapter_lines = ["CHAPTERS", ""]
|
||||
for ch in chapters:
|
||||
chapter_lines.append(f"{_format_timestamp(ch.timestamp)} {ch.title}")
|
||||
sections.append("\n".join(chapter_lines))
|
||||
|
||||
# 3. Citations/References
|
||||
if include_citations:
|
||||
citations = citations or []
|
||||
if citations and transcription:
|
||||
# Align citations to get timestamps
|
||||
for citation in citations:
|
||||
citation.timestamp = _align_citation_to_transcription(
|
||||
citation, transcription, manuscript_text
|
||||
)
|
||||
|
||||
if citations:
|
||||
ref_lines = ["REFERENCES", ""]
|
||||
for citation in citations:
|
||||
if citation.timestamp >= 0:
|
||||
ref_lines.append(
|
||||
f"{_format_timestamp(citation.timestamp)} - {citation.reference}"
|
||||
)
|
||||
else:
|
||||
ref_lines.append(f"- {citation.reference}")
|
||||
sections.append("\n".join(ref_lines))
|
||||
|
||||
# 4. Stock footage attributions
|
||||
if include_attributions:
|
||||
attributions = collect_attributions(videos, video_events)
|
||||
if attributions:
|
||||
attr_lines = ["STOCK FOOTAGE", ""]
|
||||
for video_id, attr in attributions:
|
||||
# Format: "Description by Creator via Source: URL"
|
||||
line = f"{video_id.replace('_', ' ').title()} by {attr.creator} via {attr.source.title()}"
|
||||
if attr.url:
|
||||
line += f": {attr.url}"
|
||||
attr_lines.append(line)
|
||||
sections.append("\n".join(attr_lines))
|
||||
|
||||
# 5. Footer
|
||||
if config.footer:
|
||||
sections.append(config.footer.strip())
|
||||
|
||||
# Join sections with double newlines
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def write_description_file(
|
||||
output_path: Path,
|
||||
config: ProjectConfig,
|
||||
manuscript_text: str,
|
||||
slides: dict[str, SlideDefinition],
|
||||
videos: dict[str, VideoSource],
|
||||
marker_timings: list,
|
||||
transcription: list[TranscribedWord] = None,
|
||||
video_events: list = None,
|
||||
citations: list[Citation] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate and write YouTube description to file.
|
||||
|
||||
Args:
|
||||
output_path: Path to write description (e.g., out/description_youtube.txt)
|
||||
config: Project configuration
|
||||
manuscript_text: Manuscript content
|
||||
slides: Slide definitions
|
||||
videos: Video definitions
|
||||
marker_timings: Aligned marker timings
|
||||
transcription: Word-level transcription (optional, for citation timestamps)
|
||||
video_events: Video events from render plan (optional, for attribution filtering)
|
||||
citations: Pre-extracted citations (optional, loaded from citations.json)
|
||||
|
||||
Returns:
|
||||
The generated description text
|
||||
"""
|
||||
description = generate_description(
|
||||
config=config,
|
||||
manuscript_text=manuscript_text,
|
||||
slides=slides,
|
||||
videos=videos,
|
||||
marker_timings=marker_timings,
|
||||
transcription=transcription,
|
||||
video_events=video_events,
|
||||
citations=citations,
|
||||
)
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write description
|
||||
output_path.write_text(description, encoding="utf-8")
|
||||
|
||||
return description
|
||||
+15
-3
@@ -7,12 +7,14 @@ from typing import Optional
|
||||
|
||||
class GnommoError(Exception):
|
||||
"""Base exception for all GnommoEditor errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""A single validation issue with location context."""
|
||||
|
||||
message: str
|
||||
file: Optional[Path] = None
|
||||
line: Optional[int] = None
|
||||
@@ -30,7 +32,9 @@ class ValidationIssue:
|
||||
class ParseError(GnommoError):
|
||||
"""Error during parsing of input files."""
|
||||
|
||||
def __init__(self, message: str, file: Optional[Path] = None, line: Optional[int] = None):
|
||||
def __init__(
|
||||
self, message: str, file: Optional[Path] = None, line: Optional[int] = None
|
||||
):
|
||||
self.issue = ValidationIssue(message, file, line)
|
||||
super().__init__(str(self.issue))
|
||||
|
||||
@@ -48,7 +52,9 @@ class ValidationError(GnommoError):
|
||||
class RenderError(GnommoError):
|
||||
"""Error during rendering stage."""
|
||||
|
||||
def __init__(self, message: str, command: Optional[str] = None, stderr: Optional[str] = None):
|
||||
def __init__(
|
||||
self, message: str, command: Optional[str] = None, stderr: Optional[str] = None
|
||||
):
|
||||
self.command = command
|
||||
self.stderr = stderr
|
||||
full_message = message
|
||||
@@ -62,7 +68,13 @@ class RenderError(GnommoError):
|
||||
class PreprocessError(GnommoError):
|
||||
"""Error during preprocessing stage."""
|
||||
|
||||
def __init__(self, message: str, filter_type: Optional[str] = None, command: Optional[str] = None, stderr: Optional[str] = None):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
filter_type: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
stderr: Optional[str] = None,
|
||||
):
|
||||
self.filter_type = filter_type
|
||||
self.command = command
|
||||
self.stderr = stderr
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
ObjC.import('stdlib');
|
||||
ObjC.import('Foundation');
|
||||
|
||||
function toAbsolutePath(p) {
|
||||
// Expand ~ and make absolute relative to current working directory
|
||||
var s = $(String(p)).stringByExpandingTildeInPath;
|
||||
if (!s.isAbsolutePath) {
|
||||
var cwd = $.NSFileManager.defaultManager.currentDirectoryPath;
|
||||
s = cwd.stringByAppendingPathComponent(s);
|
||||
}
|
||||
return s.stringByStandardizingPath.js;
|
||||
}
|
||||
|
||||
function fileExists(p) {
|
||||
return $.NSFileManager.defaultManager.fileExistsAtPath($(p));
|
||||
}
|
||||
|
||||
function getNotes(slide) {
|
||||
try { return slide.presenterNotes(); } catch (e) {}
|
||||
try { return slide.speakerNotes(); } catch (e) {}
|
||||
return "";
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
if (!argv || argv.length < 1) throw new Error("Usage: script.js <file.key> [slides_output_dir]");
|
||||
var abs = toAbsolutePath(argv[0]);
|
||||
var slidesDir = argv.length >= 2 ? toAbsolutePath(argv[1]) : null;
|
||||
|
||||
if (!fileExists(abs)) {
|
||||
throw new Error("File not found: " + abs);
|
||||
}
|
||||
|
||||
var Keynote = Application('Keynote');
|
||||
Keynote.activate();
|
||||
|
||||
// Keynote is happiest when given a Path() made from an absolute POSIX path
|
||||
var doc = Keynote.open(Path(abs));
|
||||
|
||||
// Export slides as PNG if output directory is provided
|
||||
if (slidesDir) {
|
||||
// Create directory if it doesn't exist
|
||||
var fm = $.NSFileManager.defaultManager;
|
||||
if (!fm.fileExistsAtPath($(slidesDir))) {
|
||||
fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(
|
||||
$(slidesDir), true, $(), $()
|
||||
);
|
||||
}
|
||||
|
||||
// Export using AppleScript (more reliable than JXA for Keynote export)
|
||||
var app = Application.currentApplication();
|
||||
app.includeStandardAdditions = true;
|
||||
|
||||
// Build osascript command with proper escaping
|
||||
// Using multiple -e flags to avoid quoting issues
|
||||
var cmd = '/usr/bin/osascript' +
|
||||
' -e \'tell application "Keynote"\'' +
|
||||
' -e \'export front document to POSIX file "' + slidesDir + '" as slide images with properties {image format:PNG}\'' +
|
||||
' -e \'end tell\'';
|
||||
|
||||
app.doShellScript(cmd);
|
||||
}
|
||||
|
||||
var slides = doc.slides();
|
||||
var out = [];
|
||||
for (var i = 0; i < slides.length; i++) {
|
||||
out.push({
|
||||
slide_index: i + 1,
|
||||
notes: String(getNotes(slides[i]) || "")
|
||||
});
|
||||
}
|
||||
|
||||
doc.close({ saving: 'no' });
|
||||
return JSON.stringify(out, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract presenter notes from a Keynote .key file.
|
||||
|
||||
Usage:
|
||||
python extract_keynote_notes.py path/to/deck.key --out notes.json
|
||||
|
||||
Notes:
|
||||
- A .key file is a package (zip). The presenter notes live in an XML-ish file
|
||||
typically called index.apxl inside the package.
|
||||
- This script tries to be robust across minor format changes by searching for
|
||||
likely note fields.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from gnommo.parser import _read_json
|
||||
|
||||
|
||||
def write_manuscript(data: Path, out_path: Path):
|
||||
data = _read_json(data.read_text(encoding="utf-8"))
|
||||
lines = []
|
||||
i = 0
|
||||
for item in data:
|
||||
print(f"Writing notes for slide {i} to file")
|
||||
idx = item.get("slide_index")
|
||||
notes = (item.get("notes") or "").rstrip()
|
||||
|
||||
lines.append(f"[S{idx}]")
|
||||
lines.append(notes)
|
||||
lines.append("") # blank line between slides
|
||||
i += 1
|
||||
|
||||
out_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
|
||||
def main():
|
||||
keynote_file = Path("video1/video1.key").expanduser().resolve()
|
||||
if not keynote_file.exists():
|
||||
raise FileNotFoundError(f"Keynote file not found: {keynote_file}")
|
||||
|
||||
script_file = Path("gnommo/extract_keynote_notes.js").expanduser().resolve()
|
||||
if not script_file.exists():
|
||||
raise FileNotFoundError(f"Extractor script not found: {script_file}")
|
||||
|
||||
presenter_notes_json_file = Path("video1/manuscript.json").expanduser().resolve()
|
||||
|
||||
# Run JXA extractor
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"osascript",
|
||||
"-l",
|
||||
"JavaScript",
|
||||
str(script_file),
|
||||
str(keynote_file),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
"Failed to extract presenter notes:\n"
|
||||
f"STDERR:\n{proc.stderr}\n"
|
||||
f"STDOUT:\n{proc.stdout}"
|
||||
)
|
||||
|
||||
# Write JSON output
|
||||
presenter_notes_json_file.write_text(proc.stdout, encoding="utf-8")
|
||||
|
||||
if not presenter_notes_json_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Failed to extract presenter notes to {presenter_notes_json_file}"
|
||||
)
|
||||
|
||||
# Convert JSON → manuscript.txt
|
||||
write_manuscript(
|
||||
presenter_notes_json_file, out_path=keynote_file.parent / "manuscript.txt"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Hand off a finished video to the gnommoweb server.
|
||||
|
||||
Works for any gnommo project type: parent videos and shorts alike.
|
||||
|
||||
Usage:
|
||||
gnommo handoff -p video1
|
||||
gnommo handoff -p short_pixelated_universe
|
||||
gnommo handoff -p video1 --file /path/to/render.mp4
|
||||
|
||||
Reads project.json for the 'output_video' field (path relative to the
|
||||
project directory). Override with --file.
|
||||
|
||||
On success:
|
||||
- Uploads the video to MinIO via POST /api/projects/:handle/handoff
|
||||
- For shorts: server auto-advances status to 'processed'
|
||||
- Bumps video_version on every upload
|
||||
- Updates .gnommo_sync.json with new video_version
|
||||
|
||||
Configuration (from .env or environment):
|
||||
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
|
||||
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print(
|
||||
"Error: 'requests' package is required. Run: pip install requests",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
SYNC_FILE_LOCAL = ".gnommo_sync.json"
|
||||
SYNC_FILE_PROD = ".gnommo_sync.prod.json"
|
||||
|
||||
|
||||
def _sync_file(prod: bool) -> str:
|
||||
return SYNC_FILE_PROD if prod else SYNC_FILE_LOCAL
|
||||
|
||||
|
||||
def _load_env_file():
|
||||
env_path = Path(__file__).parent.parent / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
with open(env_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _read_sync(project_path: Path, prod: bool = False) -> dict:
|
||||
sync_file = project_path / _sync_file(prod)
|
||||
if sync_file.exists():
|
||||
with open(sync_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_sync(project_path: Path, data: dict, prod: bool = False):
|
||||
with open(project_path / _sync_file(prod), "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def cmd_handoff(
|
||||
project_path: Path,
|
||||
verbose: bool = False,
|
||||
file_override: str | None = None,
|
||||
prod: bool = False,
|
||||
res: str = "full",
|
||||
) -> int:
|
||||
_load_env_file()
|
||||
|
||||
if prod:
|
||||
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||
if not api_url:
|
||||
print("Error: GNOMMOWEB_PROD_URL is not set.", file=sys.stderr)
|
||||
return 1
|
||||
if not api_key:
|
||||
print("Error: GNOMMOWEB_PROD_API_KEY is not set.", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||
if not api_url:
|
||||
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
|
||||
return 1
|
||||
if not api_key:
|
||||
print("Error: GNOMMOWEB_API_KEY is not set.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if verbose:
|
||||
target = "production" if prod else "local"
|
||||
print(f" → {target}: {api_url}")
|
||||
|
||||
project_file = project_path / "project.json"
|
||||
if not project_file.exists():
|
||||
print(f"Error: {project_file} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(project_file) as f:
|
||||
project = json.load(f)
|
||||
|
||||
project_id = project.get("id")
|
||||
if not project_id:
|
||||
print("Error: project.json must have an 'id' field.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ── Resolve video file ─────────────────────────────────────────────────────
|
||||
if file_override:
|
||||
video_path = Path(file_override)
|
||||
else:
|
||||
output_filename = (
|
||||
project.get("output") or Path(project.get("output_video", "")).name
|
||||
)
|
||||
if not output_filename:
|
||||
print(
|
||||
"Error: no 'output' field in project.json and no --file provided.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if res != "full":
|
||||
video_path = project_path / "out" / res / output_filename
|
||||
else:
|
||||
video_path = project_path / "out" / output_filename
|
||||
|
||||
if not video_path.exists():
|
||||
print(f"Error: video file not found: {video_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
file_size_mb = video_path.stat().st_size / (1024 * 1024)
|
||||
if verbose:
|
||||
print(f"Handing off {project_id} → {api_url}")
|
||||
print(f" File: {video_path} ({file_size_mb:.1f} MB)")
|
||||
|
||||
# ── Upload ─────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
with open(video_path, "rb") as vf:
|
||||
r = requests.post(
|
||||
f"{api_url}/api/projects/{project_id}/handoff",
|
||||
files={"video": (video_path.name, vf, _mime_type(video_path))},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=None, # large files may take a while
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"✗ Could not connect to {api_url}")
|
||||
return 1
|
||||
|
||||
if not r.ok:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = r.text[:500]
|
||||
print(f"✗ Server returned {r.status_code}: {body}")
|
||||
return 1
|
||||
|
||||
result = r.json()
|
||||
video_version = result.get("video_version", "?")
|
||||
video_url = result.get("video_url", "")
|
||||
|
||||
# ── Write sync state ───────────────────────────────────────────────────────
|
||||
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||
existing_sync = _read_sync(project_path, prod)
|
||||
_write_sync(
|
||||
project_path,
|
||||
{
|
||||
**existing_sync,
|
||||
"last_handoff_at": now_iso,
|
||||
"video_version": video_version,
|
||||
"server_updated_at": result.get("asset", {}).get(
|
||||
"updated_at", existing_sync.get("server_updated_at")
|
||||
),
|
||||
},
|
||||
prod,
|
||||
)
|
||||
|
||||
print(f"✓ {project_id} → v{video_version} [processed]")
|
||||
if video_url:
|
||||
print(f" {video_url}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _mime_type(path: Path) -> str:
|
||||
ext = path.suffix.lower()
|
||||
return {
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
}.get(ext, "application/octet-stream")
|
||||
+446
-37
@@ -2,35 +2,78 @@
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class TalkingHeadConfig:
|
||||
"""Configuration for talking head video positioning."""
|
||||
x: int
|
||||
y: int
|
||||
target_height: int # in pixels, or -1 for percentage-based
|
||||
target_height_percent: float = 0.0 # percentage (0.0-1.0) if target_height is -1
|
||||
file: Optional[str] = None # Path to video or metadata JSON file
|
||||
class CutoutDefinition:
|
||||
"""Definition of a named zone for placing video content.
|
||||
|
||||
All positioning values support both pixels (int) and percentages (str like "50%").
|
||||
Percentage values are stored as floats (0.0-1.0) with pixel value set to -1.
|
||||
|
||||
Videos placed in cutouts are cropped to fit the cutout dimensions.
|
||||
"""
|
||||
|
||||
x: int # in pixels, or -1 for percentage-based
|
||||
y: int # in pixels, or -1 for percentage-based
|
||||
height: int # in pixels, or -1 for percentage-based
|
||||
width: int = (
|
||||
-1
|
||||
) # in pixels, or -1 for percentage-based (defaults to height for square)
|
||||
x_percent: float = 0.0 # percentage (0.0-1.0) if x is -1
|
||||
y_percent: float = 0.0 # percentage (0.0-1.0) if y is -1
|
||||
height_percent: float = 0.0 # percentage (0.0-1.0) if height is -1
|
||||
width_percent: float = 0.0 # percentage (0.0-1.0) if width is -1
|
||||
|
||||
|
||||
# Backwards compatibility alias
|
||||
TalkingHeadConfig = CutoutDefinition
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""Global project configuration from project.json."""
|
||||
|
||||
resolution: tuple[int, int]
|
||||
fps: int
|
||||
talking_head: TalkingHeadConfig
|
||||
default_slide_type: str
|
||||
cutouts: dict[str, CutoutDefinition] = field(
|
||||
default_factory=dict
|
||||
) # Named zones for video placement
|
||||
default_filters: dict[str, list[dict]] = field(
|
||||
default_factory=dict
|
||||
) # Named filter presets that can be referenced in videos.json
|
||||
background: str = "" # Background image or video path (in shared_assets/)
|
||||
background_video: str = "" # Deprecated: use background instead
|
||||
slides_path: str = "slides.json" # path to slides.json relative to project
|
||||
videos_path: str = "videos.json" # path to videos.json relative to project
|
||||
audio_path: str = "audio.json" # path to audio.json relative to project
|
||||
audio_source: Optional[str] = None # defaults to talking head
|
||||
main_video: Optional[
|
||||
Union[str, list]
|
||||
] = None # ID(s) of main video(s) - array for multi-segment narration
|
||||
gnommo_scratch: Optional[
|
||||
str
|
||||
] = None # directory for intermediate files (e.g., external SSD)
|
||||
default_begin: float = 0.0 # Trim this many seconds from the start of each segment (if no explicit begin/skip)
|
||||
default_end_trim: float = 0.0 # Trim this many seconds from the end of each segment (if no explicit end/take)
|
||||
# Outro sequence - plays after narration ends (not marker-triggered)
|
||||
outro: list[str] = field(
|
||||
default_factory=list
|
||||
) # List of video IDs to play in sequence after narration
|
||||
# YouTube description fields
|
||||
description: str = "" # Video description text for YouTube
|
||||
footer: str = "" # Footer text (social links, subscribe CTA, etc.)
|
||||
output_video: str = (
|
||||
"" # Output filename (e.g. "DISC_INT3.mp4"); placed in out/ or out/<res>/
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlideDefinition:
|
||||
"""Definition of a single slide from slides.json."""
|
||||
|
||||
image: str
|
||||
type: str # "fullscreen" | "square"
|
||||
|
||||
@@ -38,25 +81,226 @@ class SlideDefinition:
|
||||
@dataclass
|
||||
class ChromaKeyConfig:
|
||||
"""Configuration for chroma key (green screen) filter."""
|
||||
|
||||
color: tuple[int, int, int] = (0, 255, 0) # RGB color to key out
|
||||
similarity: float = 0.15 # Color similarity threshold (0.0-1.0)
|
||||
blend: float = 0.1 # Edge blend/feathering (0.0-1.0)
|
||||
spill: float = 0.0 # Spill suppression amount (0.0-1.0)
|
||||
similarity: float = (
|
||||
0.4 # Color similarity threshold (0.0-1.0), higher = more aggressive
|
||||
)
|
||||
blend: float = 0.08 # Edge blend/feathering (0.0-1.0), lower = tighter edges
|
||||
spill: float = 0.1 # Spill suppression amount (0.0-1.0)
|
||||
edge_erode: int = 0 # Pixels to erode from alpha edge (0-5), removes green fringe
|
||||
# Color protection - restore opacity for colors that shouldn't be keyed
|
||||
protect_color: tuple[int, int, int] = None # RGB color to protect from keying
|
||||
protect_tolerance: float = (
|
||||
0.15 # How much variation from protect_color to allow (0-1)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GnommoKeyConfig:
|
||||
"""Configuration for gnommokey filter - Keylight-style color-difference keyer.
|
||||
|
||||
Uses YCbCr color-difference keying (like Keylight/Ultimatte) instead of
|
||||
simple Euclidean distance. This handles lighting variation much better
|
||||
than basic chromakey.
|
||||
"""
|
||||
|
||||
# Screen color (the green/blue screen color to key out)
|
||||
screen_color: tuple[int, int, int] = (0, 177, 64) # RGB of the screen
|
||||
|
||||
# Key extraction strength (default 100, higher = more aggressive)
|
||||
# Values 80-150 are typical. Maps to Keylight's Screen Gain.
|
||||
screen_gain: float = 100.0
|
||||
|
||||
# Balance between chrominance and luminance in key calculation (0-100)
|
||||
# 0 = pure color-difference, 100 = luminance weighted
|
||||
# Maps to Keylight's Screen Balance.
|
||||
screen_balance: float = 50.0
|
||||
|
||||
# Alpha/matte adjustments
|
||||
clip_black: float = 0.0 # Crush blacks (0-100). Higher = more transparent areas
|
||||
clip_white: float = 100.0 # Crush whites (0-100). Lower = more opaque areas
|
||||
|
||||
# Despill: color to shift green spill toward (RGB)
|
||||
# Typical values: skin tone [217, 200, 180] or neutral [200, 200, 200]
|
||||
despill_bias: tuple[int, int, int] = None
|
||||
|
||||
# How aggressively to apply despill (0-1)
|
||||
despill_strength: float = 0.5
|
||||
|
||||
# Alpha bias: influences edge treatment (RGB)
|
||||
# Can help with edge color contamination
|
||||
alpha_bias: tuple[int, int, int] = None
|
||||
|
||||
# Edge refinement
|
||||
edge_erode: int = 0 # Pixels to erode from alpha edge (0-5)
|
||||
edge_soften: float = 0.0 # Blur the alpha edge (0-5 pixels)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorGradeConfig:
|
||||
"""Configuration for color grading filter.
|
||||
|
||||
Applies color balance, contrast curves, and saturation adjustments
|
||||
while preserving the alpha channel.
|
||||
"""
|
||||
|
||||
# Color balance (range: -1.0 to 1.0, 0 = no change)
|
||||
# Midtones
|
||||
rm: float = 0.0 # Red midtones adjustment
|
||||
gm: float = 0.0 # Green midtones adjustment
|
||||
bm: float = 0.0 # Blue midtones adjustment
|
||||
# Highlights
|
||||
rh: float = 0.0 # Red highlights adjustment
|
||||
gh: float = 0.0 # Green highlights adjustment
|
||||
bh: float = 0.0 # Blue highlights adjustment
|
||||
# Shadows
|
||||
rs: float = 0.0 # Red shadows adjustment
|
||||
gs: float = 0.0 # Green shadows adjustment
|
||||
bs: float = 0.0 # Blue shadows adjustment
|
||||
|
||||
# Curves preset (none, lighter, darker, increase_contrast, medium_contrast, etc.)
|
||||
curves_preset: str = "none"
|
||||
|
||||
# EQ adjustments
|
||||
contrast: float = 1.0 # Contrast multiplier (0.0-2.0, 1.0 = no change)
|
||||
brightness: float = 0.0 # Brightness adjustment (-1.0 to 1.0, 0 = no change)
|
||||
saturation: float = 1.0 # Saturation multiplier (0.0-3.0, 1.0 = no change)
|
||||
|
||||
# Custom curves for lift/gamma/gain control
|
||||
# Format: "0/0 0.5/0.56 1/1" means (input/output) control points
|
||||
curves_r: str = "" # Red channel curve
|
||||
curves_g: str = "" # Green channel curve
|
||||
curves_b: str = "" # Blue channel curve
|
||||
curves_master: str = "" # Master (luminance) curve
|
||||
|
||||
|
||||
@dataclass
|
||||
class EQBand:
|
||||
"""A single parametric EQ band."""
|
||||
|
||||
freq: float # Center frequency in Hz
|
||||
gain: float # Gain in dB (negative = cut, positive = boost)
|
||||
q: float = 1.0 # Q factor (bandwidth), higher = narrower
|
||||
type: str = "peak" # "peak", "lowshelf", or "highshelf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioNormalizeConfig:
|
||||
"""Configuration for audio normalization filter.
|
||||
|
||||
Applies noise reduction, compression, and loudness normalization
|
||||
to improve audio quality and consistency.
|
||||
"""
|
||||
|
||||
# Parametric EQ bands (applied before other processing)
|
||||
eq_bands: list[EQBand] = field(default_factory=list)
|
||||
|
||||
# High-pass filter (remove room rumble)
|
||||
highpass: float = (
|
||||
0.0 # High-pass frequency in Hz (0 = disabled, try 80-120 for voice)
|
||||
)
|
||||
|
||||
# Low-pass filter (remove harsh highs)
|
||||
lowpass: float = (
|
||||
0.0 # Low-pass frequency in Hz (0 = disabled, try 12000-16000 if needed)
|
||||
)
|
||||
|
||||
# Room resonance EQ cut (reduce muddy room buildup)
|
||||
room_eq: bool = False # Enable room resonance cut
|
||||
room_eq_freq: float = 300.0 # Center frequency for room cut (Hz, typically 200-400)
|
||||
room_eq_gain: float = -4.0 # Gain in dB (negative = cut)
|
||||
room_eq_width: float = 1.5 # Q/bandwidth (higher = narrower cut)
|
||||
|
||||
# Noise gate (reduce reverb tails during pauses)
|
||||
gate: bool = False # Enable noise gate
|
||||
gate_threshold: float = -35.0 # Threshold in dB (signal below this gets attenuated)
|
||||
gate_range: float = -20.0 # Attenuation amount in dB when gate is closed
|
||||
gate_attack: float = 10.0 # Attack time in ms
|
||||
gate_release: float = 150.0 # Release time in ms
|
||||
|
||||
# Neural de-reverb (arnndn filter - very effective but needs model file)
|
||||
dereverb_model: str = "" # Path to RNNoise model file (empty = disabled)
|
||||
dereverb_mix: float = (
|
||||
0.8 # Mix ratio 0.0-1.0 (1.0 = full effect, 0.8 = preserve some natural room)
|
||||
)
|
||||
|
||||
# Noise reduction (afftdn filter)
|
||||
denoise: bool = True # Enable noise reduction
|
||||
noise_floor: float = (
|
||||
-25.0
|
||||
) # Noise floor in dB (default -25, lower = more aggressive)
|
||||
|
||||
# Compression (acompressor filter)
|
||||
compress: bool = True # Enable dynamic range compression
|
||||
threshold: float = -20.0 # Compression threshold in dB
|
||||
ratio: float = 4.0 # Compression ratio (4:1 default)
|
||||
attack: float = 5.0 # Attack time in ms
|
||||
release: float = 50.0 # Release time in ms
|
||||
makeup: float = 2.0 # Makeup gain in dB
|
||||
|
||||
# Loudness normalization (loudnorm filter - EBU R128)
|
||||
normalize: bool = True # Enable loudness normalization
|
||||
target_lufs: float = (
|
||||
-16.0
|
||||
) # Target integrated loudness (YouTube recommends -14 to -16)
|
||||
target_lra: float = 11.0 # Target loudness range
|
||||
target_tp: float = -1.5 # Target true peak in dB
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterConfig:
|
||||
"""Base configuration for a preprocessing filter."""
|
||||
|
||||
type: str
|
||||
# Type-specific config stored in subclasses or as dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attribution:
|
||||
"""Attribution information for stock footage (e.g., Pexels)."""
|
||||
|
||||
source: str # Source platform (e.g., "pexels", "pixabay", "unsplash")
|
||||
creator: str # Creator/photographer name
|
||||
url: Optional[str] = None # URL to the original content
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoSource:
|
||||
"""Video source definition from videos.json."""
|
||||
file: str
|
||||
preprocess: list[dict] = field(default_factory=list) # List of filter config dicts
|
||||
output_file: Optional[str] = None # Path to preprocessed output (if any)
|
||||
|
||||
source_file: str # Source video filename (relative to videos.json location or shared_assets/)
|
||||
filter: list[dict] = field(default_factory=list) # List of filter config dicts
|
||||
output_file: Optional[
|
||||
str
|
||||
] = None # Path to preprocessed output (relative to videos.json)
|
||||
take: Optional[
|
||||
float
|
||||
] = None # Max duration to play (seconds). Default: until next slide or end of clip
|
||||
skip: float = 0.0 # Skip this many seconds at start of video (seek point)
|
||||
zoom: float = (
|
||||
1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge)
|
||||
)
|
||||
cutout: Optional[
|
||||
str
|
||||
] = None # Name of cutout to place video in (from project.json cutouts)
|
||||
always_visible: bool = False # If True, video is always shown (like talking head)
|
||||
is_shared: bool = False # If True, source_file is relative to shared_assets/
|
||||
pause_narration: float = (
|
||||
0.0 # Seconds to pause narration during this video (0 = no pause)
|
||||
)
|
||||
attribution: Optional[Attribution] = None # Attribution for stock footage
|
||||
use_audio_channels: str = (
|
||||
"both" # Audio channel selection: "both", "left", or "right"
|
||||
)
|
||||
defer_loudnorm: bool = (
|
||||
False # If True, skip loudnorm during preprocessing (apply after concatenation)
|
||||
)
|
||||
volume: float = 1.0 # Volume multiplier (1.0=full, >1.0=boost, <1.0=reduce)
|
||||
layer: str = "above" # "above" = renders on top of slides; "below" = behind slides
|
||||
duration: Optional[float] = None # Pre-probed file duration in seconds (set by import)
|
||||
has_audio: Optional[bool] = None # Pre-detected audio presence (set by import)
|
||||
end_on: Optional[str] = None # When video event ends: "next_slide" | "end" | "take" (None = marker-type default)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,50 +311,215 @@ class VideoMetadata:
|
||||
This allows defining preprocessing steps separately from videos.json,
|
||||
enabling per-video preprocessing configuration.
|
||||
"""
|
||||
|
||||
source_file: str # Original source video file
|
||||
preprocess: list[dict] = field(default_factory=list) # Preprocessing filters
|
||||
output: Optional[dict] = None # Output config {"file": "...", "colorspace": "...", "alpha": "..."}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimedWord:
|
||||
"""A word or marker with its timestamp from transcript.csv."""
|
||||
time: float
|
||||
word: str
|
||||
|
||||
@property
|
||||
def is_marker(self) -> bool:
|
||||
"""Check if this is a slide marker like [S1]."""
|
||||
return self.word.startswith("[") and self.word.endswith("]")
|
||||
|
||||
@property
|
||||
def marker_id(self) -> Optional[str]:
|
||||
"""Extract marker ID (e.g., 'S1' from '[S1]')."""
|
||||
if self.is_marker:
|
||||
return self.word[1:-1]
|
||||
return None
|
||||
output: Optional[
|
||||
dict
|
||||
] = None # Output config {"file": "...", "colorspace": "...", "alpha": "..."}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlideEvent:
|
||||
"""A resolved slide event with timing information."""
|
||||
|
||||
slide_id: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
slide_def: SlideDefinition
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioDefinition:
|
||||
"""Definition of an audio clip from audio.json."""
|
||||
|
||||
file: str # Audio filename (relative to audio.json location)
|
||||
volume: float = 1.0 # Volume multiplier (0.0-1.0)
|
||||
loop: bool = False # If True, loop for entire duration from trigger point
|
||||
overlap: Optional[float] = None # Crossfade overlap in seconds when looping
|
||||
ignore_pauses: bool = (
|
||||
False # If True, audio continues playing during narration pauses
|
||||
)
|
||||
duration: Optional[float] = None # Pre-probed duration in seconds (set by import)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Citation:
|
||||
"""A citation extracted from manuscript.txt [cite:...] markers."""
|
||||
|
||||
reference: str # The literal reference text after cite:
|
||||
marker_id: str # The full marker (e.g., "cite:Smith et al...")
|
||||
timestamp: float = -1.0 # Aligned timestamp (-1 if not aligned)
|
||||
context: str = "" # Text following the citation for alignment
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioEvent:
|
||||
"""A resolved audio event with timing information."""
|
||||
|
||||
audio_id: str
|
||||
start_time: float # When to start playing (marker time - offset)
|
||||
audio_def: AudioDefinition
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoEvent:
|
||||
"""A resolved video event with timing information."""
|
||||
|
||||
video_id: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
video_source: "VideoSource"
|
||||
cutout: "CutoutDefinition"
|
||||
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
|
||||
layer: str = "above" # "above" = on top of slides; "below" = behind slides
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraState:
|
||||
"""State of the virtual camera at a point in time.
|
||||
|
||||
The camera transforms the entire composed scene (background, slides, cutouts).
|
||||
This ensures all elements stay spatially synchronized when zooming/tilting.
|
||||
"""
|
||||
|
||||
zoom: float = 1.0 # 1.0 = 100%, 1.25 = 125%, etc.
|
||||
rotation: float = 0.0 # degrees, positive = clockwise
|
||||
pan_x: float = 0.0 # -1.0 to 1.0, percentage of frame width
|
||||
pan_y: float = 0.0 # -1.0 to 1.0, percentage of frame height
|
||||
focal_x: float = 0.5 # 0.0 to 1.0, zoom focal point X (0.5 = center)
|
||||
focal_y: float = 0.5 # 0.0 to 1.0, zoom focal point Y (0.5 = center)
|
||||
|
||||
def __post_init__(self):
|
||||
# Clamp values to reasonable ranges
|
||||
self.zoom = max(0.5, min(3.0, self.zoom))
|
||||
self.rotation = max(-45.0, min(45.0, self.rotation))
|
||||
self.pan_x = max(-1.0, min(1.0, self.pan_x))
|
||||
self.pan_y = max(-1.0, min(1.0, self.pan_y))
|
||||
self.focal_x = max(0.0, min(1.0, self.focal_x))
|
||||
self.focal_y = max(0.0, min(1.0, self.focal_y))
|
||||
|
||||
def is_default(self) -> bool:
|
||||
"""Check if this is the default camera state (no transform)."""
|
||||
return (
|
||||
self.zoom == 1.0
|
||||
and self.rotation == 0.0
|
||||
and self.pan_x == 0.0
|
||||
and self.pan_y == 0.0
|
||||
and self.focal_x == 0.5
|
||||
and self.focal_y == 0.5
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraEvent:
|
||||
"""A camera state change at a specific time.
|
||||
|
||||
Camera events can be instant (duration=0) or animated (duration>0).
|
||||
When animated, the camera smoothly transitions from its current state
|
||||
to the target state over the specified duration using the easing function.
|
||||
"""
|
||||
|
||||
time: float # timestamp in seconds
|
||||
target_state: CameraState
|
||||
duration: float = 0.2 # transition duration (0 = instant snap)
|
||||
easing: str = "ease-out" # linear, ease-in, ease-out, ease-in-out
|
||||
|
||||
|
||||
# Camera effect presets - map marker names to camera states
|
||||
# Effect strengths are intentionally subtle for professional look
|
||||
CAMERA_PRESETS: dict[str, CameraState] = {
|
||||
# Zoom levels (halved for subtlety)
|
||||
"Zoom0": CameraState(zoom=1.0),
|
||||
"Zoom1": CameraState(zoom=1.05),
|
||||
"Zoom2": CameraState(zoom=1.125),
|
||||
"Zoom3": CameraState(zoom=1.25),
|
||||
# Tilt/rotation (halved)
|
||||
"TiltLeft": CameraState(rotation=-7.5),
|
||||
"TiltRight": CameraState(rotation=7.5),
|
||||
"NoTilt": CameraState(), # Full reset to default state
|
||||
# Pan (halved)
|
||||
"PanLeft": CameraState(pan_x=-0.1),
|
||||
"PanRight": CameraState(pan_x=0.1),
|
||||
"PanUp": CameraState(pan_y=-0.075),
|
||||
"PanDown": CameraState(pan_y=0.075),
|
||||
"PanCenter": CameraState(pan_x=0.0, pan_y=0.0),
|
||||
# Reset all
|
||||
"Reset": CameraState(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class NarrationPause:
|
||||
"""A pause in the narration timeline for an interstitial video."""
|
||||
|
||||
output_time: float # When the pause starts in the OUTPUT timeline
|
||||
narration_time: float # Where we are in the NARRATION source when pause starts
|
||||
duration: float # How long the pause lasts
|
||||
video_id: str # The video that plays during the pause
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutroEvent:
|
||||
"""A video that plays as part of the outro sequence (after narration ends)."""
|
||||
|
||||
video_id: str
|
||||
start_time: float # When this outro video starts (in output timeline)
|
||||
end_time: float # When this outro video ends
|
||||
video_source: "VideoSource"
|
||||
cutout: Optional["CutoutDefinition"] = None # None = fullscreen
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderPlan:
|
||||
"""Complete plan for rendering the final video."""
|
||||
|
||||
project_path: Path
|
||||
config: ProjectConfig
|
||||
talking_head: VideoSource
|
||||
slide_events: list[SlideEvent]
|
||||
total_duration: float
|
||||
slides: dict[str, SlideDefinition]
|
||||
videos: dict[str, VideoSource] = field(default_factory=dict)
|
||||
video_events: list[VideoEvent] = field(
|
||||
default_factory=list
|
||||
) # Triggered video overlays
|
||||
narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = field(
|
||||
default_factory=list
|
||||
) # (video_id, source, cutout)
|
||||
slides_dir: Path = None # directory containing slide images
|
||||
talking_head_path: Path = None # Resolved path to actual video file
|
||||
videos_dir: Path = None # directory containing videos.json and video files
|
||||
audio_events: list[AudioEvent] = field(default_factory=list)
|
||||
audio: dict[str, AudioDefinition] = field(default_factory=dict)
|
||||
audio_dir: Path = None # directory containing audio.json and audio files
|
||||
camera_events: list[CameraEvent] = field(
|
||||
default_factory=list
|
||||
) # Virtual camera keyframes
|
||||
# Partial rendering support
|
||||
time_offset: float = (
|
||||
0.0 # Offset subtracted from all timestamps (for partial render)
|
||||
)
|
||||
initial_camera_state: "CameraState" = (
|
||||
None # Camera state at render start (for partial render)
|
||||
)
|
||||
input_seek_time: float = 0.0 # Seek position for input videos (for partial render)
|
||||
# Shared assets support
|
||||
shared_assets_dir: Path = None # Directory containing shared assets (pexels, etc.)
|
||||
# Narration pause support
|
||||
narration_pauses: list[NarrationPause] = field(
|
||||
default_factory=list
|
||||
) # Gaps in narration for interstitial videos
|
||||
# Outro sequence (plays after narration ends)
|
||||
outro_events: list["OutroEvent"] = field(
|
||||
default_factory=list
|
||||
) # Videos that play after narration ends
|
||||
narration_end_time: float = 0.0 # When narration ends (before outro starts)
|
||||
# GnommoCache support
|
||||
cached_files: set = field(
|
||||
default_factory=set
|
||||
) # Video IDs loaded from external cache (show 📁 indicator)
|
||||
output_path: Optional[
|
||||
Path
|
||||
] = None # Final output file path (set after plan is built)
|
||||
|
||||
|
||||
# Slide layout configurations (hardcoded for POC)
|
||||
|
||||
+465
-77
@@ -1,28 +1,42 @@
|
||||
"""Extract stage: parse all input files."""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .cache import resolve_with_cache
|
||||
from .errors import ParseError
|
||||
from .models import (
|
||||
Attribution,
|
||||
AudioDefinition,
|
||||
Citation,
|
||||
CutoutDefinition,
|
||||
ProjectConfig,
|
||||
SlideDefinition,
|
||||
TalkingHeadConfig,
|
||||
TimedWord,
|
||||
VideoMetadata,
|
||||
VideoSource,
|
||||
)
|
||||
|
||||
|
||||
def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int, str]]]:
|
||||
def _read_json(path: Path) -> Any:
|
||||
"""Read and parse a JSON file, treating an empty file as {}."""
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
return json.loads(text) if text else {}
|
||||
|
||||
|
||||
def parse_manuscript(
|
||||
project_path: Path,
|
||||
) -> tuple[str, list[str], list[tuple[int, str]], list[Citation]]:
|
||||
"""
|
||||
Parse manuscript.txt and extract text content and slide markers.
|
||||
|
||||
Strips [cite:...] and [marker:...] markers from the returned text so they
|
||||
never pollute alignment contexts. Citations are extracted and returned
|
||||
separately. Marker cues are personal recording notes and are simply discarded.
|
||||
|
||||
Returns:
|
||||
Tuple of (full text, list of marker IDs found, list of malformed markers as (line_num, text))
|
||||
Tuple of (full text, list of marker IDs found, list of malformed markers, list of citations)
|
||||
"""
|
||||
manuscript_path = project_path / "manuscript.txt"
|
||||
|
||||
@@ -31,8 +45,19 @@ def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int
|
||||
|
||||
text = manuscript_path.read_text(encoding="utf-8")
|
||||
|
||||
# Extract all valid slide markers like [S1], [S2], etc.
|
||||
markers = re.findall(r"\[([A-Za-z0-9_]+)\]", text)
|
||||
# Extract citations before stripping them
|
||||
citations = parse_citations(text)
|
||||
|
||||
# Strip [cite:...] markers from text so they don't pollute alignment
|
||||
text = re.sub(r"\[cite:[^\]]+\]", "", text)
|
||||
|
||||
# Strip [marker:...] and [cue:...] markers (personal recording cues, ignored by pipeline)
|
||||
text = re.sub(r"\[marker:[^\]]+\]", "", text)
|
||||
text = re.sub(r"\[cue:[^\]]+\]", "", text)
|
||||
|
||||
# Extract all valid markers like [S1], [video:demo], [Zoom2], etc.
|
||||
# Include . in pattern to catch markers with file extensions (so validator can warn about them)
|
||||
markers = re.findall(r"\[([A-Za-z0-9_:.]+)\]", text)
|
||||
|
||||
# Find malformed markers (missing brackets, extra spaces, etc.)
|
||||
malformed: list[tuple[int, str]] = []
|
||||
@@ -56,48 +81,72 @@ def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int
|
||||
for match in spaced:
|
||||
malformed.append((line_num, match))
|
||||
|
||||
return text, markers, malformed
|
||||
return text, markers, malformed, citations
|
||||
|
||||
|
||||
def parse_transcript(project_path: Path) -> list[TimedWord]:
|
||||
def parse_citations(manuscript_text: str) -> list[Citation]:
|
||||
"""
|
||||
Parse transcript.csv into a list of timed words.
|
||||
Extract all [cite:...] markers from manuscript text.
|
||||
|
||||
Expected format:
|
||||
t,word
|
||||
0.00,This
|
||||
0.42,is
|
||||
...
|
||||
The text after 'cite:' is the literal reference that should appear
|
||||
in the video description.
|
||||
|
||||
Returns:
|
||||
List of Citation objects with reference text and context for alignment.
|
||||
"""
|
||||
transcript_path = project_path / "transcript.csv"
|
||||
citations = []
|
||||
|
||||
if not transcript_path.exists():
|
||||
raise ParseError("transcript.csv not found", transcript_path)
|
||||
# Match [cite:...] markers - content can include any characters except ]
|
||||
# Use a more permissive pattern that handles multi-word citations
|
||||
pattern = r"\[cite:([^\]]+)\]"
|
||||
|
||||
timed_words = []
|
||||
for match in re.finditer(pattern, manuscript_text):
|
||||
reference = match.group(1).strip()
|
||||
marker_id = f"cite:{reference}"
|
||||
|
||||
with open(transcript_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
# Extract context: text following the citation (for alignment)
|
||||
# Get up to 100 chars after the marker, stopping at next marker or newline
|
||||
end_pos = match.end()
|
||||
context_text = manuscript_text[end_pos : end_pos + 150]
|
||||
|
||||
if reader.fieldnames is None or "t" not in reader.fieldnames or "word" not in reader.fieldnames:
|
||||
raise ParseError(
|
||||
"transcript.csv must have columns: t, word",
|
||||
transcript_path
|
||||
# Clean up context: take text until next marker or double newline
|
||||
context_match = re.match(r"([^\[]*?)(?:\[|\n\n|$)", context_text)
|
||||
context = context_match.group(1).strip() if context_match else ""
|
||||
|
||||
# Truncate context to ~50 chars for display
|
||||
if len(context) > 50:
|
||||
context = context[:47] + "..."
|
||||
|
||||
citations.append(
|
||||
Citation(
|
||||
reference=reference,
|
||||
marker_id=marker_id,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
for line_num, row in enumerate(reader, start=2): # start=2 because line 1 is header
|
||||
try:
|
||||
time = float(row["t"])
|
||||
word = row["word"].strip()
|
||||
timed_words.append(TimedWord(time=time, word=word))
|
||||
except (ValueError, KeyError) as e:
|
||||
raise ParseError(
|
||||
f"Invalid row: {e}",
|
||||
transcript_path,
|
||||
line_num
|
||||
)
|
||||
return citations
|
||||
|
||||
return timed_words
|
||||
|
||||
def save_citations(citations: list[Citation], path: Path) -> None:
|
||||
"""Save citations to a JSON file."""
|
||||
data = [{"reference": c.reference, "context": c.context} for c in citations]
|
||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_citations(path: Path) -> list[Citation]:
|
||||
"""Load citations from a JSON file."""
|
||||
if not path.exists():
|
||||
return []
|
||||
data = _read_json(path)
|
||||
return [
|
||||
Citation(
|
||||
reference=item["reference"],
|
||||
marker_id=f"cite:{item['reference']}",
|
||||
context=item.get("context", ""),
|
||||
)
|
||||
for item in data
|
||||
]
|
||||
|
||||
|
||||
def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||
@@ -108,19 +157,57 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||
raise ParseError("project.json not found", config_path)
|
||||
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
data = _read_json(config_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", config_path)
|
||||
|
||||
# Parse talking head config
|
||||
th_data = data.get("talkinghead", {})
|
||||
th_height, th_height_pct = _parse_dimension(th_data.get("targetheight", 200))
|
||||
talking_head = TalkingHeadConfig(
|
||||
x=th_data.get("x", 100),
|
||||
y=th_data.get("y", 100),
|
||||
target_height=th_height,
|
||||
target_height_percent=th_height_pct,
|
||||
file=th_data.get("file"),
|
||||
# Built-in cutouts — used by vft/vfb/vst/vsb marker shorthand.
|
||||
# Projects can override these by defining cutouts with the same names.
|
||||
cutouts: dict[str, CutoutDefinition] = {
|
||||
# 100 % × 100 % at origin — for fullscreen video (vf* markers)
|
||||
"fullscreen": CutoutDefinition(
|
||||
x=-1,
|
||||
y=-1,
|
||||
height=-1,
|
||||
width=-1,
|
||||
x_percent=0.0,
|
||||
y_percent=0.0,
|
||||
height_percent=1.0,
|
||||
width_percent=1.0,
|
||||
),
|
||||
# 50 % height, square aspect, centred — for square video (vs* markers)
|
||||
"square": CutoutDefinition(
|
||||
x=-1,
|
||||
y=-1,
|
||||
height=-1,
|
||||
width=-1,
|
||||
x_percent=0.25,
|
||||
y_percent=0.25,
|
||||
height_percent=0.5,
|
||||
width_percent=0.0,
|
||||
),
|
||||
}
|
||||
|
||||
# Parse cutouts (named zones for video placement) — project definitions
|
||||
# override the built-ins above.
|
||||
cutouts_data = data.get("cutouts", {})
|
||||
for cutout_name, cutout_data in cutouts_data.items():
|
||||
x, x_pct = _parse_dimension(cutout_data.get("x", 0))
|
||||
y, y_pct = _parse_dimension(cutout_data.get("y", 0))
|
||||
height, height_pct = _parse_dimension(cutout_data.get("height", 200))
|
||||
# Width defaults to same as height (square) if not specified
|
||||
width, width_pct = _parse_dimension(
|
||||
cutout_data.get("width", cutout_data.get("height", 200))
|
||||
)
|
||||
cutouts[cutout_name] = CutoutDefinition(
|
||||
x=x,
|
||||
y=y,
|
||||
height=height,
|
||||
width=width,
|
||||
x_percent=x_pct,
|
||||
y_percent=y_pct,
|
||||
height_percent=height_pct,
|
||||
width_percent=width_pct,
|
||||
)
|
||||
|
||||
# Parse resolution
|
||||
@@ -128,15 +215,29 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||
if not isinstance(resolution, list) or len(resolution) != 2:
|
||||
raise ParseError("resolution must be [width, height]", config_path)
|
||||
|
||||
# Parse default_filters (named filter presets)
|
||||
default_filters: dict[str, list[dict]] = data.get("default_filters", {})
|
||||
|
||||
return ProjectConfig(
|
||||
resolution=tuple(resolution),
|
||||
fps=data.get("fps", 30),
|
||||
talking_head=talking_head,
|
||||
default_slide_type=data.get("defaultSlideType", "square"),
|
||||
cutouts=cutouts,
|
||||
default_filters=default_filters,
|
||||
background=data.get("background", ""),
|
||||
background_video=data.get("background_video", ""), # Deprecated
|
||||
slides_path=data.get("slides", "slides.json"),
|
||||
videos_path=data.get("videos", "videos.json"),
|
||||
audio_path=data.get("audio", "audio.json"),
|
||||
audio_source=data.get("audio_source"),
|
||||
main_video=data.get("main_video"),
|
||||
gnommo_scratch=data.get("gnommo_scratch"),
|
||||
default_begin=float(data.get("default_begin", 0.0)),
|
||||
default_end_trim=float(data.get("default_end_trim", 0.0)),
|
||||
outro=data.get("outro", []),
|
||||
description=data.get("description", ""),
|
||||
footer=data.get("footer", ""),
|
||||
output_video=data.get("output_video", ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -157,18 +258,24 @@ def _parse_dimension(value: Any) -> tuple[int, float]:
|
||||
return 200, 0.0 # default
|
||||
|
||||
|
||||
def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str, SlideDefinition]:
|
||||
def parse_slides(
|
||||
project_path: Path, config: ProjectConfig = None
|
||||
) -> dict[str, SlideDefinition]:
|
||||
"""Parse slides.json into slide definitions."""
|
||||
if config and config.slides_path:
|
||||
slides_path = project_path / config.slides_path
|
||||
local_slides_path = project_path / config.slides_path
|
||||
else:
|
||||
slides_path = project_path / "slides.json"
|
||||
local_slides_path = project_path / "slides.json"
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
slides_path, _ = resolve_with_cache(local_slides_path, project_path)
|
||||
if not slides_path.exists():
|
||||
raise ParseError(f"slides file not found: {slides_path}", slides_path)
|
||||
raise ParseError(
|
||||
f"slides file not found: {local_slides_path}", local_slides_path
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(slides_path.read_text(encoding="utf-8"))
|
||||
data = _read_json(slides_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", slides_path)
|
||||
|
||||
@@ -176,8 +283,7 @@ def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str,
|
||||
for slide_id, slide_data in data.items():
|
||||
if "image" not in slide_data:
|
||||
raise ParseError(
|
||||
f"Slide '{slide_id}' missing required field 'image'",
|
||||
slides_path
|
||||
f"Slide '{slide_id}' missing required field 'image'", slides_path
|
||||
)
|
||||
slides[slide_id] = SlideDefinition(
|
||||
image=slide_data["image"],
|
||||
@@ -187,32 +293,307 @@ def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str,
|
||||
return slides
|
||||
|
||||
|
||||
def parse_videos(project_path: Path) -> dict[str, VideoSource]:
|
||||
"""Parse videos.json into video source definitions."""
|
||||
videos_path = project_path / "videos.json"
|
||||
def parse_audio(
|
||||
project_path: Path, config: Optional[ProjectConfig] = None
|
||||
) -> tuple[dict[str, AudioDefinition], Path]:
|
||||
"""
|
||||
Parse audio.json into audio definitions.
|
||||
|
||||
if not videos_path.exists():
|
||||
raise ParseError("videos.json not found", videos_path)
|
||||
Returns:
|
||||
Tuple of (audio dict, audio_dir) where audio_dir is the directory
|
||||
containing audio.json (for resolving relative file paths).
|
||||
"""
|
||||
if config and config.audio_path:
|
||||
local_audio_path = project_path / config.audio_path
|
||||
else:
|
||||
local_audio_path = project_path / "audio.json"
|
||||
|
||||
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||
audio_dir = local_audio_path.parent
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
audio_path, _ = resolve_with_cache(local_audio_path, project_path)
|
||||
|
||||
# Audio is optional - return empty dict if not found
|
||||
if not audio_path.exists():
|
||||
return {}, audio_dir
|
||||
|
||||
try:
|
||||
data = json.loads(videos_path.read_text(encoding="utf-8"))
|
||||
data = _read_json(audio_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", audio_path)
|
||||
|
||||
audio = {}
|
||||
for audio_id, audio_data in data.items():
|
||||
if "file" not in audio_data:
|
||||
raise ParseError(
|
||||
f"Audio '{audio_id}' missing required field 'file'", audio_path
|
||||
)
|
||||
# Parse overlap if specified (timestamp string like "10s")
|
||||
overlap = None
|
||||
if "overlap" in audio_data and audio_data["overlap"]:
|
||||
overlap = parse_timestamp(audio_data["overlap"])
|
||||
|
||||
raw_duration = audio_data.get("duration")
|
||||
audio[audio_id] = AudioDefinition(
|
||||
file=audio_data["file"],
|
||||
volume=float(audio_data.get("volume", 1.0)),
|
||||
loop=bool(audio_data.get("loop", False)),
|
||||
overlap=overlap,
|
||||
ignore_pauses=bool(audio_data.get("ignore_pauses", False)),
|
||||
duration=float(raw_duration) if raw_duration is not None else None,
|
||||
)
|
||||
|
||||
return audio, audio_dir
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> float:
|
||||
"""
|
||||
Parse a timestamp string into seconds.
|
||||
|
||||
Supported formats:
|
||||
- "3.5s" or "3.5" → 3.5 seconds
|
||||
- "2:54" → 2 minutes 54 seconds (174.0)
|
||||
- "1:23:45" → 1 hour 23 minutes 45 seconds
|
||||
- "2:54.5" → 2 minutes 54.5 seconds
|
||||
|
||||
Returns:
|
||||
Time in seconds as a float.
|
||||
"""
|
||||
if not value:
|
||||
return 0.0
|
||||
|
||||
value = value.strip()
|
||||
|
||||
# Remove trailing 's' if present (e.g., "3.5s")
|
||||
if value.endswith("s"):
|
||||
value = value[:-1]
|
||||
|
||||
# Check for colon-separated format (MM:SS or HH:MM:SS)
|
||||
if ":" in value:
|
||||
parts = value.split(":")
|
||||
if len(parts) == 2:
|
||||
# MM:SS format
|
||||
minutes, seconds = parts
|
||||
return float(minutes) * 60 + float(seconds)
|
||||
elif len(parts) == 3:
|
||||
# HH:MM:SS format
|
||||
hours, minutes, seconds = parts
|
||||
return float(hours) * 3600 + float(minutes) * 60 + float(seconds)
|
||||
else:
|
||||
raise ParseError(f"Invalid timestamp format: {value}", None)
|
||||
|
||||
# Plain number (seconds)
|
||||
return float(value)
|
||||
|
||||
|
||||
def parse_videos(
|
||||
project_path: Path, config: Optional[ProjectConfig] = None
|
||||
) -> tuple[dict[str, VideoSource], Path]:
|
||||
"""
|
||||
Parse videos.json into video source definitions.
|
||||
|
||||
Filter can be specified as:
|
||||
- A list of filter configs (inline definition)
|
||||
- A string referencing a named preset in config.default_filters
|
||||
|
||||
Trim points can be specified as:
|
||||
- skip/take: raw values in seconds (traditional)
|
||||
- begin/end: timestamp strings like "3.5s", "2:54", "1:23:45" (user-friendly)
|
||||
These are converted to skip/take internally.
|
||||
|
||||
Returns:
|
||||
Tuple of (videos dict, videos_dir) where videos_dir is the directory
|
||||
containing videos.json (for resolving relative file paths).
|
||||
"""
|
||||
if config and config.videos_path:
|
||||
local_videos_path = project_path / config.videos_path
|
||||
else:
|
||||
local_videos_path = project_path / "videos.json"
|
||||
|
||||
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||
videos_dir = local_videos_path.parent
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
videos_path, _ = resolve_with_cache(local_videos_path, project_path)
|
||||
if not videos_path.exists():
|
||||
raise ParseError(
|
||||
f"videos.json not found: {local_videos_path}", local_videos_path
|
||||
)
|
||||
|
||||
try:
|
||||
data = _read_json(videos_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", videos_path)
|
||||
|
||||
# Get default_filters from config for resolving references
|
||||
default_filters = config.default_filters if config else {}
|
||||
|
||||
videos = {}
|
||||
for video_id, video_data in data.items():
|
||||
if "file" not in video_data:
|
||||
if "source_file" not in video_data:
|
||||
raise ParseError(
|
||||
f"Video '{video_id}' missing required field 'file'",
|
||||
videos_path
|
||||
)
|
||||
videos[video_id] = VideoSource(
|
||||
file=video_data["file"],
|
||||
preprocess=video_data.get("preprocess", []),
|
||||
output_file=video_data.get("output_file"),
|
||||
f"Video '{video_id}' missing required field 'source_file'", videos_path
|
||||
)
|
||||
|
||||
return videos
|
||||
# Parse attribution if present
|
||||
attribution = None
|
||||
if "attribution" in video_data:
|
||||
attr_data = video_data["attribution"]
|
||||
attribution = Attribution(
|
||||
source=attr_data.get("source", "unknown"),
|
||||
creator=attr_data.get("creator", "Unknown"),
|
||||
url=attr_data.get("url"),
|
||||
)
|
||||
|
||||
# Resolve filter - can be a list or a string reference to default_filters
|
||||
filter_value = video_data.get("filter", [])
|
||||
if isinstance(filter_value, str):
|
||||
# It's a reference to a named filter preset
|
||||
if filter_value not in default_filters:
|
||||
raise ParseError(
|
||||
f"Video '{video_id}' references unknown filter preset '{filter_value}'. "
|
||||
f"Available presets: {list(default_filters.keys())}",
|
||||
videos_path,
|
||||
)
|
||||
filter_list = default_filters[filter_value]
|
||||
else:
|
||||
# It's an inline filter definition
|
||||
filter_list = filter_value
|
||||
|
||||
# Handle skip/take - can use begin/end as user-friendly alternatives
|
||||
skip = video_data.get("skip", 0.0)
|
||||
take = video_data.get("take")
|
||||
|
||||
# Convert begin/end to skip/take if provided
|
||||
if "begin" in video_data and video_data["begin"]:
|
||||
skip = parse_timestamp(video_data["begin"])
|
||||
if "end" in video_data and video_data["end"]:
|
||||
end_time = parse_timestamp(video_data["end"])
|
||||
# take = end - begin (duration from begin to end)
|
||||
take = end_time - skip
|
||||
|
||||
raw_duration = video_data.get("duration")
|
||||
raw_has_audio = video_data.get("has_audio")
|
||||
videos[video_id] = VideoSource(
|
||||
source_file=video_data["source_file"],
|
||||
filter=filter_list,
|
||||
output_file=video_data.get("output_file"),
|
||||
take=take,
|
||||
skip=skip,
|
||||
zoom=video_data.get("zoom", 1.0),
|
||||
cutout=video_data.get("cutout"),
|
||||
always_visible=video_data.get("always_visible", False),
|
||||
is_shared=video_data.get("is_shared", False),
|
||||
pause_narration=float(video_data.get("pause_narration", 0)),
|
||||
attribution=attribution,
|
||||
use_audio_channels=video_data.get("use_audio_channels", "both"),
|
||||
defer_loudnorm=video_data.get("defer_loudnorm", False),
|
||||
volume=float(video_data.get("volume", 1.0)),
|
||||
layer=video_data.get("layer", "above"),
|
||||
duration=float(raw_duration) if raw_duration is not None else None,
|
||||
has_audio=bool(raw_has_audio) if raw_has_audio is not None else None,
|
||||
end_on=video_data.get("end_on"),
|
||||
)
|
||||
|
||||
return videos, videos_dir
|
||||
|
||||
|
||||
def parse_narration(
|
||||
project_path: Path, config: Optional[ProjectConfig] = None
|
||||
) -> tuple[dict[str, VideoSource], Path]:
|
||||
"""
|
||||
Parse narration.json into narration segment definitions.
|
||||
|
||||
Narration segments are stored in media/narration/ and are processed
|
||||
separately from videos. Each segment can have filters, begin/end trim
|
||||
points, and other properties similar to videos.
|
||||
|
||||
Filter can be specified as:
|
||||
- A list of filter configs (inline definition)
|
||||
- A string referencing a named preset in config.default_filters
|
||||
|
||||
Trim points can be specified as:
|
||||
- skip/take: raw values in seconds (traditional)
|
||||
- begin/end: timestamp strings like "3.5s", "2:54", "1:23:45" (user-friendly)
|
||||
These are converted to skip/take internally.
|
||||
|
||||
Returns:
|
||||
Tuple of (narration dict, narration_dir) where narration_dir is the directory
|
||||
containing narration.json (for resolving relative file paths).
|
||||
"""
|
||||
# Narration is always in media/narration/
|
||||
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||
narration_dir = project_path / "media" / "narration"
|
||||
local_narration_path = narration_dir / "narration.json"
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
narration_path, _ = resolve_with_cache(local_narration_path, project_path)
|
||||
|
||||
# Narration is optional - return empty dict if not found
|
||||
if not narration_path.exists():
|
||||
return {}, narration_dir
|
||||
|
||||
try:
|
||||
data = _read_json(narration_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", narration_path)
|
||||
|
||||
# Get default_filters from config for resolving references
|
||||
default_filters = config.default_filters if config else {}
|
||||
|
||||
narration = {}
|
||||
for segment_id, segment_data in data.items():
|
||||
if "source_file" not in segment_data:
|
||||
raise ParseError(
|
||||
f"Narration segment '{segment_id}' missing required field 'source_file'",
|
||||
narration_path,
|
||||
)
|
||||
|
||||
# Resolve filter - can be a list or a string reference to default_filters
|
||||
filter_value = segment_data.get("filter", [])
|
||||
if isinstance(filter_value, str):
|
||||
# It's a reference to a named filter preset
|
||||
if filter_value not in default_filters:
|
||||
raise ParseError(
|
||||
f"Narration segment '{segment_id}' references unknown filter preset '{filter_value}'. "
|
||||
f"Available presets: {list(default_filters.keys())}",
|
||||
narration_path,
|
||||
)
|
||||
filter_list = default_filters[filter_value]
|
||||
else:
|
||||
# It's an inline filter definition
|
||||
filter_list = filter_value
|
||||
|
||||
# Handle skip/take - can use begin/end as user-friendly alternatives
|
||||
# Fall back to project-level defaults if no explicit value is set
|
||||
default_begin = config.default_begin if config else 0.0
|
||||
skip = segment_data.get("skip", default_begin)
|
||||
take = segment_data.get("take")
|
||||
|
||||
# Explicit begin/end always override defaults
|
||||
if "begin" in segment_data and segment_data["begin"]:
|
||||
skip = parse_timestamp(segment_data["begin"])
|
||||
if "end" in segment_data and segment_data["end"]:
|
||||
end_time = parse_timestamp(segment_data["end"])
|
||||
# take = end - begin (duration from begin to end)
|
||||
take = end_time - skip
|
||||
|
||||
narration[segment_id] = VideoSource(
|
||||
source_file=segment_data["source_file"],
|
||||
filter=filter_list,
|
||||
output_file=segment_data.get("output_file"),
|
||||
take=take,
|
||||
skip=skip,
|
||||
zoom=segment_data.get("zoom", 1.0),
|
||||
cutout=segment_data.get("cutout"),
|
||||
always_visible=segment_data.get("always_visible", False),
|
||||
use_audio_channels=segment_data.get("use_audio_channels", "both"),
|
||||
defer_loudnorm=segment_data.get("defer_loudnorm", False),
|
||||
volume=float(segment_data.get("volume", 1.0)),
|
||||
)
|
||||
|
||||
return narration, narration_dir
|
||||
|
||||
|
||||
def get_video_duration(video_path: Path) -> float:
|
||||
@@ -221,10 +602,13 @@ def get_video_duration(video_path: Path) -> float:
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(video_path)
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(video_path),
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
@@ -256,12 +640,14 @@ def parse_video_metadata(metadata_path: Path) -> VideoMetadata:
|
||||
raise ParseError(f"Video metadata not found: {metadata_path}", metadata_path)
|
||||
|
||||
try:
|
||||
data = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
data = _read_json(metadata_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", metadata_path)
|
||||
|
||||
if "source_file" not in data:
|
||||
raise ParseError("Video metadata missing required field 'source_file'", metadata_path)
|
||||
raise ParseError(
|
||||
"Video metadata missing required field 'source_file'", metadata_path
|
||||
)
|
||||
|
||||
return VideoMetadata(
|
||||
source_file=data["source_file"],
|
||||
@@ -270,7 +656,9 @@ def parse_video_metadata(metadata_path: Path) -> VideoMetadata:
|
||||
)
|
||||
|
||||
|
||||
def resolve_video_file(project_path: Path, file_ref: str) -> tuple[Path, Optional[VideoMetadata]]:
|
||||
def resolve_video_file(
|
||||
project_path: Path, file_ref: str
|
||||
) -> tuple[Path, Optional[VideoMetadata]]:
|
||||
"""
|
||||
Resolve a video file reference, which can be either:
|
||||
1. A direct path to a video file
|
||||
|
||||
+2253
-65
File diff suppressed because it is too large
Load Diff
+233
@@ -0,0 +1,233 @@
|
||||
"""Pull project metadata from gnommoweb server.
|
||||
|
||||
Usage:
|
||||
gnommo pull -p video1 # pull parent video project
|
||||
gnommo pull -p short_pixelated_universe # pull a short project
|
||||
gnommo pull -p myproject --force # force pull, overwrite local
|
||||
|
||||
For a parent project: updates name, description, and the shorts index
|
||||
(list of slugs) in project.json.
|
||||
|
||||
For a short project: updates title, hook, platform_targets, resolution,
|
||||
fps, duration_seconds. Preserves local script path reference.
|
||||
|
||||
Conflict detection:
|
||||
- If local project.json mtime > last_pushed_at → local has unpushed changes
|
||||
→ warn and abort unless --force
|
||||
|
||||
Configuration (from .env or environment):
|
||||
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
|
||||
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print(
|
||||
"Error: 'requests' package is required. Run: pip install requests",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
SYNC_FILE_LOCAL = ".gnommo_sync.json"
|
||||
SYNC_FILE_PROD = ".gnommo_sync.prod.json"
|
||||
|
||||
|
||||
def _sync_file(prod: bool) -> str:
|
||||
return SYNC_FILE_PROD if prod else SYNC_FILE_LOCAL
|
||||
|
||||
|
||||
def _load_env_file():
|
||||
env_path = Path(__file__).parent.parent / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
with open(env_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _read_sync(project_path: Path, prod: bool = False) -> dict:
|
||||
sync_file = project_path / _sync_file(prod)
|
||||
if sync_file.exists():
|
||||
with open(sync_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_sync(project_path: Path, data: dict, prod: bool = False):
|
||||
with open(project_path / _sync_file(prod), "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def _parse_ts(ts_str) -> datetime | None:
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cmd_pull(
|
||||
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
|
||||
) -> int:
|
||||
_load_env_file()
|
||||
|
||||
if prod:
|
||||
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||
if not api_url:
|
||||
print("Error: GNOMMOWEB_PROD_URL is not set.", file=sys.stderr)
|
||||
return 1
|
||||
if not api_key:
|
||||
print("Error: GNOMMOWEB_PROD_API_KEY is not set.", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||
if not api_url:
|
||||
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
|
||||
return 1
|
||||
if not api_key:
|
||||
print("Error: GNOMMOWEB_API_KEY is not set.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if verbose:
|
||||
target = "production" if prod else "local"
|
||||
print(f" → {target}: {api_url}")
|
||||
|
||||
project_file = project_path / "project.json"
|
||||
if not project_file.exists():
|
||||
print(f"Error: {project_file} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(project_file) as f:
|
||||
local_project = json.load(f)
|
||||
|
||||
project_id = local_project.get("id")
|
||||
if not project_id:
|
||||
print("Error: project.json missing 'id'.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ── Conflict check ────────────────────────────────────────────────────────
|
||||
if not force:
|
||||
sync = _read_sync(project_path, prod)
|
||||
last_pushed_at = _parse_ts(sync.get("last_pushed_at"))
|
||||
local_mtime = datetime.fromtimestamp(
|
||||
project_file.stat().st_mtime, tz=timezone.utc
|
||||
)
|
||||
if last_pushed_at and local_mtime > last_pushed_at:
|
||||
print(
|
||||
f"⚠ project.json has local changes since last push "
|
||||
f"({local_mtime.strftime('%Y-%m-%d %H:%M')} > "
|
||||
f"{last_pushed_at.strftime('%Y-%m-%d %H:%M')}).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
" Push first with `gnommo push -p` or use `gnommo pull -p --force`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# ── Fetch from server ─────────────────────────────────────────────────────
|
||||
if verbose:
|
||||
print(f"Pulling {project_id} from {api_url}…")
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/api/projects/{project_id}",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"✗ Could not connect to {api_url}")
|
||||
return 1
|
||||
|
||||
if not r.ok:
|
||||
if r.status_code == 404:
|
||||
print(f"✗ Project '{project_id}' not found on server. Push it first.")
|
||||
else:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = r.text[:500]
|
||||
print(f"✗ Server returned {r.status_code}: {body}")
|
||||
return 1
|
||||
|
||||
server = r.json()
|
||||
server_updated_at = server.get("updated_at")
|
||||
project_type = server.get("type")
|
||||
|
||||
# ── Merge into project.json ───────────────────────────────────────────────
|
||||
if project_type == "parent":
|
||||
_merge_parent(local_project, server, verbose)
|
||||
count = len(server.get("shorts", []))
|
||||
print(f"✓ Pulled {project_id} (parent video) — {count} short(s) in index")
|
||||
elif project_type == "short":
|
||||
_merge_short(local_project, server, verbose)
|
||||
print(f"✓ Pulled {project_id} (short) — [{server.get('status')}]")
|
||||
else:
|
||||
print(f"Error: unexpected project type: {project_type}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ── Write back ────────────────────────────────────────────────────────────
|
||||
with open(project_file, "w") as f:
|
||||
json.dump(local_project, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||
existing_sync = _read_sync(project_path, prod)
|
||||
_write_sync(
|
||||
project_path,
|
||||
{
|
||||
**existing_sync,
|
||||
"last_pulled_at": now_iso,
|
||||
"server_updated_at": server_updated_at,
|
||||
"last_pushed_at": existing_sync.get("last_pushed_at"),
|
||||
},
|
||||
prod,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _merge_parent(local: dict, server: dict, verbose: bool):
|
||||
"""Update parent project.json: name, description, shorts index (slugs)."""
|
||||
local["name"] = server.get("title", local.get("name"))
|
||||
local["description"] = server.get("description") or local.get("description")
|
||||
# shorts is a list of slugs — update from server's shorts list
|
||||
server_shorts = server.get("shorts", [])
|
||||
local["shorts"] = [s["project_id"] for s in server_shorts]
|
||||
if verbose:
|
||||
print(f" shorts index: {local['shorts']}")
|
||||
|
||||
|
||||
def _merge_short(local: dict, server: dict, verbose: bool):
|
||||
"""Update short project.json: name, hook, platform_targets, resolution, fps, duration."""
|
||||
local["name"] = server.get("title", local.get("name"))
|
||||
if server.get("hook"):
|
||||
local["hook"] = server["hook"]
|
||||
if server.get("platform_targets"):
|
||||
local["platform_targets"] = server["platform_targets"]
|
||||
if server.get("resolution"):
|
||||
local["resolution"] = server["resolution"]
|
||||
if server.get("fps"):
|
||||
local["fps"] = server["fps"]
|
||||
if server.get("duration_seconds"):
|
||||
local["duration_seconds"] = server["duration_seconds"]
|
||||
if server.get("parent_project_id"):
|
||||
local["parent_project"] = server["parent_project_id"]
|
||||
# Never overwrite local script path — that stays local
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
"""Push project metadata to gnommoweb server.
|
||||
|
||||
Usage:
|
||||
gnommo push -p video1 # push parent video project
|
||||
gnommo push -p short_pixelated_universe # push a short project
|
||||
gnommo push -p myproject --force # force push, overwrite server
|
||||
|
||||
Reads project.json and POSTs to POST /api/projects/push.
|
||||
|
||||
If project.json contains a "parent_project" field, the project is pushed
|
||||
as a short and registered under that parent. Otherwise it is pushed as a
|
||||
parent video project.
|
||||
|
||||
Parent project.json "shorts" field is a list of slugs (just an index):
|
||||
"shorts": ["short_pixelated_universe", "short_planck_length"]
|
||||
|
||||
Short project.json has its own full config plus a parent_project field:
|
||||
{
|
||||
"id": "short_pixelated_universe",
|
||||
"parent_project": "Video1",
|
||||
"resolution": [1080, 1920],
|
||||
"fps": 30,
|
||||
"duration_seconds": 60,
|
||||
...
|
||||
}
|
||||
|
||||
Conflict detection:
|
||||
- If server.updated_at > our recorded server_updated_at → server has newer changes
|
||||
→ warn and abort unless --force
|
||||
|
||||
Configuration (from .env or environment):
|
||||
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
|
||||
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print(
|
||||
"Error: 'requests' package is required. Run: pip install requests",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
SYNC_FILE_LOCAL = ".gnommo_sync.json"
|
||||
SYNC_FILE_PROD = ".gnommo_sync.prod.json"
|
||||
|
||||
|
||||
def _sync_file(prod: bool) -> str:
|
||||
return SYNC_FILE_PROD if prod else SYNC_FILE_LOCAL
|
||||
|
||||
|
||||
def _load_env_file():
|
||||
env_path = Path(__file__).parent.parent / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
with open(env_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _read_sync(project_path: Path, prod: bool = False) -> dict:
|
||||
sync_file = project_path / _sync_file(prod)
|
||||
if sync_file.exists():
|
||||
with open(sync_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_sync(project_path: Path, data: dict, prod: bool = False):
|
||||
with open(project_path / _sync_file(prod), "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def _parse_ts(ts_str) -> datetime | None:
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cmd_push(
|
||||
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
|
||||
) -> int:
|
||||
_load_env_file()
|
||||
|
||||
if prod:
|
||||
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||
if not api_url:
|
||||
print("Error: GNOMMOWEB_PROD_URL is not set.", file=sys.stderr)
|
||||
return 1
|
||||
if not api_key:
|
||||
print("Error: GNOMMOWEB_PROD_API_KEY is not set.", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||
if not api_url:
|
||||
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
|
||||
return 1
|
||||
if not api_key:
|
||||
print("Error: GNOMMOWEB_API_KEY is not set.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if verbose:
|
||||
target = "production" if prod else "local"
|
||||
print(f" → {target}: {api_url}")
|
||||
|
||||
project_file = project_path / "project.json"
|
||||
if not project_file.exists():
|
||||
print(f"Error: {project_file} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(project_file) as f:
|
||||
project = json.load(f)
|
||||
|
||||
project_id = project.get("id")
|
||||
name = project.get("name")
|
||||
if not project_id or not name:
|
||||
print("Error: project.json must have 'id' and 'name' fields.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parent_project = project.get("parent_project")
|
||||
|
||||
# ── Build payload ─────────────────────────────────────────────────────────
|
||||
if parent_project:
|
||||
payload = _build_short_payload(project, project_path, verbose)
|
||||
else:
|
||||
payload = _build_parent_payload(project, project_path, verbose)
|
||||
|
||||
if verbose:
|
||||
kind = "short" if parent_project else "parent video"
|
||||
print(f"Pushing {project_id} ({kind}) to {api_url}")
|
||||
|
||||
# ── POST ──────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{api_url}/api/projects/push",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"✗ Could not connect to {api_url}")
|
||||
return 1
|
||||
|
||||
if not r.ok:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = r.text[:500]
|
||||
print(f"✗ Server returned {r.status_code}: {body}")
|
||||
return 1
|
||||
|
||||
result = r.json()
|
||||
server_updated_at = result.get("server_updated_at")
|
||||
|
||||
# ── Write sync state ──────────────────────────────────────────────────────
|
||||
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||
existing_sync = _read_sync(project_path, prod)
|
||||
_write_sync(
|
||||
project_path,
|
||||
{
|
||||
**existing_sync,
|
||||
"last_pushed_at": now_iso,
|
||||
"server_updated_at": server_updated_at,
|
||||
},
|
||||
prod,
|
||||
)
|
||||
|
||||
# ── Print summary ─────────────────────────────────────────────────────────
|
||||
asset = result.get("asset", {})
|
||||
if result.get("type") == "short":
|
||||
print(f"✓ {project_id} → gn_asset #{asset.get('id')} [{asset.get('status')}]")
|
||||
if result.get("task_created"):
|
||||
print(f" task #{result['task_id']} created")
|
||||
else:
|
||||
print(f"✓ {project_id} → gn_asset #{asset.get('id')} ({asset.get('name')})")
|
||||
if verbose:
|
||||
script_len = len(asset.get("script") or "")
|
||||
print(
|
||||
f" server.script: {script_len} chars | fps={asset.get('fps')} res={asset.get('resolution')}"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> dict:
|
||||
# Read the manuscript file if one is specified
|
||||
script_content = None
|
||||
manuscript_str = project.get("manuscript")
|
||||
if manuscript_str:
|
||||
manuscript_path = project_path / manuscript_str
|
||||
if manuscript_path.exists():
|
||||
try:
|
||||
script_content = manuscript_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
script_content = manuscript_path.read_text(encoding="latin-1")
|
||||
print(f" Warning: manuscript is not UTF-8, read as latin-1")
|
||||
print(f" manuscript: {len(script_content)} chars")
|
||||
else:
|
||||
print(f" Warning: manuscript not found: {manuscript_path}")
|
||||
else:
|
||||
if verbose:
|
||||
print(f" no manuscript field in project.json")
|
||||
|
||||
return {
|
||||
"project_id": project["id"],
|
||||
"name": project["name"],
|
||||
"description": project.get("description"),
|
||||
"coursecode": project.get("coursecode"),
|
||||
"script_content": script_content,
|
||||
"resolution": project.get("resolution"),
|
||||
"fps": project.get("fps"),
|
||||
"duration_seconds": project.get("duration_seconds"),
|
||||
"hook": project.get("hook"),
|
||||
"platform_targets": project.get("platform_targets"),
|
||||
"status": project.get("status"),
|
||||
"youtube_url": project.get("youtube_url"),
|
||||
"shorts": project.get("shorts", []),
|
||||
}
|
||||
|
||||
|
||||
def _build_short_payload(project: dict, project_path: Path, verbose: bool) -> dict:
|
||||
# Read the script file if one is specified
|
||||
script_content = None
|
||||
script_path_str = project.get("script")
|
||||
if script_path_str:
|
||||
script_path = project_path / script_path_str
|
||||
if script_path.exists():
|
||||
script_content = script_path.read_text()
|
||||
if verbose:
|
||||
print(f" Read script: {script_path} ({len(script_content)} chars)")
|
||||
else:
|
||||
print(f" Warning: script file not found: {script_path}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"project_id": project["id"],
|
||||
"name": project["name"],
|
||||
"description": project.get("description"),
|
||||
"parent_project": project["parent_project"],
|
||||
"hook": project.get("hook"),
|
||||
"script_content": script_content,
|
||||
"platform_targets": project.get("platform_targets", ["youtube"]),
|
||||
"resolution": project.get("resolution"),
|
||||
"fps": project.get("fps"),
|
||||
"duration_seconds": project.get("duration_seconds"),
|
||||
}
|
||||
+1183
-73
File diff suppressed because it is too large
Load Diff
+108
-10
@@ -5,12 +5,15 @@ import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import resolve_with_cache
|
||||
from .errors import GnommoError
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscribedWord:
|
||||
"""A word with its timestamp from transcription."""
|
||||
|
||||
word: str
|
||||
start: float
|
||||
end: float
|
||||
@@ -18,6 +21,7 @@ class TranscribedWord:
|
||||
|
||||
class TranscriptionError(GnommoError):
|
||||
"""Error during transcription."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -57,28 +61,38 @@ def transcribe_video(video_path: Path, model: str = "base") -> list[TranscribedW
|
||||
|
||||
for segment in result.get("segments", []):
|
||||
for word_info in segment.get("words", []):
|
||||
words.append(TranscribedWord(
|
||||
words.append(
|
||||
TranscribedWord(
|
||||
word=word_info["word"].strip(),
|
||||
start=word_info["start"],
|
||||
end=word_info["end"],
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
return words
|
||||
|
||||
|
||||
def save_transcript(words: list[TranscribedWord], output_path: Path) -> None:
|
||||
"""Save transcribed words to a JSON file."""
|
||||
data = [
|
||||
{"word": w.word, "start": w.start, "end": w.end}
|
||||
for w in words
|
||||
]
|
||||
data = [{"word": w.word, "start": w.start, "end": w.end} for w in words]
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def load_transcript(transcript_path: Path) -> list[TranscribedWord]:
|
||||
"""Load transcribed words from a JSON file."""
|
||||
def load_transcript(
|
||||
transcript_path: Path, project_path: Optional[Path] = None
|
||||
) -> list[TranscribedWord]:
|
||||
"""Load transcribed words from a JSON file.
|
||||
|
||||
Args:
|
||||
transcript_path: Path to the transcript JSON file
|
||||
project_path: Optional project path for cache fallback
|
||||
"""
|
||||
# Try cache fallback if project_path provided
|
||||
if project_path:
|
||||
transcript_path, _ = resolve_with_cache(transcript_path, project_path)
|
||||
|
||||
if not transcript_path.exists():
|
||||
raise TranscriptionError(f"Transcript file not found: {transcript_path}")
|
||||
|
||||
@@ -86,6 +100,90 @@ def load_transcript(transcript_path: Path) -> list[TranscribedWord]:
|
||||
data = json.load(f)
|
||||
|
||||
return [
|
||||
TranscribedWord(word=w["word"], start=w["start"], end=w["end"])
|
||||
for w in data
|
||||
TranscribedWord(word=w["word"], start=w["start"], end=w["end"]) for w in data
|
||||
]
|
||||
|
||||
|
||||
def _format_srt_timestamp(seconds: float) -> str:
|
||||
"""Format seconds as SRT timestamp: HH:MM:SS,mmm"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
millis = int((seconds % 1) * 1000)
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
||||
|
||||
|
||||
def words_to_srt(
|
||||
words: list[TranscribedWord],
|
||||
max_words_per_line: int = 10,
|
||||
max_duration: float = 5.0,
|
||||
gap_threshold: float = 1.0,
|
||||
) -> str:
|
||||
"""
|
||||
Convert word-level timestamps to SRT caption format.
|
||||
|
||||
Groups words into readable caption segments based on:
|
||||
- Maximum words per line (default: 10)
|
||||
- Maximum segment duration (default: 5 seconds)
|
||||
- Natural gaps between words (default: 1 second pause triggers new segment)
|
||||
|
||||
Args:
|
||||
words: List of TranscribedWord with timestamps
|
||||
max_words_per_line: Maximum words before splitting to new segment
|
||||
max_duration: Maximum duration of a single caption segment
|
||||
gap_threshold: Pause duration that triggers a new segment
|
||||
|
||||
Returns:
|
||||
SRT formatted string ready for YouTube upload
|
||||
"""
|
||||
if not words:
|
||||
return ""
|
||||
|
||||
segments: list[tuple[float, float, str]] = [] # (start, end, text)
|
||||
current_words: list[str] = []
|
||||
segment_start: float = words[0].start
|
||||
segment_end: float = words[0].end
|
||||
|
||||
for i, word in enumerate(words):
|
||||
# Check if we should start a new segment
|
||||
start_new_segment = False
|
||||
|
||||
# Gap between words
|
||||
if current_words and (word.start - segment_end) > gap_threshold:
|
||||
start_new_segment = True
|
||||
|
||||
# Too many words
|
||||
if len(current_words) >= max_words_per_line:
|
||||
start_new_segment = True
|
||||
|
||||
# Segment too long
|
||||
if current_words and (word.end - segment_start) > max_duration:
|
||||
start_new_segment = True
|
||||
|
||||
if start_new_segment and current_words:
|
||||
# Save current segment
|
||||
text = " ".join(current_words)
|
||||
segments.append((segment_start, segment_end, text))
|
||||
# Start new segment
|
||||
current_words = []
|
||||
segment_start = word.start
|
||||
|
||||
current_words.append(word.word)
|
||||
segment_end = word.end
|
||||
|
||||
# Don't forget the last segment
|
||||
if current_words:
|
||||
text = " ".join(current_words)
|
||||
segments.append((segment_start, segment_end, text))
|
||||
|
||||
# Format as SRT
|
||||
srt_lines = []
|
||||
for idx, (start, end, text) in enumerate(segments, 1):
|
||||
srt_lines.append(str(idx))
|
||||
srt_lines.append(
|
||||
f"{_format_srt_timestamp(start)} --> {_format_srt_timestamp(end)}"
|
||||
)
|
||||
srt_lines.append(text)
|
||||
srt_lines.append("") # Blank line between entries
|
||||
|
||||
return "\n".join(srt_lines)
|
||||
|
||||
+1120
-53
File diff suppressed because it is too large
Load Diff
+193
-59
@@ -2,8 +2,16 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import resolve_with_cache
|
||||
from .errors import ValidationError, ValidationIssue
|
||||
from .models import ProjectConfig, SlideDefinition, VideoSource, SLIDE_LAYOUTS
|
||||
from .parser import _read_json
|
||||
from .models import (
|
||||
ProjectConfig,
|
||||
SlideDefinition,
|
||||
VideoSource,
|
||||
SLIDE_LAYOUTS,
|
||||
CAMERA_PRESETS,
|
||||
)
|
||||
|
||||
|
||||
def validate_project(
|
||||
@@ -12,10 +20,12 @@ def validate_project(
|
||||
config: ProjectConfig,
|
||||
slides: dict[str, SlideDefinition],
|
||||
videos: dict[str, VideoSource],
|
||||
videos_dir: Path,
|
||||
malformed_markers: list[tuple[int, str]] = None,
|
||||
) -> None:
|
||||
) -> list[ValidationIssue]:
|
||||
"""
|
||||
Validate all parsed project data. Raises ValidationError if any issues found.
|
||||
Returns a list of warnings (non-fatal issues).
|
||||
|
||||
Checks:
|
||||
- All slide markers in manuscript exist in slides.json
|
||||
@@ -26,23 +36,88 @@ def validate_project(
|
||||
- No malformed markers in manuscript
|
||||
"""
|
||||
issues: list[ValidationIssue] = []
|
||||
warnings: list[ValidationIssue] = []
|
||||
|
||||
# Check for malformed markers first (these are likely typos)
|
||||
if malformed_markers:
|
||||
for line_num, marker_text in malformed_markers:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Malformed marker: {marker_text}",
|
||||
project_path / "manuscript.txt",
|
||||
line_num
|
||||
))
|
||||
line_num,
|
||||
)
|
||||
)
|
||||
|
||||
# Check all manuscript markers have corresponding slides
|
||||
# Check all manuscript markers have corresponding slides or videos
|
||||
for marker in manuscript_markers:
|
||||
# Skip camera effect markers (Zoom0, TiltLeft, Reset, etc.)
|
||||
if marker in CAMERA_PRESETS:
|
||||
continue
|
||||
# Skip audio markers (start with 'A' followed by audio id, e.g., Awoosh)
|
||||
if marker.startswith("A") and len(marker) > 1 and marker[1:].isalnum():
|
||||
continue
|
||||
# Validate video trigger markers — both legacy [video:xxx] and
|
||||
# shorthand [vft:xxx] / [vfb:xxx] / [vst:xxx] / [vsb:xxx].
|
||||
_VIDEO_PREFIXES = {
|
||||
"video:": 6,
|
||||
"vft:": 4,
|
||||
"vfb:": 4,
|
||||
"vst:": 4,
|
||||
"vsb:": 4,
|
||||
}
|
||||
matched_prefix = next(
|
||||
(p for p in _VIDEO_PREFIXES if marker.startswith(p)), None
|
||||
)
|
||||
if matched_prefix is not None:
|
||||
video_id = marker[_VIDEO_PREFIXES[matched_prefix] :]
|
||||
if video_id not in videos:
|
||||
hint = ""
|
||||
if "." in video_id:
|
||||
base_name = video_id.rsplit(".", 1)[0]
|
||||
if base_name in videos:
|
||||
hint = f" (Did you mean [{matched_prefix}{base_name}]? Don't include file extensions in markers)"
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Video marker [{marker}] referenced in manuscript but '{video_id}' not defined in videos.json{hint} — using PlaceholderVideo instead",
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Validate narration trigger markers (narration:xxx) - continuous videos
|
||||
if marker.startswith("narration:"):
|
||||
video_id = marker[10:] # Remove 'narration:' prefix
|
||||
if video_id not in videos:
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Narration marker [{marker}] referenced in manuscript but '{video_id}' not defined in videos.json — using PlaceholderVideo instead",
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Segment markers are structural annotations, not slide references
|
||||
if marker.startswith("segment:"):
|
||||
continue
|
||||
|
||||
# Unknown namespaced markers (e.g. [background:xxx]) — not supported, ignore with warning
|
||||
if ":" in marker:
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Unknown marker type [{marker}] — ignoring (no support for '{marker.split(':', 1)[0]}:' markers)",
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if marker not in slides:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Slide marker [{marker}] referenced in manuscript but not defined in slides.json",
|
||||
project_path / "manuscript.txt"
|
||||
))
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
|
||||
# Check all slide images exist
|
||||
# Slides are in the same directory as the slides.json file
|
||||
@@ -51,82 +126,141 @@ def validate_project(
|
||||
|
||||
for slide_id, slide_def in slides.items():
|
||||
image_path = slides_dir / slide_def.image
|
||||
image_path, _ = resolve_with_cache(image_path, project_path)
|
||||
if not image_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
f"Slide image not found: {slide_def.image}",
|
||||
slides_json_path
|
||||
))
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Slide image not found: {slide_def.image}", slides_json_path
|
||||
)
|
||||
)
|
||||
|
||||
# Check slide type is valid
|
||||
if slide_def.type not in SLIDE_LAYOUTS:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Unknown slide type '{slide_def.type}' for slide {slide_id}. "
|
||||
f"Valid types: {list(SLIDE_LAYOUTS.keys())}",
|
||||
project_path / "slides.json"
|
||||
))
|
||||
project_path / "slides.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check all video files exist (paths relative to videos_dir or shared_assets)
|
||||
videos_json_path = project_path / config.videos_path
|
||||
|
||||
# Find shared_assets directory
|
||||
shared_assets_dir = None
|
||||
if (project_path / "shared_assets").exists():
|
||||
shared_assets_dir = project_path / "shared_assets"
|
||||
elif (project_path.parent / "shared_assets").exists():
|
||||
shared_assets_dir = project_path.parent / "shared_assets"
|
||||
|
||||
# Check all video files exist
|
||||
for video_id, video_source in videos.items():
|
||||
video_path = project_path / video_source.file
|
||||
if not video_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
f"Video file not found: {video_source.file}",
|
||||
project_path / "videos.json"
|
||||
))
|
||||
# Determine base directory based on is_shared flag
|
||||
if video_source.is_shared:
|
||||
if shared_assets_dir:
|
||||
base_dir = shared_assets_dir
|
||||
else:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Video '{video_id}' has is_shared=true but shared_assets directory not found",
|
||||
videos_json_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
else:
|
||||
base_dir = videos_dir
|
||||
|
||||
# Check preprocessed output exists if preprocessing is defined
|
||||
if video_source.preprocess and video_source.output_file:
|
||||
output_path = project_path / video_source.output_file
|
||||
video_path = base_dir / video_source.source_file
|
||||
video_path, _ = resolve_with_cache(video_path, project_path)
|
||||
if not video_path.exists():
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Video file not found: {video_source.source_file} — falling back to PlaceholderVideo",
|
||||
videos_json_path,
|
||||
)
|
||||
)
|
||||
|
||||
# Check preprocessed output exists if filters are defined
|
||||
if video_source.filter and video_source.output_file:
|
||||
output_path = base_dir / video_source.output_file
|
||||
output_path, _ = resolve_with_cache(output_path, project_path)
|
||||
if not output_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Preprocessed output not found: {video_source.output_file}. "
|
||||
f"Run with -a preprocess first.",
|
||||
project_path / "videos.json"
|
||||
))
|
||||
videos_json_path,
|
||||
)
|
||||
)
|
||||
|
||||
# Check background exists (image or video)
|
||||
# Try 'background' first, fall back to deprecated 'background_video'
|
||||
bg_file = config.background or config.background_video
|
||||
if bg_file:
|
||||
# Check in project folder first, then parent (for shared_assets)
|
||||
bg_path = project_path / bg_file
|
||||
# Check background exists — must be a handle in shared_assets/videos.json
|
||||
bg_handle = config.background
|
||||
if bg_handle:
|
||||
shared_assets_dir = project_path.parent / "shared_assets"
|
||||
videos_json_path_bg = shared_assets_dir / "videos.json"
|
||||
if not videos_json_path_bg.exists():
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"shared_assets/videos.json not found (needed for background handle '{bg_handle}')",
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
else:
|
||||
bg_videos = _read_json(videos_json_path_bg)
|
||||
if bg_handle not in bg_videos:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Background handle '{bg_handle}' not found in shared_assets/videos.json",
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
else:
|
||||
bg_path = shared_assets_dir / bg_videos[bg_handle]["source_file"]
|
||||
if not bg_path.exists():
|
||||
# Try parent directory (shared_assets at repo root)
|
||||
bg_path = project_path.parent / bg_file
|
||||
if not bg_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
f"Background not found: {bg_file}",
|
||||
project_path / "project.json"
|
||||
))
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Background file not found: {bg_path} (from handle '{bg_handle}')",
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check we have at least one video source
|
||||
if not videos:
|
||||
issues.append(ValidationIssue(
|
||||
"No video sources defined in videos.json",
|
||||
project_path / "videos.json"
|
||||
))
|
||||
# Check videos.json exists (empty is fine — project may not need triggered videos)
|
||||
if not (project_path / config.videos_path).exists():
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
"videos.json not found — run 'gnommo import' to create it",
|
||||
project_path / "videos.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check resolution is reasonable
|
||||
width, height = config.resolution
|
||||
if width < 100 or height < 100:
|
||||
issues.append(ValidationIssue(
|
||||
f"Resolution too small: {width}x{height}",
|
||||
project_path / "project.json"
|
||||
))
|
||||
if width < 50 or height < 50:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Resolution too small: {width}x{height}", project_path / "project.json"
|
||||
)
|
||||
)
|
||||
|
||||
if width > 7680 or height > 4320:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Resolution too large: {width}x{height} (max 8K)",
|
||||
project_path / "project.json"
|
||||
))
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check FPS is reasonable
|
||||
if config.fps < 1 or config.fps > 120:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Invalid FPS: {config.fps} (must be 1-120)",
|
||||
project_path / "project.json"
|
||||
))
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
|
||||
# If any issues, raise ValidationError
|
||||
if issues:
|
||||
raise ValidationError(issues)
|
||||
|
||||
return warnings
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACCDr3tCxUf7HC+9s9N0TF9EECMshm6/Epcr6kZzaZGv0AAAAKC+5OiPvuTo
|
||||
jwAAAAtzc2gtZWQyNTUxOQAAACCDr3tCxUf7HC+9s9N0TF9EECMshm6/Epcr6kZzaZGv0A
|
||||
AAAEBKyC2/ZfItNXIf/UcSTYaV/eWjX6uKIrvliO+sdFJUV4Ove0LFR/scL72z03RMX0QQ
|
||||
IyyGbr8SlyvqRnNpka/QAAAAHGplbnMudGFuZHN0YWRAZWFnbGVjb25kb3Iubm8B
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIOve0LFR/scL72z03RMX0QQIyyGbr8SlyvqRnNpka/Q jens.tandstad@eaglecondor.no
|
||||
@@ -0,0 +1,6 @@
|
||||
import gnommo
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("This is the main module.")
|
||||
|
||||
gnommo.main()
|
||||
@@ -0,0 +1,2 @@
|
||||
openai-whisper
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"last_pushed_at": "2026-03-13T09:44:12+00:00",
|
||||
"server_updated_at": "2026-03-13T09:44:12.934Z",
|
||||
"last_pulled_at": "2026-03-13T09:35:00+00:00"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "short_is_universe_pixelated",
|
||||
"name": "Is the universe pixelated?",
|
||||
"description": "What if space is made of tiny blocks? A 60-second take on discrete physics.",
|
||||
"parent_project": "Video1",
|
||||
"hook": "What if reality is fundamentally blocky — like Minecraft, but smaller?",
|
||||
"platform_targets": [
|
||||
"youtube"
|
||||
],
|
||||
"resolution": [
|
||||
1080,
|
||||
1920
|
||||
],
|
||||
"fps": 30,
|
||||
"duration_seconds": 60,
|
||||
"script": "script.md",
|
||||
"output_video": "short_is_universe_pixelated.mp4",
|
||||
"keynote_file": "../video1/media/video1.key",
|
||||
"background": "../video1/shared_assets/BlackBackground.mp4",
|
||||
"slides": "../video1/media/slides/Video1/slides.json",
|
||||
"defaultSlideType": "fullscreen",
|
||||
"cutouts": {
|
||||
"talkinghead": {
|
||||
"x": "-23%",
|
||||
"y": "10%",
|
||||
"height": "90%"
|
||||
},
|
||||
"fullscreen": {
|
||||
"x": "0%",
|
||||
"y": "0%",
|
||||
"height": "100%"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Short: Is the universe pixelated?
|
||||
|
||||
**HOOK**: What if reality is fundamentally blocky — like Minecraft, but smaller?
|
||||
|
||||
[SLIDE: title_card]
|
||||
|
||||
Everyone assumes space is smooth and continuous.
|
||||
|
||||
[SLIDE: smooth_space]
|
||||
|
||||
But what if it isn't?
|
||||
|
||||
[SLIDE: pixelated_space]
|
||||
|
||||
What if there's a *smallest* unit of space — and below that, nothing exists?
|
||||
|
||||
[SLIDE: planck_length]
|
||||
|
||||
This isn't new-age woo. The Planck length has been sitting in physics for a century.
|
||||
|
||||
[SLIDE: planck_formula]
|
||||
|
||||
The question is: is it a minimum, or just a measurement limit?
|
||||
|
||||
[SLIDE: question_mark]
|
||||
|
||||
That's what we're exploring at Glitch University.
|
||||
|
||||
[SLIDE: outro]
|
||||
|
||||
Link in description. The physics rabbit hole goes deep.
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
|
||||
API_URL="${GNOMMO_API_URL:-https://glitch.university}"
|
||||
CONTENT_API_KEY=782y497821y491y3981212
|
||||
@@ -0,0 +1,110 @@
|
||||
# Gnommo Content Skills
|
||||
|
||||
Skills for generating content for the Gnommo/Glitch.University learning platform.
|
||||
|
||||
## Available Skills
|
||||
|
||||
| Skill | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| DEGLITCH Gates | `deglitch-gate-generator.md` | Generate quiz questions from manuscripts |
|
||||
| Slide Content | `slide-content-generator.md` | Generate image prompts & text for slides |
|
||||
|
||||
---
|
||||
|
||||
# DEGLITCH Gate Generator
|
||||
|
||||
Generate quiz questions from manuscript content for the Gnommo learning platform.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Read `manuscript.txt` (or specified file)
|
||||
2. Identify 3-7 key concepts
|
||||
3. Create 1-2 questions per concept
|
||||
4. Output JSON or submit via API
|
||||
|
||||
## Project Structure
|
||||
|
||||
Each video project has:
|
||||
- `manuscript.txt` - The narration script with `[SX]` slide markers
|
||||
- `project.json` - Contains `coursecode` to identify the tech on the server
|
||||
|
||||
## API Configuration
|
||||
|
||||
```
|
||||
Base URL: ${GNOMMO_API_URL:-http://localhost:3001}
|
||||
Auth: Authorization: Bearer ${CONTENT_API_KEY}
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/content/techs/available` - Find tech_id to link
|
||||
- `POST /api/content/deglitch-gates` - Create gate
|
||||
- `GET /api/content/deglitch-gates` - List gates
|
||||
- `PUT /api/content/deglitch-gates/:id` - Update gate
|
||||
|
||||
## Question JSON Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"tech_id": null,
|
||||
"title": "Gate Title",
|
||||
"description": "What this tests",
|
||||
"passing_score": 0.8,
|
||||
"shuffle_questions": true,
|
||||
"shuffle_options": true,
|
||||
"questions": [
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "Question?",
|
||||
"sort_order": 0,
|
||||
"options": {
|
||||
"a": { "answer": "Wrong", "correct": false, "why": "Explanation" },
|
||||
"b": { "answer": "Right", "correct": true, "why": "Explanation" },
|
||||
"c": { "answer": "Wrong", "correct": false, "why": "Explanation" },
|
||||
"d": { "answer": "Wrong", "correct": false, "why": "Explanation" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Question Types
|
||||
- `radio` - Single answer (most common)
|
||||
- `checkbox` - Multiple answers
|
||||
- `llm` - Free text (AI evaluated)
|
||||
|
||||
## Quality Guidelines
|
||||
|
||||
- Test understanding, not memorization
|
||||
- One clear correct answer per radio question
|
||||
- Plausible wrong answers with educational "why"
|
||||
- Concise questions, avoid trick questions
|
||||
- Vary difficulty across questions
|
||||
|
||||
## Workflow with API Key
|
||||
|
||||
```bash
|
||||
# 1. Read project.json to get coursecode
|
||||
cat /path/to/video/project.json | jq '.coursecode'
|
||||
|
||||
# 2. Find tech_id by matching coursecode
|
||||
curl -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
$GNOMMO_API_URL/api/content/techs
|
||||
|
||||
# 3. Create gate with matched tech_id
|
||||
curl -X POST -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
$GNOMMO_API_URL/api/content/deglitch-gates \
|
||||
-d '{"tech_id": 1, "title":"...","questions":[...]}'
|
||||
```
|
||||
|
||||
## Matching Coursecode to Tech
|
||||
|
||||
The `coursecode` in `project.json` matches the `code` field in the server's tech list:
|
||||
- `♟️_#1.0` → Lightlane series, Video 1
|
||||
- `♟️_#2.0` → Lightlane series, Video 2
|
||||
- `WTF_#1` → What is Glitch University series, Video 1
|
||||
|
||||
## Workflow without API Key
|
||||
|
||||
Output the complete JSON for manual entry or later API submission.
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
# glitch gate API Helper Script
|
||||
# Usage: source this file, then use the functions
|
||||
|
||||
# Configuration - set these or export before sourcing
|
||||
GNOMMO_API_URL="${GNOMMO_API_URL:-http://localhost:3001}"
|
||||
# CONTENT_API_KEY should be set in environment
|
||||
|
||||
# Check if API key is set
|
||||
check_api_key() {
|
||||
if [ -z "$CONTENT_API_KEY" ]; then
|
||||
echo "Error: CONTENT_API_KEY not set"
|
||||
echo "Run: export CONTENT_API_KEY=your-key-here"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# List all techs
|
||||
list_techs() {
|
||||
check_api_key || return 1
|
||||
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/techs" | jq
|
||||
}
|
||||
|
||||
# List techs without gates (available for linking)
|
||||
list_available_techs() {
|
||||
check_api_key || return 1
|
||||
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/techs/available" | jq
|
||||
}
|
||||
|
||||
# List all glitch gates
|
||||
list_gates() {
|
||||
check_api_key || return 1
|
||||
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||
}
|
||||
|
||||
# Get a specific gate by ID
|
||||
get_gate() {
|
||||
check_api_key || return 1
|
||||
local gate_id=$1
|
||||
if [ -z "$gate_id" ]; then
|
||||
echo "Usage: get_gate <gate_id>"
|
||||
return 1
|
||||
fi
|
||||
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||
}
|
||||
|
||||
# Create a gate from JSON file
|
||||
create_gate() {
|
||||
check_api_key || return 1
|
||||
local json_file=$1
|
||||
if [ -z "$json_file" ]; then
|
||||
echo "Usage: create_gate <json_file>"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -f "$json_file" ]; then
|
||||
echo "Error: File not found: $json_file"
|
||||
return 1
|
||||
fi
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @"$json_file" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||
}
|
||||
|
||||
# Create a gate from JSON string
|
||||
create_gate_json() {
|
||||
check_api_key || return 1
|
||||
local json_data=$1
|
||||
if [ -z "$json_data" ]; then
|
||||
echo "Usage: create_gate_json '<json_string>'"
|
||||
return 1
|
||||
fi
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$json_data" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||
}
|
||||
|
||||
# Update a gate from JSON file
|
||||
update_gate() {
|
||||
check_api_key || return 1
|
||||
local gate_id=$1
|
||||
local json_file=$2
|
||||
if [ -z "$gate_id" ] || [ -z "$json_file" ]; then
|
||||
echo "Usage: update_gate <gate_id> <json_file>"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -f "$json_file" ]; then
|
||||
echo "Error: File not found: $json_file"
|
||||
return 1
|
||||
fi
|
||||
curl -s -X PUT \
|
||||
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @"$json_file" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||
}
|
||||
|
||||
# Delete a gate
|
||||
delete_gate() {
|
||||
check_api_key || return 1
|
||||
local gate_id=$1
|
||||
if [ -z "$gate_id" ]; then
|
||||
echo "Usage: delete_gate <gate_id>"
|
||||
return 1
|
||||
fi
|
||||
read -p "Delete gate $gate_id? (y/N) " confirm
|
||||
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||
else
|
||||
echo "Cancelled"
|
||||
fi
|
||||
}
|
||||
|
||||
# Print available commands
|
||||
deglitch_help() {
|
||||
echo "glitch gate API Commands:"
|
||||
echo ""
|
||||
echo " list_techs - List all techs"
|
||||
echo " list_available_techs - List techs without gates"
|
||||
echo " list_gates - List all glitch gates"
|
||||
echo " get_gate <id> - Get gate details"
|
||||
echo " create_gate <file> - Create gate from JSON file"
|
||||
echo " create_gate_json '<json>' - Create gate from JSON string"
|
||||
echo " update_gate <id> <file> - Update gate from JSON file"
|
||||
echo " delete_gate <id> - Delete a gate"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " GNOMMO_API_URL=$GNOMMO_API_URL"
|
||||
echo " CONTENT_API_KEY=$([ -n "$CONTENT_API_KEY" ] && echo "[set]" || echo "[not set]")"
|
||||
}
|
||||
|
||||
echo "DEGLITCH API helper loaded. Run 'deglitch_help' for commands."
|
||||
@@ -0,0 +1,251 @@
|
||||
# glitch gate Generator Skill
|
||||
|
||||
You are a quiz/assessment generator for the Gnommo learning platform. Your job is to read educational manuscript content and create glitch gate questions that test understanding.
|
||||
|
||||
## What is a glitch gate?
|
||||
|
||||
A glitch gate is a quiz that learners must pass to demonstrate mastery of a tech (lesson). Gates have:
|
||||
- A title and description
|
||||
- A passing score (default 80%)
|
||||
- Multiple questions with explanations for why each answer is correct/incorrect
|
||||
|
||||
## Question Philosophy
|
||||
|
||||
Create questions that test **understanding**, not memorization:
|
||||
|
||||
- **Intuition questions**: Test pattern recognition and conceptual understanding
|
||||
- **Grit questions**: Present tricky scenarios requiring careful thinking
|
||||
- **Craft questions**: Test precise technical knowledge and attention to detail
|
||||
|
||||
### Good Question Characteristics
|
||||
- Tests a single concept clearly
|
||||
- Has one unambiguously correct answer
|
||||
- Wrong answers are plausible (not obviously wrong)
|
||||
- Each answer has a "why" explanation
|
||||
- Avoids trick questions or gotchas
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Read the Manuscript
|
||||
|
||||
First, read the manuscript file to understand the content:
|
||||
|
||||
```
|
||||
Read the file: manuscript.txt
|
||||
```
|
||||
|
||||
Or if given a specific path:
|
||||
```
|
||||
Read the file: /path/to/manuscript.txt
|
||||
```
|
||||
|
||||
### Step 2: Identify Key Concepts
|
||||
|
||||
After reading, identify 3-7 key concepts that learners should understand. Consider:
|
||||
- Core principles explained in the text
|
||||
- Common misconceptions to address
|
||||
- Practical applications mentioned
|
||||
- Relationships between concepts
|
||||
|
||||
### Step 3: Generate Questions
|
||||
|
||||
For each key concept, create 1-2 questions. Use this JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"tech_id": null,
|
||||
"title": "Gate Title Based on Content",
|
||||
"description": "Brief description of what this gate tests",
|
||||
"passing_score": 0.8,
|
||||
"shuffle_questions": true,
|
||||
"shuffle_options": true,
|
||||
"is_active": true,
|
||||
"questions": [
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "Question text here?",
|
||||
"sort_order": 0,
|
||||
"options": {
|
||||
"a": {
|
||||
"answer": "First option",
|
||||
"correct": false,
|
||||
"why": "Explanation of why this is incorrect"
|
||||
},
|
||||
"b": {
|
||||
"answer": "Second option (correct)",
|
||||
"correct": true,
|
||||
"why": "Explanation of why this is correct"
|
||||
},
|
||||
"c": {
|
||||
"answer": "Third option",
|
||||
"correct": false,
|
||||
"why": "Explanation of why this is incorrect"
|
||||
},
|
||||
"d": {
|
||||
"answer": "Fourth option",
|
||||
"correct": false,
|
||||
"why": "Explanation of why this is incorrect"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Question Types
|
||||
|
||||
- `radio` - Single correct answer (most common)
|
||||
- `checkbox` - Multiple correct answers
|
||||
- `llm` - Free text evaluated by AI (use sparingly)
|
||||
|
||||
## Step 4: Submit to API (if API key available)
|
||||
|
||||
If you have the Content API key, you can directly create the gate:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/content/deglitch-gates \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d 'YOUR_JSON_HERE'
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/content/techs` | List all techs to find tech_id |
|
||||
| GET | `/api/content/techs/available` | Techs without gates |
|
||||
| GET | `/api/content/deglitch-gates` | List existing gates |
|
||||
| POST | `/api/content/deglitch-gates` | Create new gate |
|
||||
| PUT | `/api/content/deglitch-gates/:id` | Update gate |
|
||||
| DELETE | `/api/content/deglitch-gates/:id` | Delete gate |
|
||||
|
||||
### Finding the Right Tech ID
|
||||
|
||||
Before creating a gate, list available techs to find the correct `tech_id`:
|
||||
|
||||
```bash
|
||||
curl -X GET https://your-domain.com/api/content/techs/available \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
## Example: Complete Question Set
|
||||
|
||||
Here's an example of a well-structured gate for an "Atomic Structure" lesson:
|
||||
|
||||
```json
|
||||
{
|
||||
"tech_id": 1,
|
||||
"title": "Atomic Structure Fundamentals",
|
||||
"description": "Test your understanding of basic atomic structure and the components of atoms.",
|
||||
"passing_score": 0.8,
|
||||
"shuffle_questions": true,
|
||||
"shuffle_options": true,
|
||||
"is_active": true,
|
||||
"questions": [
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "What determines the chemical properties of an atom?",
|
||||
"sort_order": 0,
|
||||
"options": {
|
||||
"a": {
|
||||
"answer": "The number of neutrons",
|
||||
"correct": false,
|
||||
"why": "Neutrons affect atomic mass and stability, but not chemical properties directly."
|
||||
},
|
||||
"b": {
|
||||
"answer": "The number of protons",
|
||||
"correct": false,
|
||||
"why": "Protons determine the element, but electrons determine how it bonds."
|
||||
},
|
||||
"c": {
|
||||
"answer": "The number of electrons in the outer shell",
|
||||
"correct": true,
|
||||
"why": "Valence electrons determine how an atom bonds with others, defining its chemical behavior."
|
||||
},
|
||||
"d": {
|
||||
"answer": "The total atomic mass",
|
||||
"correct": false,
|
||||
"why": "Atomic mass affects physical properties like density, not chemical reactivity."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "An atom has 6 protons and 8 neutrons. What element is it?",
|
||||
"sort_order": 1,
|
||||
"options": {
|
||||
"a": {
|
||||
"answer": "Oxygen",
|
||||
"correct": false,
|
||||
"why": "Oxygen has 8 protons. The number of protons defines the element."
|
||||
},
|
||||
"b": {
|
||||
"answer": "Carbon",
|
||||
"correct": true,
|
||||
"why": "Carbon has 6 protons. This is carbon-14, an isotope with 8 neutrons."
|
||||
},
|
||||
"c": {
|
||||
"answer": "Nitrogen",
|
||||
"correct": false,
|
||||
"why": "Nitrogen has 7 protons."
|
||||
},
|
||||
"d": {
|
||||
"answer": "Carbon-14 is not carbon",
|
||||
"correct": false,
|
||||
"why": "Isotopes are variants of the same element. Carbon-14 is still carbon."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "Why are noble gases chemically inert?",
|
||||
"sort_order": 2,
|
||||
"options": {
|
||||
"a": {
|
||||
"answer": "They have no electrons",
|
||||
"correct": false,
|
||||
"why": "Noble gases have electrons; helium has 2, neon has 10, etc."
|
||||
},
|
||||
"b": {
|
||||
"answer": "Their outer electron shell is full",
|
||||
"correct": true,
|
||||
"why": "A full valence shell means no tendency to gain, lose, or share electrons."
|
||||
},
|
||||
"c": {
|
||||
"answer": "They are too heavy to react",
|
||||
"correct": false,
|
||||
"why": "Mass doesn't determine reactivity. Francium is heavy but highly reactive."
|
||||
},
|
||||
"d": {
|
||||
"answer": "They only exist at very low temperatures",
|
||||
"correct": false,
|
||||
"why": "Noble gases exist at all temperatures; they're gases at room temperature."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Tips for Quality Questions
|
||||
|
||||
1. **Start with the concept**, then craft the question around it
|
||||
2. **Make wrong answers educational** - the "why" should teach something
|
||||
3. **Vary difficulty** - include some easier and some harder questions
|
||||
4. **Avoid "all of the above"** or "none of the above" options
|
||||
5. **Keep questions concise** - if it needs a lot of context, split it
|
||||
6. **Test understanding, not recall** - ask "why" and "how", not just "what"
|
||||
|
||||
## Environment Variables
|
||||
|
||||
If using the API programmatically, you need:
|
||||
- `CONTENT_API_KEY` - Your API key for authentication
|
||||
- API base URL (e.g., `https://gnommo.com` or `http://localhost:3001`)
|
||||
|
||||
## Output Format
|
||||
|
||||
When generating questions without API access, output:
|
||||
1. A summary of key concepts identified
|
||||
2. The complete JSON structure ready to copy
|
||||
3. Any notes about the questions or suggestions for the tech linking
|
||||
@@ -0,0 +1,165 @@
|
||||
# Slide Content Generator Skill
|
||||
|
||||
Generate slide content (image prompts or text) from Gnommo manuscript files.
|
||||
|
||||
## Context
|
||||
|
||||
Gnommo presentations use a **square slide area next to a talking head**. Slides should be:
|
||||
- Visually impactful but not cluttered
|
||||
- Timed to appear with the first word after the `[SX]` marker
|
||||
- Either **image-based** (generated via AI) or **text-based** (minimal, punchy text)
|
||||
|
||||
## Manuscript Format
|
||||
|
||||
Manuscripts use slide markers like `[S1]`, `[S2]`, etc. The content following each marker is what the presenter says while that slide is displayed.
|
||||
|
||||
```
|
||||
[S1]
|
||||
Welcome to the course...
|
||||
|
||||
[S2]
|
||||
What if the universe is discrete?
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Read the Manuscript
|
||||
|
||||
```
|
||||
Read the file: /path/to/manuscript.txt
|
||||
```
|
||||
|
||||
### Step 2: Analyze Each Slide
|
||||
|
||||
For each `[SX]` marker, determine:
|
||||
1. **What is the core message?** - The key idea being communicated
|
||||
2. **Visual or text?** - Would an image or text better support the message?
|
||||
3. **Emotional tone?** - Dramatic, contemplative, humorous, technical?
|
||||
|
||||
### Step 3: Generate Content
|
||||
|
||||
For each slide, output one of:
|
||||
|
||||
#### IMAGE PROMPT
|
||||
For conceptual, emotional, or complex ideas that benefit from visualization.
|
||||
|
||||
```
|
||||
**[SX]** - "First few words..."
|
||||
**IMAGE PROMPT:**
|
||||
`Detailed description for AI image generation, style, mood, composition, lighting, specific elements to include`
|
||||
```
|
||||
|
||||
#### TEXT SLIDE
|
||||
For lists, key terms, definitions, or when words ARE the point.
|
||||
|
||||
```
|
||||
**[SX]** - "First few words..."
|
||||
**TEXT SLIDE:**
|
||||
```
|
||||
HEADLINE
|
||||
|
||||
• Bullet point
|
||||
• Another point
|
||||
```
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
### When to Use IMAGE PROMPTS
|
||||
|
||||
- Abstract concepts (e.g., "the fabric of spacetime")
|
||||
- Metaphors and analogies (e.g., "like changing engines while driving")
|
||||
- Emotional moments (e.g., "this sounds insane")
|
||||
- Scene-setting (e.g., "imagine a Minecraft universe")
|
||||
|
||||
### When to Use TEXT SLIDES
|
||||
|
||||
- Lists of items being enumerated
|
||||
- Technical terms being defined
|
||||
- Key questions or frameworks
|
||||
- Course titles, section headers
|
||||
- Quotes or key phrases
|
||||
|
||||
### Image Prompt Best Practices
|
||||
|
||||
1. **Be specific about style**: "isometric illustration", "cinematic lighting", "minimal vector style"
|
||||
2. **Include mood/tone**: "mysterious", "hopeful", "dramatic contrast"
|
||||
3. **Describe composition**: "split image", "centered subject", "deep space background"
|
||||
4. **Avoid text in images**: AI image generators struggle with text - use text slides instead
|
||||
5. **Keep it achievable**: Don't describe impossibly complex scenes
|
||||
|
||||
### Text Slide Best Practices
|
||||
|
||||
1. **Minimal words**: 3-7 words per line, 1-5 lines max
|
||||
2. **Use hierarchy**: HEADLINES in caps, details below
|
||||
3. **Bullets for lists**: Keep them short and scannable
|
||||
4. **Leave breathing room**: Don't fill the entire square
|
||||
|
||||
## Output Format
|
||||
|
||||
Output slides in order, with clear separation:
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
**[S1]** - "First words of narration..."
|
||||
**TYPE:** (IMAGE PROMPT or TEXT SLIDE)
|
||||
Content here
|
||||
|
||||
---
|
||||
|
||||
**[S2]** - "First words of narration..."
|
||||
...
|
||||
```
|
||||
|
||||
## Example Output
|
||||
|
||||
---
|
||||
|
||||
**[S1]** - "Welcome to Glitch.University..."
|
||||
**TEXT SLIDE:**
|
||||
```
|
||||
GLITCH.UNIVERSITY
|
||||
WTF_#1
|
||||
|
||||
What is Glitch University?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**[S2]** - "What if the universe is fundamentally discrete..."
|
||||
**IMAGE PROMPT:**
|
||||
`A hyper-detailed Minecraft-style voxel universe, showing galaxies and stars rendered as tiny glowing cubes, deep space background with blocky nebulae, cosmic scale but pixelated, dark background with vibrant cube-shaped stars, cinematic lighting`
|
||||
|
||||
---
|
||||
|
||||
## Customization Options
|
||||
|
||||
### Style Presets
|
||||
|
||||
You can request specific visual styles:
|
||||
- **Tech/Corporate**: Clean vectors, isometric, blues and whites
|
||||
- **Cosmic/Physics**: Deep space, nebulae, particle effects
|
||||
- **Playful/Minecraft**: Voxels, bright colors, blocky
|
||||
- **Philosophical**: Abstract, minimal, contemplative
|
||||
- **Dramatic**: High contrast, cinematic, intense lighting
|
||||
|
||||
### Text Tone
|
||||
|
||||
- **Academic**: Formal terminology, structured
|
||||
- **Casual**: Conversational, approachable
|
||||
- **Punchy**: Short, impactful, memorable
|
||||
|
||||
## Integration with Gnommo
|
||||
|
||||
The generated content can be used to:
|
||||
1. Create slides in Keynote/PowerPoint
|
||||
2. Generate images via Midjourney/DALL-E/Stable Diffusion
|
||||
3. Populate the `slides.json` file in the project's media folder
|
||||
|
||||
## Tips
|
||||
|
||||
- Read the ENTIRE manuscript first to understand the arc
|
||||
- Match slide density to pacing - fast sections need simpler slides
|
||||
- Create visual continuity - recurring metaphors should have consistent imagery
|
||||
- Consider what the talking head is doing - slides complement, not compete
|
||||
@@ -0,0 +1,476 @@
|
||||
# Gnommo Feature Development Roadmap
|
||||
|
||||
## Overview
|
||||
Features to standardize the Keynote-to-YouTube workflow, so that once the presentation is complete, only a standardized recording session stands between you and a finished video.
|
||||
|
||||
---
|
||||
|
||||
## 1. Video Description Generator
|
||||
|
||||
**Command:** `gnommo -p <project> description`
|
||||
|
||||
Generate a complete YouTube description with citations, attributions, and chapters.
|
||||
|
||||
---
|
||||
|
||||
### 1.1 Manuscript Citations (`[cite:...]`)
|
||||
|
||||
Citations embedded in the manuscript represent sources, references, or links mentioned during narration. The text after `cite:` is the **literal reference** that should appear in the description.
|
||||
|
||||
**Format in manuscript.txt:**
|
||||
```
|
||||
[cite:Reference text exactly as it should appear]
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
[S3]
|
||||
According to this study [cite:Smith et al. (2024) "Effects of AI on Productivity" - https://example.com/paper],
|
||||
the effect is significant.
|
||||
|
||||
[S7]
|
||||
I'm using [cite:Keynote by Apple - https://apple.com/keynote] for all my presentations.
|
||||
|
||||
[S12]
|
||||
This technique was pioneered by [cite:Dr. Jane Doe, MIT Media Lab].
|
||||
```
|
||||
|
||||
**Output in description:**
|
||||
```
|
||||
SOURCES & REFERENCES
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
1:23 - Smith et al. (2024) "Effects of AI on Productivity" - https://example.com/paper
|
||||
4:56 - Keynote by Apple - https://apple.com/keynote
|
||||
8:30 - Dr. Jane Doe, MIT Media Lab
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Parse `[cite:...]` markers from manuscript.txt
|
||||
- Extract the literal text after `cite:` as the reference
|
||||
- Align citations to timestamps (same fuzzy matching as other markers)
|
||||
- Group citations in order of appearance
|
||||
- Citations are NOT aligned for rendering (ignored by renderer) but ARE timestamped for description
|
||||
|
||||
**Note:** `[cite:...]` markers should not affect video rendering or narration alignment - they are metadata-only markers for description generation.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Pexels/Stock Footage Attribution
|
||||
|
||||
Attribution for Pexels content is **not legally required** but is appreciated and professional.
|
||||
|
||||
**Official Pexels attribution format:**
|
||||
```
|
||||
by [Contributor Name] via Pexels
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Extend `videos.json` to include attribution metadata:
|
||||
```json
|
||||
{
|
||||
"beach_waves": {
|
||||
"source_file": "pexels/beach.mp4",
|
||||
"is_shared": true,
|
||||
"attribution": {
|
||||
"source": "pexels",
|
||||
"creator": "John Doe",
|
||||
"url": "https://pexels.com/video/12345"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Auto-detect Pexels videos from `shared_assets/pexels/` folder
|
||||
- Support Pexels metadata JSON files (if downloaded with video)
|
||||
- Generate attribution section for video description:
|
||||
```
|
||||
STOCK FOOTAGE
|
||||
━━━━━━━━━━━━━
|
||||
Beach waves by John Doe via Pexels: https://pexels.com/video/12345
|
||||
City timelapse by Jane Smith via Pexels: https://pexels.com/video/67890
|
||||
```
|
||||
|
||||
**Pexels License Notes** (from pexels.com/license):
|
||||
- Free for personal and commercial use
|
||||
- Attribution not required but appreciated
|
||||
- Cannot sell unaltered copies
|
||||
- Cannot redistribute on other stock platforms
|
||||
|
||||
### 1.3 Complete Description Output
|
||||
|
||||
**Output file:** `out/description_youtube.txt`
|
||||
|
||||
Combine all elements into a ready-to-paste YouTube description.
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
[Video description from project.json "description" field]
|
||||
|
||||
CHAPTERS
|
||||
━━━━━━━━
|
||||
0:00 Introduction
|
||||
1:23 Topic One
|
||||
3:45 Topic Two
|
||||
...
|
||||
|
||||
REFERENCES
|
||||
━━━━━━━━━━
|
||||
1:23 - Smith et al. (2024) "AI Study" - https://example.com
|
||||
4:56 - Keynote by Apple - https://apple.com/keynote
|
||||
...
|
||||
|
||||
STOCK FOOTAGE
|
||||
━━━━━━━━━━━━━
|
||||
Beach waves by John Doe via Pexels: https://pexels.com/video/12345
|
||||
...
|
||||
|
||||
[Optional footer from project.json "footer" field - social links, subscribe CTA, etc.]
|
||||
```
|
||||
|
||||
**project.json additions:**
|
||||
```json
|
||||
{
|
||||
"description": "In this video, I walk through the complete Gnommo workflow for creating YouTube videos from Keynote presentations.",
|
||||
"footer": "Subscribe for more tutorials: https://youtube.com/@channel\nTwitter: https://twitter.com/handle"
|
||||
}
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Pull video description from `project.json` "description" field
|
||||
- Generate chapters from slide markers (see Section 2)
|
||||
- Collect all `[cite:...]` references with timestamps
|
||||
- Collect all Pexels/stock attributions from `videos.json`
|
||||
- Append optional footer from `project.json` "footer" field
|
||||
- Output to `out/description_youtube.txt`
|
||||
- Sections with no content are omitted (e.g., no STOCK FOOTAGE section if none used)
|
||||
|
||||
---
|
||||
|
||||
## 2. YouTube Chapter Markers
|
||||
|
||||
**Command:** `gnommo -p <project> chapters`
|
||||
|
||||
Auto-generate chapter timestamps from slide markers.
|
||||
|
||||
**Requirements:**
|
||||
- Extract chapter titles from:
|
||||
- Keynote slide titles (via presenter notes import)
|
||||
- First sentence after each `[SN]` marker
|
||||
- Optional `[chapter:Title]` markers for explicit chapter names
|
||||
- Calculate timestamps from aligned marker timings
|
||||
- Output copy-paste ready format:
|
||||
```
|
||||
CHAPTERS
|
||||
━━━━━━━━
|
||||
0:00 Introduction
|
||||
1:23 What is Gnommo?
|
||||
3:45 Setting Up Your Project
|
||||
7:12 Recording Tips
|
||||
10:30 Rendering Your Video
|
||||
12:45 Outro
|
||||
```
|
||||
- Option to merge small chapters (minimum duration threshold)
|
||||
- Support for nested chapters (main topics + subtopics)
|
||||
|
||||
---
|
||||
|
||||
## 3. Subtitle/Caption Export
|
||||
|
||||
**Command:** `gnommo -p <project> subtitles`
|
||||
|
||||
Generate subtitle files from Whisper transcription.
|
||||
|
||||
**Requirements:**
|
||||
- Export formats: SRT, VTT, TXT
|
||||
- Use existing word-level timestamps from transcription
|
||||
- Smart line breaking (max characters per line, break at punctuation)
|
||||
- Speaker diarization support (future: multiple speakers)
|
||||
- Options:
|
||||
- `--format srt|vtt|txt`
|
||||
- `--max-chars 42` (characters per line)
|
||||
- `--max-duration 5` (seconds per subtitle block)
|
||||
|
||||
**Example output (SRT):**
|
||||
```
|
||||
1
|
||||
00:00:01,500 --> 00:00:04,200
|
||||
Hello and welcome to this tutorial
|
||||
on video editing with Gnommo.
|
||||
|
||||
2
|
||||
00:00:04,500 --> 00:00:07,800
|
||||
Today we're going to cover
|
||||
the complete workflow.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Thumbnail Generation
|
||||
|
||||
**Command:** `gnommo -p <project> thumbnail`
|
||||
|
||||
Auto-generate thumbnail candidates from slides.
|
||||
|
||||
**Requirements:**
|
||||
- Designate thumbnail slides with `[thumbnail]` marker
|
||||
- If no marker, use slide 1 or title slide
|
||||
- Apply text overlays from config:
|
||||
```json
|
||||
{
|
||||
"thumbnail": {
|
||||
"title_text": "Episode ${episode_number}",
|
||||
"subtitle_text": "${title}",
|
||||
"font": "Impact",
|
||||
"text_color": "#FFFFFF",
|
||||
"outline_color": "#000000",
|
||||
"position": "bottom-left"
|
||||
}
|
||||
}
|
||||
```
|
||||
- Generate multiple variants:
|
||||
- With/without text overlay
|
||||
- Different zoom levels
|
||||
- Different color treatments (saturated, high contrast)
|
||||
- Output to `out/thumbnails/` folder
|
||||
- Resolution: 1280x720 (YouTube standard)
|
||||
|
||||
---
|
||||
|
||||
## 5. Intro/Outro Templates
|
||||
|
||||
**Configuration in project.json:**
|
||||
```json
|
||||
{
|
||||
"intro": {
|
||||
"template": "templates/intro_v2.mp4",
|
||||
"duration": 3.5,
|
||||
"transition": "fade",
|
||||
"variables": {
|
||||
"episode_number": "12",
|
||||
"title": "Getting Started with Gnommo"
|
||||
}
|
||||
},
|
||||
"outro": {
|
||||
"template": "templates/outro_subscribe.mp4",
|
||||
"duration": 8.0,
|
||||
"transition": "fade"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Define intro/outro templates in `shared_assets/templates/`
|
||||
- Auto-prepend intro before first slide
|
||||
- Auto-append outro after last slide
|
||||
- Support variable substitution in templates (episode number, title)
|
||||
- Configurable transition types (fade, cut, wipe)
|
||||
- End screen safe zone support (last 20 seconds)
|
||||
|
||||
---
|
||||
|
||||
## 6. Multi-Platform Format Presets
|
||||
|
||||
**Command:** `gnommo -p <project> render --format <preset>`
|
||||
|
||||
**Presets:**
|
||||
| Preset | Aspect | Resolution | Notes |
|
||||
|--------|--------|------------|-------|
|
||||
| `youtube` | 16:9 | 1920x1080 | Default, standard horizontal |
|
||||
| `youtube-4k` | 16:9 | 3840x2160 | 4K export |
|
||||
| `shorts` | 9:16 | 1080x1920 | Vertical, auto-reframe slides |
|
||||
| `podcast` | - | Audio only | MP3/M4A export for podcast feeds |
|
||||
| `square` | 1:1 | 1080x1080 | Instagram/LinkedIn |
|
||||
|
||||
**Requirements:**
|
||||
- Auto-adjust cutout positions per format
|
||||
- Smart slide reframing for vertical (zoom to content area)
|
||||
- Separate output folders per format
|
||||
- Batch export to multiple formats: `--format youtube,shorts,podcast`
|
||||
|
||||
---
|
||||
|
||||
## 7. Teleprompter Script Generation
|
||||
|
||||
**Command:** `gnommo -p <project> teleprompter`
|
||||
|
||||
Extract clean narration text for teleprompter display.
|
||||
|
||||
**Requirements:**
|
||||
- Strip all markers from manuscript
|
||||
- Keep only spoken text
|
||||
- Output formats:
|
||||
- `--format txt` - Plain text
|
||||
- `--format html` - Scrollable HTML page with large font
|
||||
- `--format json` - For teleprompter apps
|
||||
- Optional: Include slide thumbnails as visual cues
|
||||
- Configurable font size and scroll speed hints
|
||||
|
||||
**Example HTML output:**
|
||||
```html
|
||||
<div class="teleprompter">
|
||||
<p class="cue">[SLIDE: Introduction]</p>
|
||||
<p>Hello and welcome to this tutorial on video editing with Gnommo.</p>
|
||||
<p class="cue">[SLIDE: What is Gnommo?]</p>
|
||||
<p>Gnommo is a code-first video editing pipeline...</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Recording Checklist Generator
|
||||
|
||||
**Command:** `gnommo -p <project> checklist`
|
||||
|
||||
Generate a pre-recording checklist based on project configuration.
|
||||
|
||||
**Output includes:**
|
||||
- [ ] Camera settings (resolution, fps from project.json)
|
||||
- [ ] Lighting setup (if green screen detected in videos.json)
|
||||
- [ ] Audio check (microphone levels)
|
||||
- [ ] Props/demos needed (parsed from `[video:...]` markers)
|
||||
- [ ] Slide count and estimated duration
|
||||
- [ ] Teleprompter ready
|
||||
- [ ] Recording space clear
|
||||
|
||||
**Customizable via `checklist_template.md` in project folder.**
|
||||
|
||||
---
|
||||
|
||||
## 9. Audio Normalization
|
||||
|
||||
**Automatic during render or standalone command:**
|
||||
`gnommo -p <project> normalize`
|
||||
|
||||
**Requirements:**
|
||||
- Target: -14 LUFS (YouTube standard)
|
||||
- Apply loudness normalization to narration track
|
||||
- Preserve dynamic range (avoid over-compression)
|
||||
- Normalize intro/outro audio to match
|
||||
- Option: `--target-lufs -14`
|
||||
|
||||
**Implementation:**
|
||||
- Use FFmpeg `loudnorm` filter
|
||||
- Two-pass normalization for accurate results
|
||||
- Report before/after levels
|
||||
|
||||
---
|
||||
|
||||
## 10. Project Templates
|
||||
|
||||
**Command:** `gnommo init <project-name> --template <template>`
|
||||
|
||||
**Built-in templates:**
|
||||
| Template | Description |
|
||||
|----------|-------------|
|
||||
| `tutorial` | Talking head + slides, square slide layout |
|
||||
| `explainer` | Full-screen slides, minimal presenter |
|
||||
| `review` | Product review format, multiple camera angles |
|
||||
| `talking-head` | Full-screen presenter, no slides |
|
||||
| `screencast` | Screen recording with small presenter PIP |
|
||||
|
||||
**Requirements:**
|
||||
- Templates stored in `~/.gnommo/templates/` or `shared_assets/templates/`
|
||||
- Each template includes:
|
||||
- `project.json` with preset cutouts and settings
|
||||
- `manuscript.txt` skeleton with example markers
|
||||
- Sample `videos.json` structure
|
||||
- User can create custom templates: `gnommo template save <name>`
|
||||
|
||||
---
|
||||
|
||||
## 11. Batch Processing
|
||||
|
||||
**Command:** `gnommo batch render project1 project2 project3`
|
||||
|
||||
**Requirements:**
|
||||
- Process multiple projects in sequence
|
||||
- Continue on failure (don't stop batch for one failed project)
|
||||
- Summary report at end:
|
||||
```
|
||||
BATCH COMPLETE
|
||||
━━━━━━━━━━━━━━
|
||||
✓ project1 - rendered in 5:23
|
||||
✓ project2 - rendered in 4:17
|
||||
✗ project3 - failed (missing slide S12)
|
||||
```
|
||||
- Options:
|
||||
- `--parallel 2` - Run N renders in parallel
|
||||
- `--skip-existing` - Skip if `out/final.mp4` exists
|
||||
- `--format youtube,shorts` - Render all formats for each project
|
||||
|
||||
---
|
||||
|
||||
## 12. Progress Dashboard
|
||||
|
||||
**Command:** `gnommo status` or `gnommo -p <project> status`
|
||||
|
||||
Display pipeline status for all projects or specific project.
|
||||
|
||||
**Output:**
|
||||
```
|
||||
PROJECT STATUS
|
||||
━━━━━━━━━━━━━━
|
||||
Project Import Preprocess Transcribe Render Output
|
||||
─────────────────────────────────────────────────────────────
|
||||
video1 ✓ ✓ ✓ ✓ final.mp4 (12:34)
|
||||
video2 ✓ ✓ ✓ ✗ -
|
||||
video3 ✓ ✗ - - -
|
||||
video4 ✗ - - - -
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Scan all project directories
|
||||
- Check for existence of intermediate files
|
||||
- Show file timestamps and durations
|
||||
- Highlight what needs to be done next
|
||||
|
||||
---
|
||||
|
||||
## 13. Recording Session Mode (Future)
|
||||
|
||||
**Command:** `gnommo -p <project> session`
|
||||
|
||||
Live recording assistant mode.
|
||||
|
||||
**Features:**
|
||||
- Display current slide on secondary monitor
|
||||
- Show teleprompter text overlay
|
||||
- Keyboard shortcuts to advance slides
|
||||
- Real-time recording with proper settings
|
||||
- Auto-stop at end of manuscript
|
||||
- Voice command support: "next slide", "pause"
|
||||
|
||||
**Note:** This is a stretch goal requiring significant UI work.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1 - Core YouTube Workflow (High Impact)
|
||||
1. **Video Description Generator** (citations + Pexels attribution)
|
||||
2. **YouTube Chapter Markers**
|
||||
3. **Subtitle/Caption Export**
|
||||
4. **Audio Normalization**
|
||||
|
||||
### Phase 2 - Content Creation Efficiency
|
||||
5. **Thumbnail Generation**
|
||||
6. **Intro/Outro Templates**
|
||||
7. **Teleprompter Script Generation**
|
||||
8. **Recording Checklist Generator**
|
||||
|
||||
### Phase 3 - Scale & Automation
|
||||
9. **Project Templates**
|
||||
10. **Multi-Platform Format Presets**
|
||||
11. **Batch Processing**
|
||||
12. **Progress Dashboard**
|
||||
|
||||
### Phase 4 - Advanced
|
||||
13. **Recording Session Mode**
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All new commands should follow existing CLI pattern: `gnommo -p <project> <command>`
|
||||
- Output files go to `out/` subdirectory by default
|
||||
- All features should support `--dry-run` where applicable
|
||||
- Verbose mode (`-v`) should show detailed progress
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# to_mp3.sh — Convert .m4a and .wav files to .mp3
|
||||
#
|
||||
# Usage:
|
||||
# ./to_mp3.sh <file_or_folder> [options]
|
||||
#
|
||||
# Options:
|
||||
# --quality N VBR quality 0-9 (default: 2 ≈ 190kbps; lower = better)
|
||||
# --bitrate N CBR bitrate e.g. 192k (overrides --quality)
|
||||
# --replace Delete originals after successful conversion
|
||||
# --dry-run Show what would be converted without doing anything
|
||||
#
|
||||
# Examples:
|
||||
# ./to_mp3.sh recordings/
|
||||
# ./to_mp3.sh interview.m4a
|
||||
# ./to_mp3.sh recordings/ --replace
|
||||
# ./to_mp3.sh recordings/ --bitrate 128k
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
QUALITY=2
|
||||
BITRATE=""
|
||||
REPLACE=false
|
||||
DRY_RUN=false
|
||||
TARGET=""
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--quality) QUALITY="$2"; shift 2 ;;
|
||||
--bitrate) BITRATE="$2"; shift 2 ;;
|
||||
--replace) REPLACE=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TARGET" ]]; then
|
||||
echo "Usage: $(basename "$0") <file_or_folder> [--quality N] [--bitrate N] [--replace] [--dry-run]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v ffmpeg &>/dev/null; then
|
||||
echo "Error: ffmpeg not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect files
|
||||
files=()
|
||||
if [[ -f "$TARGET" ]]; then
|
||||
files=("$TARGET")
|
||||
elif [[ -d "$TARGET" ]]; then
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$TARGET" -maxdepth 1 -type f \( -iname "*.m4a" -o -iname "*.wav" \) -print0 | sort -z)
|
||||
else
|
||||
echo "Error: '$TARGET' is not a file or directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "No .m4a or .wav files found in: $TARGET"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build audio quality flags
|
||||
if [[ -n "$BITRATE" ]]; then
|
||||
audio_flags=(-b:a "$BITRATE")
|
||||
quality_desc="CBR ${BITRATE}"
|
||||
else
|
||||
audio_flags=(-q:a "$QUALITY")
|
||||
quality_desc="VBR quality ${QUALITY}"
|
||||
fi
|
||||
|
||||
echo "Converting ${#files[@]} file(s) to MP3 (${quality_desc})"
|
||||
[[ "$REPLACE" == true ]] && echo " Originals will be deleted after conversion."
|
||||
[[ "$DRY_RUN" == true ]] && echo " Dry-run mode — no files will be written."
|
||||
echo ""
|
||||
|
||||
converted=0
|
||||
skipped=0
|
||||
errors=0
|
||||
|
||||
for src in "${files[@]}"; do
|
||||
out="${src%.*}.mp3"
|
||||
|
||||
if [[ -f "$out" && "$DRY_RUN" == false ]]; then
|
||||
echo " $(basename "$src"): output already exists, skipping"
|
||||
((skipped++)) || true
|
||||
continue
|
||||
fi
|
||||
|
||||
size_mb=$(( $(stat -f%z "$src" 2>/dev/null || stat -c%s "$src") / 1048576 ))
|
||||
printf " %-40s (%d MB)" "$(basename "$src")" "$size_mb"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo " [dry-run] → $(basename "$out")"
|
||||
continue
|
||||
fi
|
||||
|
||||
if ffmpeg -i "$src" -vn "${audio_flags[@]}" -y "$out" -loglevel error; then
|
||||
out_kb=$(( $(stat -f%z "$out" 2>/dev/null || stat -c%s "$out") / 1024 ))
|
||||
echo " → $(basename "$out") (${out_kb} KB)"
|
||||
((converted++)) || true
|
||||
if [[ "$REPLACE" == true ]]; then
|
||||
rm "$src"
|
||||
fi
|
||||
else
|
||||
echo " ERROR"
|
||||
((errors++)) || true
|
||||
[[ -f "$out" ]] && rm "$out"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done: ${converted} converted, ${skipped} skipped, ${errors} errors."
|
||||
Executable
+300
@@ -0,0 +1,300 @@
|
||||
#!/bin/zsh
|
||||
#
|
||||
# Video Transcoding Script
|
||||
# Converts video files to H.265/HEVC at 1080p for significant size reduction
|
||||
#
|
||||
# Usage: ./transcode.sh <folder> [options]
|
||||
#
|
||||
# Options:
|
||||
# --replace Delete original files after successful transcoding
|
||||
# --dry-run Show what would be transcoded without doing it
|
||||
# --crf <N> Quality level (default: 20, lower=better quality, 18-28 typical)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
DEFAULT_CRF=18
|
||||
EXTENSIONS=("mov" "mp4" "m4v" "avi" "mkv" "mxf")
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
Video Transcoding Script
|
||||
|
||||
Converts video files to H.265/HEVC at 1080p for significant size reduction.
|
||||
Typically achieves 80-95% size reduction from uncompressed 4K footage.
|
||||
|
||||
Usage: $(basename "$0") <folder|file> [options]
|
||||
|
||||
Options:
|
||||
--replace Delete original files after successful transcoding
|
||||
--dry-run Show what would be transcoded without doing it
|
||||
--crf <N> Quality level (default: 23)
|
||||
Lower = better quality, larger files
|
||||
18 = visually lossless, 23 = default, 28 = smaller
|
||||
--help Show this help message
|
||||
|
||||
Output:
|
||||
Files are saved alongside originals with '_compressed.mp4' suffix.
|
||||
With --replace, originals are deleted after successful transcode.
|
||||
When processing a folder, files are sorted smallest-first.
|
||||
|
||||
Examples:
|
||||
$(basename "$0") ./video.mov # Transcode single file
|
||||
$(basename "$0") ./media/videos # Transcode folder (smallest first)
|
||||
$(basename "$0") ./media/videos --dry-run # Preview only
|
||||
$(basename "$0") ./media/videos --replace # Transcode and delete originals
|
||||
$(basename "$0") ./media/videos --crf 18 # Higher quality
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
FOLDER=""
|
||||
REPLACE=false
|
||||
DRY_RUN=false
|
||||
CRF=$DEFAULT_CRF
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--replace)
|
||||
REPLACE=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--crf)
|
||||
CRF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$FOLDER" ]]; then
|
||||
FOLDER="$1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate arguments
|
||||
if [[ -z "$FOLDER" ]]; then
|
||||
echo "Error: Folder path is required"
|
||||
echo ""
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ ! -d "$FOLDER" && ! -f "$FOLDER" ]]; then
|
||||
echo "Error: Path not found: $FOLDER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for ffmpeg
|
||||
if ! command -v ffmpeg &> /dev/null; then
|
||||
echo "Error: ffmpeg is not installed"
|
||||
echo "Install with: brew install ffmpeg"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build find pattern for video files
|
||||
build_find_pattern() {
|
||||
local pattern=""
|
||||
for ext in "${EXTENSIONS[@]}"; do
|
||||
if [[ -n "$pattern" ]]; then
|
||||
pattern="$pattern -o"
|
||||
fi
|
||||
pattern="$pattern -iname '*.$ext'"
|
||||
done
|
||||
echo "$pattern"
|
||||
}
|
||||
|
||||
# Format file size for display
|
||||
format_size() {
|
||||
local bytes=$1
|
||||
if (( bytes >= 1073741824 )); then
|
||||
printf "%.1fG" $(echo "scale=1; $bytes / 1073741824" | bc)
|
||||
elif (( bytes >= 1048576 )); then
|
||||
printf "%.1fM" $(echo "scale=1; $bytes / 1048576" | bc)
|
||||
else
|
||||
printf "%.1fK" $(echo "scale=1; $bytes / 1024" | bc)
|
||||
fi
|
||||
}
|
||||
|
||||
# Get file size in bytes
|
||||
get_size() {
|
||||
stat -f%z "$1" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
echo "========================================"
|
||||
echo "Video Transcoder"
|
||||
echo "========================================"
|
||||
echo "Folder: $FOLDER"
|
||||
echo "Codec: H.265/HEVC"
|
||||
echo "Resolution: 1080p (scaled down)"
|
||||
echo "Quality: CRF $CRF"
|
||||
echo "Replace: $REPLACE"
|
||||
[[ "$DRY_RUN" == true ]] && echo "DRY RUN: Yes"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Check if input is a file or folder
|
||||
IS_SINGLE_FILE=false
|
||||
if [[ -f "$FOLDER" ]]; then
|
||||
IS_SINGLE_FILE=true
|
||||
VIDEO_FILES=("$FOLDER")
|
||||
echo "Processing single file"
|
||||
echo ""
|
||||
else
|
||||
# Find all video files (excluding already compressed ones), sorted by size (smallest first)
|
||||
FIND_PATTERN=$(build_find_pattern)
|
||||
|
||||
# Use Python for robust sorting by size (handles spaces in paths correctly)
|
||||
VIDEO_FILES=()
|
||||
while IFS= read -r file; do
|
||||
VIDEO_FILES+=("$file")
|
||||
done < <(eval "find \"$FOLDER\" -type f \( $FIND_PATTERN \)" 2>/dev/null | python3 -c "
|
||||
import sys
|
||||
import os
|
||||
|
||||
files = []
|
||||
for line in sys.stdin:
|
||||
path = line.rstrip('\n')
|
||||
if '_compressed.' in path:
|
||||
continue
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
files.append((size, path))
|
||||
except:
|
||||
pass
|
||||
|
||||
files.sort(key=lambda x: x[0])
|
||||
for size, path in files:
|
||||
print(path)
|
||||
")
|
||||
|
||||
if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
|
||||
echo "No video files found in $FOLDER"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found ${#VIDEO_FILES[@]} video file(s) to process (smallest first)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Track totals
|
||||
TOTAL_ORIGINAL=0
|
||||
TOTAL_COMPRESSED=0
|
||||
SUCCESS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
# Process each file
|
||||
for input_file in "${VIDEO_FILES[@]}"; do
|
||||
# Generate output filename
|
||||
dir=$(dirname "$input_file")
|
||||
basename=$(basename "$input_file")
|
||||
name="${basename%.*}"
|
||||
output_file="$dir/${name}_compressed.mp4"
|
||||
|
||||
# Get original size
|
||||
original_size=$(get_size "$input_file")
|
||||
original_size_fmt=$(format_size $original_size)
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo "Input: $input_file ($original_size_fmt)"
|
||||
echo "Output: $output_file"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Action: [DRY RUN] Would transcode"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if output already exists
|
||||
if [[ -f "$output_file" ]]; then
|
||||
echo "Action: Skipped (output already exists)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Transcode with ffmpeg
|
||||
# -vf scale=-2:1080 = scale to 1080p height, auto width (divisible by 2)
|
||||
# -c:v libx265 = H.265/HEVC codec
|
||||
# -crf = quality (lower = better)
|
||||
# -preset medium = encoding speed/compression tradeoff
|
||||
# -c:a aac -b:a 128k = audio to AAC at 128kbps
|
||||
# -tag:v hvc1 = compatibility tag for Apple devices
|
||||
echo "Action: Transcoding..."
|
||||
|
||||
if ffmpeg -i "$input_file" \
|
||||
-vf "scale=-2:1080" \
|
||||
-c:v libx265 \
|
||||
-crf "$CRF" \
|
||||
-preset medium \
|
||||
-c:a aac -b:a 128k \
|
||||
-tag:v hvc1 \
|
||||
-y \
|
||||
"$output_file" \
|
||||
-loglevel warning -stats 2>&1; then
|
||||
|
||||
# Get compressed size
|
||||
compressed_size=$(get_size "$output_file")
|
||||
compressed_size_fmt=$(format_size $compressed_size)
|
||||
|
||||
# Calculate reduction
|
||||
if (( original_size > 0 )); then
|
||||
reduction=$(echo "scale=1; 100 - ($compressed_size * 100 / $original_size)" | bc)
|
||||
else
|
||||
reduction=0
|
||||
fi
|
||||
|
||||
echo "Result: $original_size_fmt → $compressed_size_fmt (${reduction}% reduction)"
|
||||
|
||||
TOTAL_ORIGINAL=$((TOTAL_ORIGINAL + original_size))
|
||||
TOTAL_COMPRESSED=$((TOTAL_COMPRESSED + compressed_size))
|
||||
((SUCCESS_COUNT++))
|
||||
|
||||
# Delete original if --replace is set
|
||||
if [[ "$REPLACE" == true ]]; then
|
||||
rm "$input_file"
|
||||
echo "Deleted: $input_file"
|
||||
fi
|
||||
else
|
||||
echo "Result: FAILED"
|
||||
((FAIL_COUNT++))
|
||||
# Remove partial output file if it exists
|
||||
[[ -f "$output_file" ]] && rm "$output_file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Summary"
|
||||
echo "========================================"
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "DRY RUN - no files were transcoded"
|
||||
else
|
||||
echo "Processed: $SUCCESS_COUNT succeeded, $FAIL_COUNT failed"
|
||||
if (( SUCCESS_COUNT > 0 )); then
|
||||
total_orig_fmt=$(format_size $TOTAL_ORIGINAL)
|
||||
total_comp_fmt=$(format_size $TOTAL_COMPRESSED)
|
||||
if (( TOTAL_ORIGINAL > 0 )); then
|
||||
total_reduction=$(echo "scale=1; 100 - ($TOTAL_COMPRESSED * 100 / $TOTAL_ORIGINAL)" | bc)
|
||||
else
|
||||
total_reduction=0
|
||||
fi
|
||||
echo "Total: $total_orig_fmt → $total_comp_fmt (${total_reduction}% reduction)"
|
||||
|
||||
if [[ "$REPLACE" == true ]]; then
|
||||
saved=$(format_size $((TOTAL_ORIGINAL - TOTAL_COMPRESSED)))
|
||||
echo "Freed: $saved"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "========================================"
|
||||
Reference in New Issue
Block a user