Compare commits

...

43 Commits

Author SHA1 Message Date
gitprov b9b5a8e77d Adding pexels downloader and fixes 2026-06-07 11:19:19 +02:00
gitprov 980bb84dac Fixing black formatting 2026-05-13 21:53:22 +02:00
gitprov 20aba06be1 Commit fix to time reader 2026-05-13 21:30:40 +02:00
gitprov 12b052eb1d Avoiding destructive down command when running all 2026-05-13 08:14:59 +02:00
gitprov cf40a19b4e Fixes to gnommo 2026-05-13 08:13:20 +02:00
gitprov 5d7c77db91 Adding fix to the slide 2026-05-12 21:11:33 +02:00
gitprov 87424a6531 Adding chunking to main render loop 2026-05-12 20:45:36 +02:00
gitprov 60e2f20b0f Adding performance tuning 2026-05-12 20:22:05 +02:00
gitprov 4a24d3987f Fixing the chunker 2026-05-12 20:16:28 +02:00
gitprov 7c53daec8a Adding fix to transpose 2026-05-12 19:57:28 +02:00
gitprov 41d96501b6 Fixes to performance 2026-05-12 19:49:15 +02:00
gitprov ff47ffea8f Fixing the issue 2026-05-12 08:16:30 +02:00
gitprov b4c48d81b0 Fxing the cache path 2026-05-12 08:07:12 +02:00
gitprov 409d7790c0 Fixing some filter paralleism 2026-05-12 08:04:45 +02:00
gitprov 994a2e0bb6 Fixing loudness issue 2026-05-12 00:52:14 +02:00
gitprov feb4df0506 Adding some files 2026-05-11 21:45:30 +02:00
gitprov b9376cd650 dding updates to gnommo 2026-05-11 08:23:21 +02:00
gitprov 0c2d097cdf Adding fix to aligner 2026-05-10 13:46:50 +02:00
gitprov 2dff8f45b9 Adding fixes to the publish pipeline 2026-05-09 15:36:15 +02:00
gitprov 00e01237ed Adding rsync --delete flag on up 2026-05-09 14:59:01 +02:00
gitprov 3a9e5d17e9 Updating the sync logic 2026-05-09 14:42:42 +02:00
gitprov dac6dfc48b Adding some more fixes for path 2026-05-09 13:09:41 +02:00
gitprov a351022a8f Adding some fixe 2026-05-09 13:06:37 +02:00
gitprov efd1eba5df fixing path issue on wsl 2026-05-09 12:55:33 +02:00
gitprov ad07de2e9a Git adding case insenstiive 2026-05-09 12:51:59 +02:00
gitprov e6a6968109 Tweaks ton esure that 2026-05-09 12:38:05 +02:00
gitprov d722272edc Adding ignoring processed as well 2026-05-09 12:31:17 +02:00
gitprov f8d359543a Add two way sync improvement 2026-05-09 12:18:26 +02:00
gitprov 12bf494f2d Fail gracefully on machines without osascript support 2026-05-09 12:11:36 +02:00
gitprov 831c0c4e60 Adding some bugfixes to the 'all' command 2026-05-09 12:06:15 +02:00
gitprov f0387f24bb Adding support for audio again 2026-05-08 08:08:08 +02:00
gitprov 26d027a44e Adding cache so we can sync via server 2026-05-04 20:31:37 +02:00
gitprov 2516e3eeef Add gnommo load command to copy projects from removable media
Adds the inverse of the archive command: `gnommo load -p <project>`
inspects the configured external drive and rsyncs the project folder
onto the local drive. Supports --dry-run. Also expands .gitignore to
cover additional media file types and project directories.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:05:12 +02:00
gitprov 4b4d6caacf Adding some fixes 2026-05-02 18:24:41 +02:00
gitprov 7c75610fce Fixing gnommo 2026-03-26 10:46:05 +01:00
gitprov 0e22fcfbb3 Adding fixes to alignment and also captain black! 2026-03-17 22:02:05 +01:00
gitprov e734dbfcac Commti prior to change to video tag below / above layering 2026-03-16 16:57:54 +01:00
gitprov 757d966803 Bugfixes 2026-03-15 11:13:09 +01:00
gitprov 6949124fa7 Adding fixes to the pipeline 2026-03-14 21:29:59 +01:00
gitprov b6bc5a0463 Adding push and pull commands 2026-03-14 12:28:52 +01:00
gitprov b21ca6b394 Adding handoff to Victor Viral 2026-03-13 12:01:59 +01:00
gitprov 3dcd7961c6 Adding handoff functionality for reviews 2026-03-13 11:10:32 +01:00
gitprov fdd275ac0e Adding changes version 1 2026-02-06 17:56:05 +01:00
61 changed files with 18448 additions and 959 deletions
+21 -2
View File
@@ -7,16 +7,35 @@ __pycache__/
venv/
.venv/
*.egg-info/
Video1/*
*.pdf
*.png
*.key
*.bak
shared_assets/*
Video*/*
Illustrations
# OS
.DS_Store
Thumbs.db
*/intermediate/*
# Output
**/out/
*.mp4
*.mov
*.mp3
*.aifc
*.wav
# Temp
*.tmp
.cache/
# Secrets
.env
.env.*
# Sync state (local only, per-environment)
.gnommo_sync.json
.gnommo_sync.prod.json
BIN
View File
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
[S1]
What if the universe isnt continuous?
What if it only looks smooth… because weve 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]
Dont 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"
}
}
+90
View File
@@ -0,0 +1,90 @@
{
"id": "GlitchTrailer",
"coursecode": "TRAILER",
"name": "Glitch University trailer",
"description": "Welcome to Glitch University.",
"hook": null,
"platform_targets": ["youtube"],
"status": "scripted",
"youtube_url": null,
"resolution": [1920, 1080],
"fps": 30,
"duration_seconds": null,
"default_filters": {
"audioonly": [
{
"type": "audio_normalize",
"enable":false,
"compress": false,
"normalize": true,
"target_lufs": -14,
"target_lra": 11,
"target_tp": -1.5
}
],
"talkinghead": [
{
"type": "audio_normalize",
"enable":false,
"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"
}
+368
View File
@@ -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
```
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
./gnommo.sh -p video1 all --force --prod
./gnommo.sh -p video2 all --force --prod
./gnommo.sh -p video3 all --force --prod
./gnommo.sh -p video4 all --force --prod
#./gnommo.sh -p video5 all --force
#./gnommo.sh -p video6 all --force
+23
View File
@@ -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"
}
}
}
}
}
Executable
+410
View File
@@ -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 "========================================"
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
claude --resume df8f915f-0f99-4e0f-b345-3562a49fcb06
+317
View File
@@ -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.
+265
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
[
{
"reference": "Gnommo Documentation - https://github.com/example/gnommo",
"context": ""
},
{
"reference": "FFmpeg Documentation - https://ffmpeg.org/documentation.html",
"context": ""
}
]
+37 -3
View File
@@ -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]
+16
View File
@@ -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"
}
}
+50
View File
@@ -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
}
]
+47
View File
@@ -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": []
}
}
+90 -7
View File
@@ -1,11 +1,94 @@
{
"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
},
"defaultSlideType": "square",
"background_video": ""
"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",
"output": "final.mp4",
"default_filters": {
"talkinghead": [
{
"type": "audio_normalize",
"enable":false,
"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
},
{
"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%"
}
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"S1": {
"image": "S1.png",
"type": "square"
},
"S2": {
"image": "S2.png",
"type": "square"
}
}
-8
View File
@@ -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 t word
2 0.00 Hello
3 0.30 world
4 0.60 [S1]
5 1.50 Second
6 1.80 slide
7 2.00 [S2]
8 2.50 End
-6
View File
@@ -1,6 +0,0 @@
{
"talking_head": {
"file": "media/talking_head.mp4",
"preprocess": []
}
}
+6 -139
View File
@@ -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 "$@"
-199
View File
@@ -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])
+199
View File
@@ -0,0 +1,199 @@
"""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
import os
from pathlib import Path
from typing import Optional, Tuple
_cache_config: Optional[dict] = None
_perf_config: Optional[dict] = None
def get_ffmpeg_thread_count() -> int:
"""Return FFmpeg thread count based on [performance] cpu_limit in ~/.gnommo.conf.
cpu_limit is a fraction of logical CPUs (e.g. 0.8 = 80%).
Defaults to 1 when not configured, which is safe on memory-constrained machines.
Example ~/.gnommo.conf:
[performance]
cpu_limit = 0.8
"""
global _perf_config
if _perf_config is None:
config_path = Path.home() / ".gnommo.conf"
_perf_config = {}
if config_path.exists():
cfg = configparser.ConfigParser()
cfg.read(config_path)
if cfg.has_option("performance", "cpu_limit"):
try:
_perf_config["cpu_limit"] = float(
cfg.get("performance", "cpu_limit")
)
except ValueError:
pass
cpu_limit = _perf_config.get("cpu_limit")
if cpu_limit is None:
return 1
cpu_count = os.cpu_count() or 1
return max(1, int(cpu_count * cpu_limit))
def get_render_chunk_size() -> Optional[int]:
"""Return slides-per-chunk for auto-chunked rendering, or None if not configured.
When set, cmd_render splits the filter graph into chunks of this many slides
to avoid OOM from allocating filter buffers for the entire video at once.
Example ~/.gnommo.conf:
[performance]
render_chunk_slides = 15
"""
global _perf_config
if _perf_config is None:
get_ffmpeg_thread_count() # populates _perf_config
val = _perf_config.get("render_chunk_slides")
if val is None:
return None
try:
return max(1, int(val))
except (ValueError, TypeError):
return 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
# Try 1: path inside the project → cache_base / project_name / relative
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 under project_path
# Try 2: path relative to gnommo root (sibling dirs like shared_assets)
# e.g. shared_assets/pexels/file.mp4 → cache_base / shared_assets / pexels / file.mp4
try:
relative = local_path.relative_to(project_path.parent)
cache_path = cache_base / relative
if cache_path.exists():
return cache_path, True
except ValueError:
pass # local_path is not under project_path.parent either
return local_path, False
def load_server_config() -> Optional[dict]:
"""Load server rsync config from ~/.gnommo.conf.
Expected config:
[server]
host = 76.13.144.52
user = root
path = /gnommo/project
Returns:
Dict with keys host, user, path (and optionally port), or None.
"""
config_path = Path.home() / ".gnommo.conf"
if not config_path.exists():
return None
config = configparser.ConfigParser()
config.read(config_path)
if not config.has_section("server"):
return None
host = config.get("server", "host", fallback=None)
user = config.get("server", "user", fallback="root")
path = config.get("server", "path", fallback="/gnommo/project")
port = config.get("server", "port", fallback="22")
if not host:
return None
return {"host": host, "user": user, "path": path, "port": port}
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)"
+4145 -172
View File
File diff suppressed because it is too large Load Diff
+358
View File
@@ -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
View File
@@ -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
+74
View File
@@ -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);
}
+93
View File
@@ -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()
+226
View File
@@ -0,0 +1,226 @@
"""Hand off a finished video to MinIO storage via gnommoeditor (prod) or gnommoweb (local).
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 (production):
- Uploads the video to MinIO via POST /api/assets/upload on gnommoeditor
- Updates .gnommo_sync.prod.json with asset URL
On success (local):
- Uploads via POST /api/projects/:handle/handoff on gnommoweb
- Updates .gnommo_sync.json with new video_version
Configuration (from .env or environment):
GNOMMOEDITOR_URL Base URL for production (e.g. https://editor.glitch.university)
GNOMMOWEB_URL Base URL for local dev (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("GNOMMOEDITOR_URL", "").rstrip("/")
if not api_url:
print("Error: GNOMMOEDITOR_URL 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 (gnommoeditor)" 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:
if prod:
# gnommoeditor: POST /api/assets/upload — field name is 'file', no auth
with open(video_path, "rb") as vf:
r = requests.post(
f"{api_url}/api/assets/upload",
files={"file": (video_path.name, vf, _mime_type(video_path))},
timeout=None,
)
else:
# gnommoweb: POST /api/projects/:id/handoff
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,
)
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()
# ── Write sync state ───────────────────────────────────────────────────────
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
existing_sync = _read_sync(project_path, prod)
if prod:
# gnommoeditor response: { asset: { id, url, minio_object_key, ... } }
asset = result.get("asset", {})
asset_url = asset.get("url", "")
_write_sync(
project_path,
{**existing_sync, "last_handoff_at": now_iso, "asset_url": asset_url},
prod,
)
print(f"{project_id} → uploaded [asset #{asset.get('id')}]")
if asset_url:
print(f" {asset_url}")
else:
# gnommoweb response: { video_version, video_url, asset: { updated_at } }
video_version = result.get("video_version", "?")
video_url = result.get("video_url", "")
_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")
+464 -37
View File
@@ -2,35 +2,82 @@
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
transcript_path: Optional[str] = None # path to transcript.json relative to project (always saved locally)
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)
process_cache: Optional[
str
] = None # external directory for processed/combined outputs (saves laptop disk space)
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 +85,239 @@ 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
# Luminance protection: pixels with luma above this stay fully opaque (0-255, -1 = off)
# Use ~220 to protect white objects (headphones, teeth) from being partially keyed.
protect_luma: int = -1
# Shadow boost: extra key strength for dark pixels (0.0-5.0, 0 = off)
# Ramps up key signal proportionally to how dark a pixel is, helping key dark greens
# without affecting bright foreground areas. Values 1.0-2.0 are typical.
shadow_boost: float = 0.0
# 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.
"""
enabled: bool = True # Master switch to enable/disable all audio processing
# 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" = on top of slides; "mid" = above narrator/below slides; "below" = behind narrator
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 +328,216 @@ 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, or to shared_assets/media/audio/ if is_shared)
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)
is_shared: bool = False # If True, file is relative to shared_assets/media/audio/
@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)
+613 -77
View File
@@ -1,28 +1,71 @@
"""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 _resolve_case_insensitive(path: Path) -> Path:
"""Return the real on-disk path, resolving each component case-insensitively.
On case-insensitive filesystems (macOS) paths just work. On case-sensitive
ones (Linux/WSL) a mismatch between project.json and the actual directory
name causes a FileNotFoundError. This walks each component and picks the
first directory entry whose name matches case-insensitively, returning the
corrected path. If the path already exists, it is returned unchanged.
"""
if path.exists():
return path
resolved = path.anchor and Path(path.anchor) or Path(".")
for part in path.parts[len(Path(path.anchor).parts) :]:
if (resolved / part).exists():
resolved = resolved / part
else:
try:
match = next(
(p for p in resolved.iterdir() if p.name.lower() == part.lower()),
None,
)
except (OSError, NotADirectoryError):
match = None
resolved = match if match else (resolved / part)
return resolved
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 +74,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], [vf2m:pexels/clip-name], etc.
# Include / and - to capture pexels/library video IDs; . to catch file extensions in markers.
markers = re.findall(r"\[([A-Za-z0-9_:./\-]+)\]", text)
# Find malformed markers (missing brackets, extra spaces, etc.)
malformed: list[tuple[int, str]] = []
@@ -56,48 +110,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,35 +186,88 @@ 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
resolution = data.get("resolution", [1920, 1080])
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"),
transcript_path=data.get("transcript"),
audio_source=data.get("audio_source"),
main_video=data.get("main_video"),
process_cache=data.get("process_cache"),
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 +288,27 @@ 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
# Lowercase the path so that a capital-cased project name embedded by
# the import stage (e.g. "media/slides/video2/slides.json") resolves
# correctly on case-sensitive filesystems (WSL/Linux).
local_slides_path = project_path / config.slides_path.lower()
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 +316,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 +326,319 @@ 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,
is_shared=bool(audio_data.get("is_shared", False)),
)
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
- "2m:3.5s" → 2 minutes 3.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 "h" in value:
value = value.replace("h", ":")
if "m" in value:
value = value.replace("m", ":")
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
f"Video '{video_id}' missing required field 'source_file'", videos_path
)
videos[video_id] = VideoSource(
file=video_data["file"],
preprocess=video_data.get("preprocess", []),
output_file=video_data.get("output_file"),
# 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 = float(video_data.get("skip") or 0.0)
take = (
float(video_data["take"])
if video_data.get("take") not in (None, "")
else None
)
return videos
# 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/start/end always override defaults
if "begin" in segment_data and segment_data["begin"]:
skip = parse_timestamp(segment_data["begin"])
elif "start" in segment_data and segment_data["start"]:
skip = parse_timestamp(segment_data["start"])
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 +647,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 +685,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 +701,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
@@ -300,3 +733,106 @@ def resolve_video_file(project_path: Path, file_ref: str) -> tuple[Path, Optiona
# Direct video file reference
return ref_path, None
def resolve_missing_videos(
missing_ids: list[str],
project_path: Path,
config: Optional[ProjectConfig] = None,
) -> dict[str, VideoSource]:
"""
For video IDs not found in the project's videos.json, look them up in
shared_assets/videos.json. When a match is found the entry is written back
into the project's videos.json with ``is_shared: true`` so subsequent runs
find it without another lookup.
Returns a dict of newly resolved VideoSource objects (only the ones found).
Silently ignores IDs that aren't in the shared library either.
"""
if not missing_ids:
return {}
# Locate shared_assets
shared_dir: Optional[Path] = None
if (project_path / "shared_assets").exists():
shared_dir = project_path / "shared_assets"
elif (project_path.parent / "shared_assets").exists():
shared_dir = project_path.parent / "shared_assets"
if shared_dir is None:
return {}
shared_videos_path = shared_dir / "videos.json"
if not shared_videos_path.exists():
return {}
try:
shared_data = _read_json(shared_videos_path)
except (json.JSONDecodeError, OSError):
return {}
found = {vid_id for vid_id in missing_ids if vid_id in shared_data}
if not found:
return {}
# Load the project's videos.json so we can append to it
if config and config.videos_path:
local_videos_path = project_path / config.videos_path
else:
local_videos_path = project_path / "videos.json"
try:
local_data = _read_json(local_videos_path) if local_videos_path.exists() else {}
except (json.JSONDecodeError, OSError):
local_data = {}
resolved: dict[str, VideoSource] = {}
for video_id in sorted(found):
entry = dict(shared_data[video_id])
entry["is_shared"] = True
# Persist into the project's videos.json
local_data[video_id] = entry
print(f" → Copied shared video '{video_id}' into videos.json (is_shared=true)")
# Build the in-memory VideoSource
attribution = None
if "attribution" in entry:
attr = entry["attribution"]
attribution = Attribution(
source=attr.get("source", "unknown"),
creator=attr.get("creator", "Unknown"),
url=attr.get("url"),
)
raw_duration = entry.get("duration")
raw_has_audio = entry.get("has_audio")
resolved[video_id] = VideoSource(
source_file=entry["source_file"],
filter=entry.get("filter", []),
output_file=entry.get("output_file"),
take=entry.get("take"),
skip=float(entry.get("skip", 0.0)),
zoom=float(entry.get("zoom", 1.0)),
cutout=entry.get("cutout"),
always_visible=bool(entry.get("always_visible", False)),
is_shared=True,
pause_narration=float(entry.get("pause_narration", 0)),
attribution=attribution,
use_audio_channels=entry.get("use_audio_channels", "both"),
defer_loudnorm=bool(entry.get("defer_loudnorm", False)),
volume=float(entry.get("volume", 1.0)),
layer=entry.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=entry.get("end_on"),
)
try:
with open(local_videos_path, "w", encoding="utf-8") as fh:
json.dump(local_data, fh, indent=4)
fh.write("\n")
except OSError as e:
print(f" Warning: could not update videos.json: {e}")
return resolved
+312
View File
@@ -0,0 +1,312 @@
"""Pexels video downloader for gnommo shared_assets.
Configure API key in ~/.gnommo.conf:
[pexels]
api_key = YOUR_KEY_HERE
Get a free key at https://www.pexels.com/api/
"""
import configparser
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
def get_pexels_api_key() -> Optional[str]:
config_path = Path.home() / ".gnommo.conf"
if not config_path.exists():
return None
cfg = configparser.ConfigParser()
cfg.read(config_path)
return cfg.get("pexels", "api_key", fallback=None)
def extract_pexels_id(source_file: str) -> Optional[str]:
"""Extract the numeric Pexels video ID from a source_file path.
Handles names like 'pexels/11868263-hd_1920_1080_24fps.mp4'
and 'pexels/12136677_1080_1920_30fps.mp4'.
"""
name = Path(source_file).stem.split("/")[-1]
m = re.match(r"^(\d+)", name)
return m.group(1) if m else None
def _fetch_video_info(pexels_id: str, api_key: str) -> Optional[dict]:
url = f"https://api.pexels.com/videos/videos/{pexels_id}"
req = urllib.request.Request(
url,
headers={"Authorization": api_key, "User-Agent": "Mozilla/5.0 gnommo/1.0"},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
print(f" [{pexels_id}] Pexels API error {e.code} — video may have been deleted", flush=True)
return None
except Exception as e:
print(f" [{pexels_id}] Pexels API error: {e}", flush=True)
return None
def description_from_url(video_url: str) -> str:
"""Extract human-readable description from a Pexels video URL slug.
'https://www.pexels.com/video/abstract-television-noise-11868263/'
'Abstract Television Noise'
"""
m = re.search(r"/video/([a-z0-9][a-z0-9-]+?)-\d+/?$", video_url)
if m:
return m.group(1).replace("-", " ").title()
return ""
def _pick_best_video_file(video_files: list, source_file: str) -> Optional[dict]:
"""Select the video_files entry that best matches the hints in source_file."""
stem = Path(source_file).stem.split("/")[-1]
width_hint = height_hint = fps_hint = quality_hint = None
m = re.search(r"[_-](\d{3,4})[_-](\d{3,4})[_-](\d+)fps", stem)
if m:
width_hint = int(m.group(1))
height_hint = int(m.group(2))
fps_hint = int(m.group(3))
for q in ("uhd", "hd", "sd"):
if q in stem.lower():
quality_hint = q
break
mp4s = [f for f in video_files if f.get("file_type") == "video/mp4"]
if not mp4s:
mp4s = video_files # fall back to any format
def score(vf: dict) -> int:
s = 0
if quality_hint and vf.get("quality", "").lower() == quality_hint:
s += 10
if width_hint and vf.get("width") == width_hint:
s += 5
if height_hint and vf.get("height") == height_hint:
s += 5
if fps_hint and round(float(vf.get("fps") or 0)) == fps_hint:
s += 3
return s
return max(mp4s, key=score)
def download_video(
source_file: str,
shared_assets_dir: Path,
api_key: str,
) -> Optional[dict]:
"""Download one Pexels video to shared_assets_dir/<source_file>.
Returns a metadata dict {description, duration, has_audio=False} on
success, or None on failure.
"""
pexels_id = extract_pexels_id(source_file)
if not pexels_id:
print(f" Cannot extract Pexels ID from: {source_file}", file=sys.stderr)
return None
target_path = shared_assets_dir / source_file
target_path.parent.mkdir(parents=True, exist_ok=True)
print(f" [{pexels_id}] Fetching video info...", flush=True)
info = _fetch_video_info(pexels_id, api_key)
if not info:
return None
description = description_from_url(info.get("url", ""))
duration = float(info.get("duration") or 0) or None
video_files = info.get("video_files", [])
if not video_files:
print(f" [{pexels_id}] No video files in API response", flush=True)
return None
best = _pick_best_video_file(video_files, source_file)
if not best:
return None
download_url = best["link"]
w, h, fps = best.get("width", "?"), best.get("height", "?"), best.get("fps", "?")
q = best.get("quality", "?")
label = f'"{description}"' if description else ""
print(f" [{pexels_id}] {label}{q} {w}x{h} @ {fps}fps", flush=True)
print(f"{target_path}", flush=True)
try:
req = urllib.request.Request(
download_url, headers={"User-Agent": "Mozilla/5.0 gnommo/1.0"}
)
with urllib.request.urlopen(req, timeout=300) as resp:
total = int(resp.headers.get("Content-Length") or 0)
downloaded = 0
chunks: list[bytes] = []
chunk_size = 1024 * 512 # 512 KB
while True:
chunk = resp.read(chunk_size)
if not chunk:
break
chunks.append(chunk)
downloaded += len(chunk)
if total:
pct = downloaded * 100 // total
mb_done = downloaded / 1024 / 1024
mb_total = total / 1024 / 1024
print(f" {pct:3d}% {mb_done:.1f}/{mb_total:.1f} MB\r", end="", flush=True)
print(f" Done — {downloaded / 1024 / 1024:.1f} MB ", flush=True)
target_path.write_bytes(b"".join(chunks))
except Exception as e:
print(f"\n Download failed: {e}", flush=True)
return None
return {
"description": description,
"duration": duration,
"has_audio": False, # conservative; renderer probes when needed
}
def update_videos_json(
json_path: Path,
video_id: str,
metadata: dict,
) -> None:
"""Write description (and other metadata) into an existing videos.json entry."""
if not json_path.exists():
return
with open(json_path, "r", encoding="utf-8") as f:
raw = json.load(f)
if video_id not in raw:
return
changed = False
for key, value in metadata.items():
if value and raw[video_id].get(key) != value:
raw[video_id][key] = value
changed = True
if changed:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(raw, f, indent=2, ensure_ascii=False)
def fetch_metadata(pexels_id: str, api_key: str) -> Optional[dict]:
"""Fetch only description and duration for a Pexels video (no download)."""
info = _fetch_video_info(pexels_id, api_key)
if not info:
return None
return {
"description": description_from_url(info.get("url", "")),
"duration": float(info.get("duration") or 0) or None,
}
def enrich_missing_descriptions(
shared_assets_dir: Path,
api_key: str,
) -> int:
"""Fetch descriptions from Pexels API for entries that have a file on disk but no description.
Scans shared_assets/videos.json for pexels/* entries where:
- description is absent or empty
- source_file exists on disk (locally or via cache)
Returns number of entries updated.
"""
from .cache import resolve_with_cache
videos_json = shared_assets_dir / "videos.json"
if not videos_json.exists():
return 0
with open(videos_json, "r", encoding="utf-8") as f:
raw = json.load(f)
candidates = [
(vid_id, entry)
for vid_id, entry in raw.items()
if vid_id.startswith("pexels/") and not entry.get("description")
]
# Filter to those whose file exists on disk
project_root = shared_assets_dir.parent
to_enrich = []
for vid_id, entry in candidates:
sf = entry.get("source_file", "")
if not sf:
continue
path = shared_assets_dir / sf
resolved, _ = resolve_with_cache(path, project_root)
if resolved.exists():
pexels_id = extract_pexels_id(sf)
if pexels_id:
to_enrich.append((vid_id, pexels_id))
if not to_enrich:
return 0
print(f" Enriching descriptions for {len(to_enrich)} existing pexels video(s)...", flush=True)
updated = 0
for vid_id, pexels_id in to_enrich:
meta = fetch_metadata(pexels_id, api_key)
if meta and meta.get("description"):
print(f" [{pexels_id}] \"{meta['description']}\"", flush=True)
update_videos_json(videos_json, vid_id, meta)
updated += 1
else:
print(f" [{pexels_id}] not found or no description — skipped", flush=True)
return updated
def find_missing_pexels_videos(
manuscript_markers: list[str],
videos: dict,
shared_assets_dir: Path,
) -> list[tuple[str, str]]:
"""Return [(video_id, source_file)] for pexels videos referenced but not on disk."""
from .cache import resolve_with_cache
_VIDEO_PREFIXES = (
"video:", "narration:",
"vft:", "vfb:", "vfm:",
"vf2t:", "vf2b:", "vf2m:",
"vst:", "vsb:", "vsm:",
"vftp:", "vfbp:", "vfmp:",
"vf2tp:", "vf2bp:", "vf2mp:",
"vstp:", "vsbp:", "vsmp:",
)
seen: set[str] = set()
missing: list[tuple[str, str]] = []
for marker in manuscript_markers:
prefix = next((p for p in _VIDEO_PREFIXES if marker.startswith(p)), None)
if prefix is None:
continue
video_id = marker[len(prefix):]
if video_id in seen or not video_id.startswith("pexels/"):
continue
seen.add(video_id)
source_file = videos.get(video_id, None)
if source_file is None:
continue
sf = source_file.source_file if hasattr(source_file, "source_file") else source_file
candidate = shared_assets_dir / sf
# resolve_with_cache needs a project_path — use shared_assets parent
resolved, _ = resolve_with_cache(candidate, shared_assets_dir.parent)
if not resolved.exists():
missing.append((video_id, sf))
return missing
+2364 -71
View File
File diff suppressed because it is too large Load Diff
+233
View File
@@ -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
+415
View File
@@ -0,0 +1,415 @@
"""Push project metadata to gnommoeditor (prod) or gnommoweb (local).
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 companion JSON files, then POSTs to:
Production: POST /api/ingest (gnommoeditor, uses INGEST_API_KEY)
Local: POST /api/projects/push (gnommoweb, uses GNOMMOWEB_API_KEY)
Configuration (from .env or environment):
GNOMMOEDITOR_URL Base URL for production (e.g. https://editor.glitch.university)
INGEST_API_KEY Bearer token for gnommoeditor ingest endpoint
GNOMMOWEB_URL Base URL for local dev (e.g. http://localhost:3001)
GNOMMOWEB_API_KEY Bearer token for local 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 _load_json_file(path: Path, label: str, verbose: bool) -> dict | list | None:
"""Load a JSON file, returning None if it doesn't exist."""
if not path.exists():
if verbose:
print(f" {label}: not found at {path}")
return None
try:
with open(path) as f:
return json.load(f)
except json.JSONDecodeError as e:
print(f" Warning: could not parse {label} ({path}): {e}", file=sys.stderr)
return None
def _load_text_file(path: Path, label: str) -> str | None:
"""Load a text file, returning None if it doesn't exist."""
if not path.exists():
return None
try:
return path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return path.read_text(encoding="latin-1")
def _parse_seconds(value) -> float | None:
"""Convert a time value like '30s', '1:30', or 30 into a plain float of seconds."""
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
value = str(value).strip()
if value.endswith("s"):
value = value[:-1]
if ":" in value:
parts = value.split(":")
if len(parts) == 2:
return float(parts[0]) * 60 + float(parts[1])
elif len(parts) == 3:
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
return float(value)
def _sanitize_time_fields(data: dict | None, fields: list[str]) -> dict | None:
"""Return a copy of dict with the given fields converted to plain floats."""
if not data:
return data
result = dict(data)
for field in fields:
if field in result and result[field] is not None:
try:
result[field] = _parse_seconds(result[field])
except (ValueError, TypeError):
pass # leave invalid values for the server to reject with a clear error
return result
def _build_ingest_payload(project: dict, project_path: Path, verbose: bool) -> dict:
"""Build the rich ingest payload for gnommoeditor POST /api/ingest."""
# ── slides ────────────────────────────────────────────────────────────────
slides_path_str = project.get("slides", "slides.json")
slides_path = project_path / slides_path_str
slides = _load_json_file(slides_path, "slides", verbose)
if slides and verbose:
print(f" slides: {len(slides)} entries")
# ── manuscript ────────────────────────────────────────────────────────────
manuscript_path_str = project.get("manuscript", "manuscript.txt")
manuscript_path = project_path / manuscript_path_str
manuscript = _load_text_file(manuscript_path, "manuscript")
if manuscript:
print(f" manuscript: {len(manuscript)} chars")
elif verbose:
print(f" manuscript: not found at {manuscript_path}")
# ── narration ─────────────────────────────────────────────────────────────
narration_path_str = project.get("narration", "narration.json")
narration_path = project_path / narration_path_str
narration = _load_json_file(narration_path, "narration", verbose)
# ── audio ─────────────────────────────────────────────────────────────────
audio_path_str = project.get("audio_tracks", "audio.json")
audio_path = project_path / audio_path_str
audio = _load_json_file(audio_path, "audio", verbose)
# ── videos ────────────────────────────────────────────────────────────────
videos_path_str = project.get("videos", "videos.json")
videos_path = project_path / videos_path_str
videos = _load_json_file(videos_path, "videos", verbose)
# ── citations ─────────────────────────────────────────────────────────────
citations_path = project_path / "citations.json"
citations = _load_json_file(citations_path, "citations", verbose)
# Sanitize time fields — convert "30s", "1:30" etc. to plain floats
_VIDEO_TIME_FIELDS = ["duration", "pause_narration", "skip", "take"]
_NARRATION_TIME_FIELDS = ["skip", "take"]
_AUDIO_TIME_FIELDS = ["overlap", "duration"]
if videos:
videos = {
k: _sanitize_time_fields(v, _VIDEO_TIME_FIELDS) for k, v in videos.items()
}
if narration:
narration = {
k: _sanitize_time_fields(v, _NARRATION_TIME_FIELDS)
for k, v in narration.items()
}
if audio:
audio = {
k: _sanitize_time_fields(v, _AUDIO_TIME_FIELDS) for k, v in audio.items()
}
return {
"project": project,
"slides": slides,
"manuscript": manuscript,
"narration": narration,
"audio": audio,
"videos": videos,
"citations": citations,
}
def cmd_push(
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
) -> int:
_load_env_file()
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
if prod:
return _push_prod(project, project_path, verbose)
else:
return _push_local(project, project_path, verbose, force)
# ── Production: gnommoeditor POST /api/ingest ─────────────────────────────────
def _push_prod(project: dict, project_path: Path, verbose: bool) -> int:
api_url = os.environ.get("GNOMMOEDITOR_URL", "").rstrip("/")
api_key = os.environ.get("INGEST_API_KEY", "")
if not api_url:
print("Error: GNOMMOEDITOR_URL is not set.", file=sys.stderr)
return 1
if not api_key:
print("Error: INGEST_API_KEY is not set.", file=sys.stderr)
return 1
project_id = project["id"]
payload = _build_ingest_payload(project, project_path, verbose)
# Attach sync state so the server can record it
sync = _read_sync(project_path, prod=True)
if sync:
payload["sync"] = sync
print(f"{api_url}/api/ingest")
try:
r = requests.post(
f"{api_url}/api/ingest",
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()
video_id = result.get("video_id")
slides_upserted = result.get("slides_upserted", 0)
# Update sync state
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
existing_sync = _read_sync(project_path, prod=True)
_write_sync(
project_path,
{**existing_sync, "last_pushed_at": now_iso},
prod=True,
)
print(f"{project_id} → video #{video_id} ({slides_upserted} slides)")
return 0
# ── Local dev: gnommoweb POST /api/projects/push ──────────────────────────────
def _push_local(project: dict, project_path: Path, verbose: bool, force: bool) -> int:
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:
print(f" → local: {api_url}")
project_id = project["id"]
parent_project = project.get("parent_project")
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}")
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")
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
existing_sync = _read_sync(project_path, prod=False)
_write_sync(
project_path,
{
**existing_sync,
"last_pushed_at": now_iso,
"server_updated_at": server_updated_at,
},
prod=False,
)
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:
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:
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"),
}
+1271 -83
View File
File diff suppressed because it is too large Load Diff
+111 -13
View File
@@ -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(
word=word_info["word"].strip(),
start=word_info["start"],
end=word_info["end"],
))
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)
+1325 -58
View File
File diff suppressed because it is too large Load Diff
+265 -70
View File
@@ -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, resolve_missing_videos
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,107 +36,292 @@ def validate_project(
- No malformed markers in manuscript
"""
issues: list[ValidationIssue] = []
warnings: list[ValidationIssue] = []
# Collect video IDs actually referenced in the manuscript (for file-existence checks)
_VIDEO_PREFIXES = {
"video:": 6,
"vft:": 4, "vfb:": 4, "vfm:": 4,
"vf2t:": 5, "vf2b:": 5, "vf2m:": 5,
"vst:": 4, "vsb:": 4, "vsm:": 4,
"vftp:": 5, "vfbp:": 5, "vfmp:": 5,
"vf2tp:": 6, "vf2bp:": 6, "vf2mp:": 6,
"vstp:": 5, "vsbp:": 5, "vsmp:": 5,
}
referenced_video_ids: set[str] = set()
for marker in manuscript_markers:
prefix = next((p for p in _VIDEO_PREFIXES if marker.startswith(p)), None)
if prefix is not None:
referenced_video_ids.add(marker[_VIDEO_PREFIXES[prefix]:])
elif marker.startswith("narration:"):
referenced_video_ids.add(marker[10:])
# Check for malformed markers first (these are likely typos)
if malformed_markers:
for line_num, marker_text in malformed_markers:
issues.append(ValidationIssue(
f"Malformed marker: {marker_text}",
project_path / "manuscript.txt",
line_num
))
issues.append(
ValidationIssue(
f"Malformed marker: {marker_text}",
project_path / "manuscript.txt",
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
# Skip audio: prefix markers (e.g., audio:woosh)
if marker.startswith("audio:"):
continue
# Validate video trigger markers — both legacy [video:xxx] and
# shorthand [vft:xxx] / [vfb:xxx] / [vst:xxx] / [vsb:xxx].
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",
)
)
else:
vs = videos[video_id]
if not vs.cutout or vs.cutout not in config.cutouts:
warnings.append(
ValidationIssue(
f"[{marker}] video '{video_id}' has no valid cutout in videos.json — "
f"run 'gnommo import' to project values, or set cutout manually.",
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",
)
)
else:
vs = videos[video_id]
if not vs.cutout or vs.cutout not in config.cutouts:
warnings.append(
ValidationIssue(
f"[{marker}] video '{video_id}' has no valid cutout in videos.json — "
f"run 'gnommo import' to project values, or set cutout manually.",
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(
f"Slide marker [{marker}] referenced in manuscript but not defined in slides.json",
project_path / "manuscript.txt"
))
issues.append(
ValidationIssue(
f"Slide marker [{marker}] referenced in manuscript but not defined in slides.json",
project_path / "manuscript.txt",
)
)
# Check all slide images exist
# Slides are in the same directory as the slides.json file
slides_json_path = project_path / config.slides_path
# Slides are in the same directory as the slides.json file.
# Lowercase the configured path so capital-cased project names (e.g.
# "media/slides/Video2/slides.json") resolve on case-sensitive filesystems.
slides_json_path = project_path / config.slides_path.lower()
slides_dir = slides_json_path.parent
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(
f"Unknown slide type '{slide_def.type}' for slide {slide_id}. "
f"Valid types: {list(SLIDE_LAYOUTS.keys())}",
project_path / "slides.json"
))
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",
)
)
# 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
# Only check files for videos actually used in this manuscript
if video_id not in referenced_video_ids:
continue
# 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
video_path = base_dir / video_source.source_file
video_path, _ = resolve_with_cache(video_path, project_path)
if not video_path.exists():
issues.append(ValidationIssue(
f"Video file not found: {video_source.file}",
project_path / "videos.json"
))
sf = video_source.source_file
hint = (
" — run 'gnommo pexels' to download"
if sf.startswith("pexels/")
else " — falling back to PlaceholderVideo"
)
warnings.append(
ValidationIssue(
f"Video file not found: {sf}{hint}",
videos_json_path,
)
)
# 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
# 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(
f"Preprocessed output not found: {video_source.output_file}. "
f"Run with -a preprocess first.",
project_path / "videos.json"
))
issues.append(
ValidationIssue(
f"Preprocessed output not found: {video_source.output_file}. "
f"Run with -a preprocess first.",
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
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"
))
# 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"]
bg_path, _ = resolve_with_cache(bg_path, project_path)
if not bg_path.exists():
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(
f"Resolution too large: {width}x{height} (max 8K)",
project_path / "project.json"
))
issues.append(
ValidationIssue(
f"Resolution too large: {width}x{height} (max 8K)",
project_path / "project.json",
)
)
# Check FPS is reasonable
if config.fps < 1 or config.fps > 120:
issues.append(ValidationIssue(
f"Invalid FPS: {config.fps} (must be 1-120)",
project_path / "project.json"
))
issues.append(
ValidationIssue(
f"Invalid FPS: {config.fps} (must be 1-120)",
project_path / "project.json",
)
)
# Check outro videos exist in videos.json or shared_assets
if config.outro:
missing_outro = [vid_id for vid_id in config.outro if vid_id not in videos]
if missing_outro:
found = resolve_missing_videos(missing_outro, project_path, config)
still_missing = [vid_id for vid_id in missing_outro if vid_id not in found]
for vid_id in still_missing:
warnings.append(
ValidationIssue(
f"Outro video '{vid_id}' not found in videos.json or shared_assets — will be skipped at render",
project_path / "project.json",
)
)
# If any issues, raise ValidationError
if issues:
raise ValidationError(issues)
return warnings
+7
View File
@@ -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-----
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIOve0LFR/scL72z03RMX0QQIyyGbr8SlyvqRnNpka/Q jens.tandstad@eaglecondor.no
+6
View File
@@ -0,0 +1,6 @@
import gnommo
if __name__ == "__main__":
print("This is the main module.")
gnommo.main()
View File
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
./gnommo.sh -p video1 all
./gnommo.sh -p video2 all
./gnommo.sh -p video3 all
./gnommo.sh -p video4 all
./gnommo.sh -p video5 all
./gnommo.sh -p video6 all
+2
View File
@@ -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"
}
+34
View File
@@ -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%"
}
}
}
+31
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
API_URL="${GNOMMO_API_URL:-https://glitch.university}"
CONTENT_API_KEY=782y497821y491y3981212
+110
View File
@@ -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.
+141
View File
@@ -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."
+251
View File
@@ -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
+165
View File
@@ -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
+476
View File
@@ -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
Executable
+117
View File
@@ -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
View File
@@ -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 "========================================"