Adding handoff functionality for reviews
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## Proxying
|
||||
Using the --proxy keyword makes everything faster because it creates some smaller files.
|
||||
```
|
||||
gnommo -p myproject pre --proxy
|
||||
gnommo -p myproject stitch --proxy
|
||||
gnommo -p myproject render --proxy
|
||||
```
|
||||
|
||||
## Lowres
|
||||
Renders the final video in a low-res mode, for faster iteration
|
||||
```
|
||||
gnommo -p myproject render --res low
|
||||
```
|
||||
|
||||
## 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 --proxy # Fast preview at reduced resolution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shortcut: All Stages
|
||||
|
||||
Run all stages 2-5 and render in one command:
|
||||
|
||||
```bash
|
||||
gnommo -p myproject all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manuscript Format
|
||||
|
||||
The manuscript is plain text with embedded markers:
|
||||
|
||||
```
|
||||
[S1] Welcome to this presentation.
|
||||
|
||||
[S2] Let me show you how this works.
|
||||
|
||||
[video:demo] Here's a quick demonstration.
|
||||
|
||||
[Zoom1] Notice this important detail.
|
||||
|
||||
[Reset] And that concludes our overview.
|
||||
```
|
||||
|
||||
**Marker types:**
|
||||
- `[S1]`, `[S2]` - Slide markers (reference slides.json)
|
||||
- `[video:id]` - Triggered video overlay
|
||||
- `[narration:id]` - Start continuous narration video
|
||||
- `[Zoom1]`, `[Reset]` - Camera presets
|
||||
- `[Awoosh]` - Audio effect trigger
|
||||
|
||||
---
|
||||
|
||||
## External Storage (GnommoCache)
|
||||
|
||||
For large projects, gnommo supports transparent external storage fallback.
|
||||
|
||||
**Setup:** Create `~/.gnommo.conf`:
|
||||
```ini
|
||||
[cache]
|
||||
path = /Volumes/ExternalDrive/gnommo
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Files are first looked up locally in the project directory
|
||||
- If not found, gnommo checks `{cache_path}/{project_name}/...`
|
||||
- The 📁 indicator shows files loaded from external storage
|
||||
|
||||
**Archive to external storage:**
|
||||
```bash
|
||||
gnommo -p myproject archive # Sync project to cache
|
||||
gnommo -p myproject archive --dry-run # Preview what would sync
|
||||
```
|
||||
|
||||
This allows you to move large preprocessed files to external storage while keeping the project functional.
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### New Project Setup
|
||||
```bash
|
||||
# 1. Create project structure and add media files
|
||||
mkdir -p myproject/media/{slides,videos,narration,audio}
|
||||
|
||||
# 2. Create project.json with basic config
|
||||
|
||||
# 3. Import media to generate JSON definitions
|
||||
gnommo -p myproject import
|
||||
|
||||
# 4. Edit JSON files to configure filters, trim points, etc.
|
||||
|
||||
# 5. Run full pipeline
|
||||
gnommo -p myproject all
|
||||
```
|
||||
|
||||
### Re-render After Editing Manuscript
|
||||
```bash
|
||||
gnommo -p myproject render
|
||||
```
|
||||
|
||||
### Re-process After Recording New Narration
|
||||
```bash
|
||||
gnommo -p myproject pre
|
||||
gnommo -p myproject stitch
|
||||
gnommo -p myproject transcribe
|
||||
gnommo -p myproject render
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Commands
|
||||
|
||||
```bash
|
||||
gnommo -p myproject validate # Check for errors without rendering
|
||||
gnommo -p myproject description # Generate YouTube description with chapters
|
||||
gnommo -p myproject transcribe --final # Transcribe final.mp4 for subtitles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Glitch University — Server Sync
|
||||
|
||||
Gnommo can push project metadata and short scripts to a gnommoweb server,
|
||||
and pull changes back. This keeps the platform database in sync with your
|
||||
local project files without manual copy-paste.
|
||||
|
||||
**Setup** — add to `gnommo/.env`:
|
||||
```ini
|
||||
GNOMMOWEB_URL=http://localhost:3001
|
||||
GNOMMOWEB_API_KEY=your_content_api_key
|
||||
```
|
||||
|
||||
### Push
|
||||
|
||||
Registers the project on the server and syncs all defined shorts (including
|
||||
their scripts). Creates a filming task for each new short.
|
||||
|
||||
```bash
|
||||
gnommo -p myproject push # push local → server
|
||||
gnommo -p myproject push --force # overwrite server even if it has newer changes
|
||||
```
|
||||
|
||||
On the first push, gnommo creates:
|
||||
- A stub video record in the platform database
|
||||
- One short record per entry in `project.json["shorts"]`
|
||||
- One task per new short ("Film short: …")
|
||||
|
||||
Re-running push is safe — existing records are updated, no duplicate tasks.
|
||||
Scripts are only overwritten on the server if the local file has changed;
|
||||
edits made in the staff UI are preserved.
|
||||
|
||||
### Pull
|
||||
|
||||
Fetches the current project state from the server and merges the `shorts`
|
||||
array back into `project.json`. Useful after editing short titles or hooks
|
||||
in the web interface.
|
||||
|
||||
```bash
|
||||
gnommo -p myproject pull # pull server → local
|
||||
gnommo -p myproject pull --force # overwrite local even if it has unsaved changes
|
||||
```
|
||||
|
||||
Pull preserves local `script` file paths — it won't overwrite your `.md`
|
||||
script files.
|
||||
|
||||
### Conflict guards
|
||||
|
||||
Both commands check for conflicts before writing:
|
||||
|
||||
| Situation | Push behaviour | Pull behaviour |
|
||||
|---|---|---|
|
||||
| Server has changes you haven't pulled | Blocked — pull first | Proceeds (that's the point) |
|
||||
| Local has changes you haven't pushed | Proceeds (that's the point) | Blocked — push first |
|
||||
| `--force` flag | Overrides | Overrides |
|
||||
|
||||
Sync state is stored in `<project>/.gnommo_sync.json` (tracked by git,
|
||||
so collaborators share the same reference point).
|
||||
|
||||
### Defining shorts in `project.json`
|
||||
|
||||
Add a `shorts` array to your project:
|
||||
|
||||
```json
|
||||
"shorts": [
|
||||
{
|
||||
"id": "short_pixelated_universe",
|
||||
"title": "Is the universe pixelated?",
|
||||
"hook": "What if space is made of tiny blocks?",
|
||||
"script": "shorts/short_pixelated_universe.md",
|
||||
"platform_targets": ["youtube"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `id` — unique slug within the project, used as the upsert key
|
||||
- `script` — relative path to a markdown file with the full short narration
|
||||
- `hook` — opening line / thumbnail caption
|
||||
- `platform_targets` — list of platforms (currently `["youtube"]`)
|
||||
|
||||
Scripts are plain markdown with the same `[SLIDE: name]` markers and
|
||||
`{word}` whisper timestamp tags used elsewhere in gnommo.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- FFmpeg
|
||||
- OpenAI Whisper (for transcription)
|
||||
|
||||
```bash
|
||||
pip install openai-whisper
|
||||
```
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"drives": {
|
||||
"lacie": {
|
||||
"mount_path": "/Volumes/LaCie Jens",
|
||||
"backups": {
|
||||
"small": {
|
||||
"last_attempt": "2026-02-26T10:09:08Z",
|
||||
"last_status": "success",
|
||||
"last_completed": "2026-02-26T10:09:14Z"
|
||||
},
|
||||
"big": {
|
||||
"last_attempt": "2026-02-27T12:17:30Z",
|
||||
"last_status": "failed"
|
||||
},
|
||||
"all": {
|
||||
"last_attempt": "2026-02-27T12:46:26Z",
|
||||
"last_status": "success",
|
||||
"last_completed": "2026-02-27T13:41:50Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "========================================"
|
||||
@@ -302,7 +302,7 @@ All events (slides, videos, audio) are filtered by whether their START marker fa
|
||||
|
||||
### Parallel Rendering Pipeline
|
||||
```bash
|
||||
# Render in parallel, then concatenate
|
||||
# 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: &
|
||||
|
||||
+25
-5
@@ -1,12 +1,12 @@
|
||||
[S1]
|
||||
This is the first slide. It appears immediately. [cite:Gnommo Documentation - https://github.com/example/gnommo]
|
||||
This is the first slide. It appears immediately.
|
||||
|
||||
[S2]
|
||||
However, this is the second slide. It should appear 1 second prior to when I say "however"
|
||||
However, this is the second slide. It should appear 1 second prior to when I say “however”
|
||||
|
||||
[S3]
|
||||
[video:Zoomin_MontageZoom]
|
||||
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. [cite:FFmpeg Documentation - https://ffmpeg.org/documentation.html]
|
||||
[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
|
||||
@@ -14,6 +14,26 @@ I will continue to talk without pause, but in the finished recording - there wil
|
||||
[S5]
|
||||
[video:gnommologo]
|
||||
|
||||
Notice how my voice continues after the video finished.
|
||||
Notice how my voice continues after the video finished
|
||||
|
||||
[S6]
|
||||
|
||||
[S7]
|
||||
This is the first slide. It appears immediately.
|
||||
|
||||
[S8]
|
||||
However, this is the second slide. It should appear 1 second prior to when I say “however”
|
||||
|
||||
[S9]
|
||||
[video:KnightRotating]
|
||||
This is me talking alongside a video. The video is constrained within the red square. Notice how the video stops immediately when we make the transition to the next slide.
|
||||
|
||||
[S10]
|
||||
I will continue to talk without pause, but in the finished recording - there will be a pause before the narration continues. Now a video will play that pauses the narration
|
||||
|
||||
[S11]
|
||||
[video:gnommologo]
|
||||
|
||||
Notice how my voice continues after the video finished
|
||||
|
||||
[S12]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"talking_head_S1": {
|
||||
"source_file": "talking_head_S1.mov",
|
||||
"output_file": "talking_head_S1_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": "talkinghead"
|
||||
},
|
||||
"talking_head_S3": {
|
||||
"source_file": "talking_head_S3.mov",
|
||||
"output_file": "talking_head_S3_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": "talkinghead"
|
||||
}
|
||||
}
|
||||
@@ -22,5 +22,29 @@
|
||||
"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,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
|
||||
}
|
||||
]
|
||||
@@ -1,39 +1,47 @@
|
||||
{
|
||||
"talking_head": {
|
||||
"source_file": "talking_head.mov",
|
||||
"output_file": "talking_head_processed.mov",
|
||||
"talking_head_S1": {
|
||||
"source_file": "talking_head_S1.mov",
|
||||
"output_file": "talking_head_S1_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": [
|
||||
{
|
||||
"type": "chroma_key",
|
||||
"color": [131, 177, 83],
|
||||
"similarity": 0.04,
|
||||
"blend": 0.025,
|
||||
"spill": 0.05
|
||||
"filter": "talkinghead"
|
||||
},
|
||||
{
|
||||
"type": "mask",
|
||||
"left": 0.05,
|
||||
"right": 0.10
|
||||
}
|
||||
]
|
||||
"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": 0 ,
|
||||
"take": 10,
|
||||
"pause_narration": 17,
|
||||
"take": 25,
|
||||
"skip": 0
|
||||
},
|
||||
"Zoomin_MontageZoom": {
|
||||
"description": "Montage zoom",
|
||||
"source_file": "MontageZoom.mp4",
|
||||
"output_file": "MontageZoom.mp4",
|
||||
"pause_narration":3,
|
||||
"pause_narration": 5,
|
||||
"cutout": "square",
|
||||
"is_shared": true,
|
||||
"filter": []
|
||||
},
|
||||
"narration_combined": {
|
||||
"source_file": "narration_combined.mov",
|
||||
"output_file": "narration_combined.mov",
|
||||
"cutout": "square",
|
||||
"filter": []
|
||||
}
|
||||
}
|
||||
+59
-1
@@ -13,7 +13,65 @@
|
||||
"videos": "media/videos/videos.json",
|
||||
"slides": "media/slides/Example/slides.json",
|
||||
"audio": "media/audio/audio.json",
|
||||
"main_video": "talking_head",
|
||||
"default_filters": {
|
||||
"talkinghead": [
|
||||
{
|
||||
"type": "audio_normalize",
|
||||
"eq_bands": [
|
||||
{"freq": 47, "gain": -15, "type": "lowshelf"},
|
||||
{"freq": 107, "gain": -1.3, "q": 1.2},
|
||||
{"freq": 597, "gain": -5.2, "q": 2},
|
||||
{"freq": 11811, "gain": 2.8, "q": 1},
|
||||
{"freq": 24000, "gain": 3.9, "type": "highshelf"}
|
||||
],
|
||||
"highpass": 0,
|
||||
"room_eq": false,
|
||||
"dereverb_model": "shared_assets/models/std.rnnn",
|
||||
"dereverb_mix": 0.8,
|
||||
"denoise": true,
|
||||
"noise_floor": -25,
|
||||
"gate": true,
|
||||
"gate_threshold": -35,
|
||||
"gate_range": -20,
|
||||
"compress": true,
|
||||
"threshold": -20,
|
||||
"ratio": 3,
|
||||
"attack": 12,
|
||||
"release": 100,
|
||||
"makeup": 2,
|
||||
"normalize": true,
|
||||
"target_lufs": -16,
|
||||
"target_lra": 11,
|
||||
"target_tp": -1.5
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
},
|
||||
"main_video": ["talking_head_S1", "talking_head_S3"],
|
||||
"cutouts": {
|
||||
"talkinghead": {
|
||||
"x": "-10%",
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""GnommoCache - External storage extension for large media files.
|
||||
|
||||
Provides transparent fallback to external storage when files are not found locally.
|
||||
Configure via ~/.gnommo.conf:
|
||||
|
||||
[cache]
|
||||
path = /Volumes/GnommoDisk/gnommo
|
||||
|
||||
Files are looked up first locally, then in the cache at:
|
||||
{cache_path}/{project_name}/{relative_path}
|
||||
"""
|
||||
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
_cache_config: Optional[dict] = None
|
||||
|
||||
|
||||
def load_cache_config() -> Optional[Path]:
|
||||
"""Load gnommo.conf and return cache path if configured.
|
||||
|
||||
Configuration file location: ~/.gnommo.conf
|
||||
|
||||
Returns:
|
||||
Path to the cache root directory, or None if not configured.
|
||||
"""
|
||||
global _cache_config
|
||||
if _cache_config is not None:
|
||||
return _cache_config.get("path")
|
||||
|
||||
config_path = Path.home() / ".gnommo.conf"
|
||||
if not config_path.exists():
|
||||
_cache_config = {}
|
||||
return None
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
if config.has_option("cache", "path"):
|
||||
cache_path = Path(config.get("cache", "path"))
|
||||
_cache_config = {"path": cache_path}
|
||||
return cache_path
|
||||
|
||||
_cache_config = {}
|
||||
return None
|
||||
|
||||
|
||||
def resolve_with_cache(
|
||||
local_path: Path,
|
||||
project_path: Path,
|
||||
) -> Tuple[Path, bool]:
|
||||
"""
|
||||
Resolve a file path with cache fallback (read-only).
|
||||
|
||||
Checks the local path first. If not found and cache is configured,
|
||||
checks the cache directory which mirrors the project structure.
|
||||
|
||||
Args:
|
||||
local_path: The expected local path to the file
|
||||
project_path: The project root directory
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_path, is_cached) where is_cached=True if
|
||||
the file was found in the external cache instead of locally.
|
||||
"""
|
||||
# Check local path first
|
||||
if local_path.exists():
|
||||
return local_path, False
|
||||
|
||||
# Check cache
|
||||
cache_base = load_cache_config()
|
||||
if cache_base is None:
|
||||
return local_path, False # No cache configured
|
||||
|
||||
# Build cache path: {cache_base}/{project_name}/{relative_path}
|
||||
try:
|
||||
relative = local_path.relative_to(project_path)
|
||||
cache_path = cache_base / project_path.name / relative
|
||||
if cache_path.exists():
|
||||
return cache_path, True
|
||||
except ValueError:
|
||||
pass # local_path is not relative to project_path
|
||||
|
||||
return local_path, False
|
||||
|
||||
|
||||
def is_cache_configured() -> bool:
|
||||
"""Check if cache is configured (for status messages)."""
|
||||
return load_cache_config() is not None
|
||||
|
||||
|
||||
def get_cache_info() -> Optional[str]:
|
||||
"""Get a human-readable cache configuration string."""
|
||||
cache_path = load_cache_config()
|
||||
if cache_path is None:
|
||||
return None
|
||||
if cache_path.exists():
|
||||
return f"{cache_path} (connected)"
|
||||
return f"{cache_path} (not connected)"
|
||||
+1160
-80
File diff suppressed because it is too large
Load Diff
@@ -163,7 +163,9 @@ def generate_chapters(
|
||||
chapters = []
|
||||
|
||||
# Build timing lookup
|
||||
timing_lookup = {t.marker_id: t.timestamp for t in marker_timings if t.timestamp >= 0}
|
||||
timing_lookup = {
|
||||
t.marker_id: t.timestamp for t in marker_timings if t.timestamp >= 0
|
||||
}
|
||||
|
||||
# Process slides in order
|
||||
slide_ids = sorted(
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Hand off a finished video to the gnommoweb server.
|
||||
|
||||
Works for any gnommo project type: parent videos and shorts alike.
|
||||
|
||||
Usage:
|
||||
gnommo handoff -p video1
|
||||
gnommo handoff -p short_pixelated_universe
|
||||
gnommo handoff -p video1 --file /path/to/render.mp4
|
||||
|
||||
Reads project.json for the 'output_video' field (path relative to the
|
||||
project directory). Override with --file.
|
||||
|
||||
On success:
|
||||
- Uploads the video to MinIO via POST /api/projects/:handle/handoff
|
||||
- For shorts: server auto-advances status to 'processed'
|
||||
- Bumps video_version on every upload
|
||||
- Updates .gnommo_sync.json with new video_version
|
||||
|
||||
Configuration (from .env or environment):
|
||||
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
|
||||
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
SYNC_FILE = ".gnommo_sync.json"
|
||||
|
||||
|
||||
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) -> dict:
|
||||
sync_file = project_path / SYNC_FILE
|
||||
if sync_file.exists():
|
||||
with open(sync_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_sync(project_path: Path, data: dict):
|
||||
with open(project_path / SYNC_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def cmd_handoff(project_path: Path, verbose: bool = False, file_override: str | None = None) -> int:
|
||||
_load_env_file()
|
||||
|
||||
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
|
||||
|
||||
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_video = project.get("output_video")
|
||||
if not output_video:
|
||||
print(
|
||||
"Error: no 'output_video' field in project.json and no --file provided.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
video_path = project_path / output_video
|
||||
|
||||
if not video_path.exists():
|
||||
print(f"Error: video file not found: {video_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
file_size_mb = video_path.stat().st_size / (1024 * 1024)
|
||||
if verbose:
|
||||
print(f"Handing off {project_id} → {api_url}")
|
||||
print(f" File: {video_path} ({file_size_mb:.1f} MB)")
|
||||
|
||||
# ── Upload ─────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
with open(video_path, "rb") as vf:
|
||||
r = requests.post(
|
||||
f"{api_url}/api/projects/{project_id}/handoff",
|
||||
files={"video": (video_path.name, vf, _mime_type(video_path))},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=None, # large files may take a while
|
||||
)
|
||||
r.raise_for_status()
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"Error: Could not connect to {api_url}", file=sys.stderr)
|
||||
return 1
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"Error: Server returned {e.response.status_code}", file=sys.stderr)
|
||||
try:
|
||||
print(f" {e.response.json()}", file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
result = r.json()
|
||||
video_version = result.get("video_version", "?")
|
||||
video_url = result.get("video_url", "")
|
||||
|
||||
# ── Write sync state ───────────────────────────────────────────────────────
|
||||
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||
existing_sync = _read_sync(project_path)
|
||||
_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")),
|
||||
})
|
||||
|
||||
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")
|
||||
+70
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -41,13 +41,18 @@ class ProjectConfig:
|
||||
cutouts: dict[str, CutoutDefinition] = field(
|
||||
default_factory=dict
|
||||
) # Named zones for video placement
|
||||
default_filters: dict[str, list[dict]] = field(
|
||||
default_factory=dict
|
||||
) # Named filter presets that can be referenced in videos.json
|
||||
background: str = "" # Background image or video path (in shared_assets/)
|
||||
background_video: str = "" # Deprecated: use background instead
|
||||
slides_path: str = "slides.json" # path to slides.json relative to project
|
||||
videos_path: str = "videos.json" # path to videos.json relative to project
|
||||
audio_path: str = "audio.json" # path to audio.json relative to project
|
||||
audio_source: Optional[str] = None # defaults to talking head
|
||||
main_video: Optional[str] = None # ID of main video (e.g., 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)
|
||||
@@ -165,6 +170,16 @@ class ColorGradeConfig:
|
||||
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.
|
||||
@@ -173,9 +188,43 @@ class AudioNormalizeConfig:
|
||||
to improve audio quality and consistency.
|
||||
"""
|
||||
|
||||
# Parametric EQ bands (applied before other processing)
|
||||
eq_bands: list[EQBand] = field(default_factory=list)
|
||||
|
||||
# High-pass filter (remove room rumble)
|
||||
highpass: float = (
|
||||
0.0 # High-pass frequency in Hz (0 = disabled, try 80-120 for voice)
|
||||
)
|
||||
|
||||
# Low-pass filter (remove harsh highs)
|
||||
lowpass: float = (
|
||||
0.0 # Low-pass frequency in Hz (0 = disabled, try 12000-16000 if needed)
|
||||
)
|
||||
|
||||
# Room resonance EQ cut (reduce muddy room buildup)
|
||||
room_eq: bool = False # Enable room resonance cut
|
||||
room_eq_freq: float = 300.0 # Center frequency for room cut (Hz, typically 200-400)
|
||||
room_eq_gain: float = -4.0 # Gain in dB (negative = cut)
|
||||
room_eq_width: float = 1.5 # Q/bandwidth (higher = narrower cut)
|
||||
|
||||
# Noise gate (reduce reverb tails during pauses)
|
||||
gate: bool = False # Enable noise gate
|
||||
gate_threshold: float = -35.0 # Threshold in dB (signal below this gets attenuated)
|
||||
gate_range: float = -20.0 # Attenuation amount in dB when gate is closed
|
||||
gate_attack: float = 10.0 # Attack time in ms
|
||||
gate_release: float = 150.0 # Release time in ms
|
||||
|
||||
# Neural de-reverb (arnndn filter - very effective but needs model file)
|
||||
dereverb_model: str = "" # Path to RNNoise model file (empty = disabled)
|
||||
dereverb_mix: float = (
|
||||
0.8 # Mix ratio 0.0-1.0 (1.0 = full effect, 0.8 = preserve some natural room)
|
||||
)
|
||||
|
||||
# Noise reduction (afftdn filter)
|
||||
denoise: bool = True # Enable noise reduction
|
||||
noise_floor: float = -25.0 # Noise floor in dB (default -25, lower = more aggressive)
|
||||
noise_floor: float = (
|
||||
-25.0
|
||||
) # Noise floor in dB (default -25, lower = more aggressive)
|
||||
|
||||
# Compression (acompressor filter)
|
||||
compress: bool = True # Enable dynamic range compression
|
||||
@@ -187,7 +236,9 @@ class AudioNormalizeConfig:
|
||||
|
||||
# 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_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
|
||||
|
||||
@@ -234,7 +285,13 @@ class VideoSource:
|
||||
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"
|
||||
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)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -270,7 +327,10 @@ class AudioDefinition:
|
||||
file: str # Audio filename (relative to audio.json location)
|
||||
volume: float = 1.0 # Volume multiplier (0.0-1.0)
|
||||
loop: bool = False # If True, loop for entire duration from trigger point
|
||||
ignore_pauses: bool = False # If True, audio continues playing during narration pauses
|
||||
overlap: Optional[float] = None # Crossfade overlap in seconds when looping
|
||||
ignore_pauses: bool = (
|
||||
False # If True, audio continues playing during narration pauses
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -441,6 +501,10 @@ class RenderPlan:
|
||||
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)
|
||||
|
||||
|
||||
# Slide layout configurations (hardcoded for POC)
|
||||
|
||||
+220
-22
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -24,8 +25,9 @@ def parse_manuscript(
|
||||
"""
|
||||
Parse manuscript.txt and extract text content and slide markers.
|
||||
|
||||
Strips [cite:...] markers from the returned text so they never pollute
|
||||
alignment contexts. Citations are extracted and returned separately.
|
||||
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, list of citations)
|
||||
@@ -43,6 +45,10 @@ def parse_manuscript(
|
||||
# Strip [cite:...] markers from text so they don't pollute alignment
|
||||
text = re.sub(r"\[cite:[^\]]+\]", "", text)
|
||||
|
||||
# Strip [marker:...] and [cue:...] markers (personal recording cues, ignored by pipeline)
|
||||
text = re.sub(r"\[marker:[^\]]+\]", "", text)
|
||||
text = re.sub(r"\[cue:[^\]]+\]", "", text)
|
||||
|
||||
# Extract all valid markers like [S1], [video:demo], [Zoom2], etc.
|
||||
# Include . in pattern to catch markers with file extensions (so validator can warn about them)
|
||||
markers = re.findall(r"\[([A-Za-z0-9_:.]+)\]", text)
|
||||
@@ -118,10 +124,7 @@ def parse_citations(manuscript_text: str) -> list[Citation]:
|
||||
|
||||
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
|
||||
]
|
||||
data = [{"reference": c.reference, "context": c.context} for c in citations]
|
||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
@@ -179,11 +182,15 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||
if not isinstance(resolution, list) or len(resolution) != 2:
|
||||
raise ParseError("resolution must be [width, height]", config_path)
|
||||
|
||||
# Parse default_filters (named filter presets)
|
||||
default_filters: dict[str, list[dict]] = data.get("default_filters", {})
|
||||
|
||||
return ProjectConfig(
|
||||
resolution=tuple(resolution),
|
||||
fps=data.get("fps", 30),
|
||||
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"),
|
||||
@@ -220,12 +227,14 @@ def parse_slides(
|
||||
) -> dict[str, SlideDefinition]:
|
||||
"""Parse slides.json into slide definitions."""
|
||||
if config and config.slides_path:
|
||||
slides_path = project_path / config.slides_path
|
||||
local_slides_path = project_path / config.slides_path
|
||||
else:
|
||||
slides_path = project_path / "slides.json"
|
||||
local_slides_path = project_path / "slides.json"
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
slides_path, _ = resolve_with_cache(local_slides_path, project_path)
|
||||
if not slides_path.exists():
|
||||
raise ParseError(f"slides file not found: {slides_path}", slides_path)
|
||||
raise ParseError(f"slides file not found: {local_slides_path}", local_slides_path)
|
||||
|
||||
try:
|
||||
data = json.loads(slides_path.read_text(encoding="utf-8"))
|
||||
@@ -257,15 +266,19 @@ def parse_audio(
|
||||
containing audio.json (for resolving relative file paths).
|
||||
"""
|
||||
if config and config.audio_path:
|
||||
audio_path = project_path / config.audio_path
|
||||
local_audio_path = project_path / config.audio_path
|
||||
else:
|
||||
audio_path = project_path / "audio.json"
|
||||
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 {}, project_path
|
||||
|
||||
audio_dir = audio_path.parent
|
||||
return {}, audio_dir
|
||||
|
||||
try:
|
||||
data = json.loads(audio_path.read_text(encoding="utf-8"))
|
||||
@@ -278,41 +291,102 @@ def parse_audio(
|
||||
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"])
|
||||
|
||||
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)),
|
||||
)
|
||||
|
||||
return audio, audio_dir
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> float:
|
||||
"""
|
||||
Parse a timestamp string into seconds.
|
||||
|
||||
Supported formats:
|
||||
- "3.5s" or "3.5" → 3.5 seconds
|
||||
- "2:54" → 2 minutes 54 seconds (174.0)
|
||||
- "1:23:45" → 1 hour 23 minutes 45 seconds
|
||||
- "2:54.5" → 2 minutes 54.5 seconds
|
||||
|
||||
Returns:
|
||||
Time in seconds as a float.
|
||||
"""
|
||||
if not value:
|
||||
return 0.0
|
||||
|
||||
value = value.strip()
|
||||
|
||||
# Remove trailing 's' if present (e.g., "3.5s")
|
||||
if value.endswith("s"):
|
||||
value = value[:-1]
|
||||
|
||||
# Check for colon-separated format (MM:SS or HH:MM:SS)
|
||||
if ":" in value:
|
||||
parts = value.split(":")
|
||||
if len(parts) == 2:
|
||||
# MM:SS format
|
||||
minutes, seconds = parts
|
||||
return float(minutes) * 60 + float(seconds)
|
||||
elif len(parts) == 3:
|
||||
# HH:MM:SS format
|
||||
hours, minutes, seconds = parts
|
||||
return float(hours) * 3600 + float(minutes) * 60 + float(seconds)
|
||||
else:
|
||||
raise ParseError(f"Invalid timestamp format: {value}", None)
|
||||
|
||||
# Plain number (seconds)
|
||||
return float(value)
|
||||
|
||||
|
||||
def parse_videos(
|
||||
project_path: Path, config: Optional[ProjectConfig] = None
|
||||
) -> tuple[dict[str, VideoSource], Path]:
|
||||
"""
|
||||
Parse videos.json into video source definitions.
|
||||
|
||||
Filter can be specified as:
|
||||
- A list of filter configs (inline definition)
|
||||
- A string referencing a named preset in config.default_filters
|
||||
|
||||
Trim points can be specified as:
|
||||
- skip/take: raw values in seconds (traditional)
|
||||
- begin/end: timestamp strings like "3.5s", "2:54", "1:23:45" (user-friendly)
|
||||
These are converted to skip/take internally.
|
||||
|
||||
Returns:
|
||||
Tuple of (videos dict, videos_dir) where videos_dir is the directory
|
||||
containing videos.json (for resolving relative file paths).
|
||||
"""
|
||||
if config and config.videos_path:
|
||||
videos_path = project_path / config.videos_path
|
||||
local_videos_path = project_path / config.videos_path
|
||||
else:
|
||||
videos_path = project_path / "videos.json"
|
||||
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: {videos_path}", videos_path)
|
||||
|
||||
videos_dir = videos_path.parent
|
||||
raise ParseError(f"videos.json not found: {local_videos_path}", local_videos_path)
|
||||
|
||||
try:
|
||||
data = json.loads(videos_path.read_text(encoding="utf-8"))
|
||||
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 "source_file" not in video_data:
|
||||
@@ -330,12 +404,39 @@ def parse_videos(
|
||||
url=attr_data.get("url"),
|
||||
)
|
||||
|
||||
# Resolve filter - can be a list or a string reference to default_filters
|
||||
filter_value = video_data.get("filter", [])
|
||||
if isinstance(filter_value, str):
|
||||
# It's a reference to a named filter preset
|
||||
if filter_value not in default_filters:
|
||||
raise ParseError(
|
||||
f"Video '{video_id}' references unknown filter preset '{filter_value}'. "
|
||||
f"Available presets: {list(default_filters.keys())}",
|
||||
videos_path,
|
||||
)
|
||||
filter_list = default_filters[filter_value]
|
||||
else:
|
||||
# It's an inline filter definition
|
||||
filter_list = filter_value
|
||||
|
||||
# Handle skip/take - can use begin/end as user-friendly alternatives
|
||||
skip = video_data.get("skip", 0.0)
|
||||
take = video_data.get("take")
|
||||
|
||||
# Convert begin/end to skip/take if provided
|
||||
if "begin" in video_data and video_data["begin"]:
|
||||
skip = parse_timestamp(video_data["begin"])
|
||||
if "end" in video_data and video_data["end"]:
|
||||
end_time = parse_timestamp(video_data["end"])
|
||||
# take = end - begin (duration from begin to end)
|
||||
take = end_time - skip
|
||||
|
||||
videos[video_id] = VideoSource(
|
||||
source_file=video_data["source_file"],
|
||||
filter=video_data.get("filter", []),
|
||||
filter=filter_list,
|
||||
output_file=video_data.get("output_file"),
|
||||
take=video_data.get("take"),
|
||||
skip=video_data.get("skip", 0.0),
|
||||
take=take,
|
||||
skip=skip,
|
||||
zoom=video_data.get("zoom", 1.0),
|
||||
cutout=video_data.get("cutout"),
|
||||
always_visible=video_data.get("always_visible", False),
|
||||
@@ -343,11 +444,108 @@ def parse_videos(
|
||||
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)),
|
||||
)
|
||||
|
||||
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 = json.loads(narration_path.read_text(encoding="utf-8"))
|
||||
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
|
||||
skip = segment_data.get("skip", 0.0)
|
||||
take = segment_data.get("take")
|
||||
|
||||
# Convert begin/end to skip/take if provided
|
||||
if "begin" in segment_data and segment_data["begin"]:
|
||||
skip = parse_timestamp(segment_data["begin"])
|
||||
if "end" in segment_data and segment_data["end"]:
|
||||
end_time = parse_timestamp(segment_data["end"])
|
||||
# take = end - begin (duration from begin to end)
|
||||
take = end_time - skip
|
||||
|
||||
narration[segment_id] = VideoSource(
|
||||
source_file=segment_data["source_file"],
|
||||
filter=filter_list,
|
||||
output_file=segment_data.get("output_file"),
|
||||
take=take,
|
||||
skip=skip,
|
||||
zoom=segment_data.get("zoom", 1.0),
|
||||
cutout=segment_data.get("cutout"),
|
||||
always_visible=segment_data.get("always_visible", False),
|
||||
use_audio_channels=segment_data.get("use_audio_channels", "both"),
|
||||
defer_loudnorm=segment_data.get("defer_loudnorm", False),
|
||||
volume=float(segment_data.get("volume", 1.0)),
|
||||
)
|
||||
|
||||
return narration, narration_dir
|
||||
|
||||
|
||||
def get_video_duration(video_path: Path) -> float:
|
||||
"""Get duration of a video file using ffprobe."""
|
||||
import subprocess
|
||||
|
||||
+634
-114
File diff suppressed because it is too large
Load Diff
+202
@@ -0,0 +1,202 @@
|
||||
"""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 = ".gnommo_sync.json"
|
||||
|
||||
|
||||
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) -> dict:
|
||||
sync_file = project_path / SYNC_FILE
|
||||
if sync_file.exists():
|
||||
with open(sync_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_sync(project_path: Path, data: dict):
|
||||
with open(project_path / SYNC_FILE, "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) -> int:
|
||||
_load_env_file()
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
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,
|
||||
)
|
||||
r.raise_for_status()
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"Error: Could not connect to {api_url}", file=sys.stderr)
|
||||
return 1
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
print(f"Error: Project '{project_id}' not found on server. Push it first.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error: Server returned {e.response.status_code}", file=sys.stderr)
|
||||
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)
|
||||
_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"),
|
||||
})
|
||||
|
||||
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
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
"""Push project metadata to gnommoweb server.
|
||||
|
||||
Usage:
|
||||
gnommo push -p video1 # push parent video project
|
||||
gnommo push -p short_pixelated_universe # push a short project
|
||||
gnommo push -p myproject --force # force push, overwrite server
|
||||
|
||||
Reads project.json and POSTs to POST /api/projects/push.
|
||||
|
||||
If project.json contains a "parent_project" field, the project is pushed
|
||||
as a short and registered under that parent. Otherwise it is pushed as a
|
||||
parent video project.
|
||||
|
||||
Parent project.json "shorts" field is a list of slugs (just an index):
|
||||
"shorts": ["short_pixelated_universe", "short_planck_length"]
|
||||
|
||||
Short project.json has its own full config plus a parent_project field:
|
||||
{
|
||||
"id": "short_pixelated_universe",
|
||||
"parent_project": "Video1",
|
||||
"resolution": [1080, 1920],
|
||||
"fps": 30,
|
||||
"duration_seconds": 60,
|
||||
...
|
||||
}
|
||||
|
||||
Conflict detection:
|
||||
- If server.updated_at > our recorded server_updated_at → server has newer changes
|
||||
→ warn and abort unless --force
|
||||
|
||||
Configuration (from .env or environment):
|
||||
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
|
||||
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
SYNC_FILE = ".gnommo_sync.json"
|
||||
|
||||
|
||||
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) -> dict:
|
||||
sync_file = project_path / SYNC_FILE
|
||||
if sync_file.exists():
|
||||
with open(sync_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_sync(project_path: Path, data: dict):
|
||||
with open(project_path / SYNC_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def _parse_ts(ts_str) -> datetime | None:
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cmd_push(project_path: Path, verbose: bool = False, force: bool = False) -> int:
|
||||
_load_env_file()
|
||||
|
||||
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
|
||||
|
||||
project_file = project_path / "project.json"
|
||||
if not project_file.exists():
|
||||
print(f"Error: {project_file} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(project_file) as f:
|
||||
project = json.load(f)
|
||||
|
||||
project_id = project.get("id")
|
||||
name = project.get("name")
|
||||
if not project_id or not name:
|
||||
print("Error: project.json must have 'id' and 'name' fields.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parent_project = project.get("parent_project")
|
||||
|
||||
# ── Conflict check ────────────────────────────────────────────────────────
|
||||
if not force:
|
||||
sync = _read_sync(project_path)
|
||||
recorded_server_ts = _parse_ts(sync.get("server_updated_at"))
|
||||
if recorded_server_ts:
|
||||
try:
|
||||
r_check = requests.get(
|
||||
f"{api_url}/api/projects/{project_id}",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=10,
|
||||
)
|
||||
if r_check.status_code == 200:
|
||||
current_server_ts = _parse_ts(r_check.json().get("updated_at"))
|
||||
if current_server_ts and current_server_ts > recorded_server_ts:
|
||||
print(
|
||||
f"⚠ Server has changes since your last sync "
|
||||
f"({current_server_ts.strftime('%Y-%m-%d %H:%M')} > "
|
||||
f"{recorded_server_ts.strftime('%Y-%m-%d %H:%M')}).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
" Pull first with `gnommo pull -p` or use `gnommo push -p --force`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
except requests.exceptions.ConnectionError:
|
||||
pass
|
||||
|
||||
# ── Build payload ─────────────────────────────────────────────────────────
|
||||
if parent_project:
|
||||
payload = _build_short_payload(project, project_path, verbose)
|
||||
else:
|
||||
payload = _build_parent_payload(project, project_path, verbose)
|
||||
|
||||
if verbose:
|
||||
kind = "short" if parent_project else "parent video"
|
||||
print(f"Pushing {project_id} ({kind}) to {api_url}")
|
||||
|
||||
# ── POST ──────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{api_url}/api/projects/push",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"Error: Could not connect to {api_url}", file=sys.stderr)
|
||||
return 1
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"Error: Server returned {e.response.status_code}", file=sys.stderr)
|
||||
try:
|
||||
print(f" {e.response.json()}", file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
result = r.json()
|
||||
server_updated_at = result.get("server_updated_at")
|
||||
|
||||
# ── Write sync state ──────────────────────────────────────────────────────
|
||||
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||
existing_sync = _read_sync(project_path)
|
||||
_write_sync(project_path, {
|
||||
**existing_sync,
|
||||
"last_pushed_at": now_iso,
|
||||
"server_updated_at": server_updated_at,
|
||||
})
|
||||
|
||||
# ── Print summary ─────────────────────────────────────────────────────────
|
||||
asset = result.get("asset", {})
|
||||
if result.get("type") == "short":
|
||||
print(f"✓ {project_id} → gn_asset #{asset.get('id')} [{asset.get('status')}]")
|
||||
if result.get("task_created"):
|
||||
print(f" task #{result['task_id']} created")
|
||||
else:
|
||||
print(f"✓ {project_id} → gn_asset #{asset.get('id')} ({asset.get('name')})")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> dict:
|
||||
# Read the manuscript file if one is specified
|
||||
script_content = None
|
||||
manuscript_str = project.get("manuscript")
|
||||
if manuscript_str:
|
||||
manuscript_path = project_path / manuscript_str
|
||||
if manuscript_path.exists():
|
||||
script_content = manuscript_path.read_text()
|
||||
if verbose:
|
||||
print(f" Read manuscript: {manuscript_path} ({len(script_content)} chars)")
|
||||
else:
|
||||
print(f" Warning: manuscript file not found: {manuscript_path}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"project_id": project["id"],
|
||||
"name": project["name"],
|
||||
"description": project.get("description"),
|
||||
"coursecode": project.get("coursecode"),
|
||||
"script_content": script_content,
|
||||
"shorts": project.get("shorts", []), # list of slugs, not objects
|
||||
}
|
||||
|
||||
|
||||
def _build_short_payload(project: dict, project_path: Path, verbose: bool) -> dict:
|
||||
# Read the script file if one is specified
|
||||
script_content = None
|
||||
script_path_str = project.get("script")
|
||||
if script_path_str:
|
||||
script_path = project_path / script_path_str
|
||||
if script_path.exists():
|
||||
script_content = script_path.read_text()
|
||||
if verbose:
|
||||
print(f" Read script: {script_path} ({len(script_content)} chars)")
|
||||
else:
|
||||
print(f" Warning: script file not found: {script_path}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"project_id": project["id"],
|
||||
"name": project["name"],
|
||||
"description": project.get("description"),
|
||||
"parent_project": project["parent_project"],
|
||||
"hook": project.get("hook"),
|
||||
"script_content": script_content,
|
||||
"platform_targets": project.get("platform_targets", ["youtube"]),
|
||||
"resolution": project.get("resolution"),
|
||||
"fps": project.get("fps"),
|
||||
"duration_seconds": project.get("duration_seconds"),
|
||||
}
|
||||
+204
-30
@@ -19,6 +19,110 @@ from .models import (
|
||||
from .preprocessor import run_ffmpeg_with_progress
|
||||
|
||||
|
||||
def _get_audio_duration(audio_path: Path) -> float:
|
||||
"""Get duration of an audio file using ffprobe."""
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(audio_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RenderError(f"Failed to get duration for {audio_path}: {result.stderr}")
|
||||
return float(result.stdout.strip())
|
||||
|
||||
|
||||
def _build_crossfade_loop_filter(
|
||||
input_label: str,
|
||||
output_label: str,
|
||||
audio_duration: float,
|
||||
overlap: float,
|
||||
needed_duration: float,
|
||||
volume: float,
|
||||
delay_ms: int,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build FFmpeg filter chain for crossfade looping.
|
||||
|
||||
Creates a seamless loop by overlapping copies of the audio with fade in/out.
|
||||
Each loop iteration crossfades with the next for `overlap` seconds.
|
||||
|
||||
Args:
|
||||
input_label: Input stream label (e.g., "[0:a]")
|
||||
output_label: Output stream label (e.g., "[aud0]")
|
||||
audio_duration: Duration of the source audio in seconds
|
||||
overlap: Crossfade overlap duration in seconds
|
||||
needed_duration: Total duration needed
|
||||
volume: Volume multiplier
|
||||
delay_ms: Initial delay in milliseconds
|
||||
|
||||
Returns:
|
||||
List of filter strings to append to the filter_complex
|
||||
"""
|
||||
filters = []
|
||||
loop_len = audio_duration - overlap
|
||||
|
||||
# Calculate number of loop iterations needed (add 1 extra for safety)
|
||||
n_loops = math.ceil(needed_duration / loop_len) + 1
|
||||
|
||||
# Limit to reasonable number of loops to avoid filter complexity explosion
|
||||
n_loops = min(n_loops, 100)
|
||||
|
||||
if n_loops <= 1:
|
||||
# Single play, no looping needed
|
||||
filters.append(
|
||||
f"{input_label}atrim=0:{needed_duration:.3f},"
|
||||
f"asetpts=PTS-STARTPTS,"
|
||||
f"adelay={delay_ms}|{delay_ms},"
|
||||
f"volume={volume:.2f}{output_label}"
|
||||
)
|
||||
return filters
|
||||
|
||||
# Split input into n_loops copies
|
||||
split_labels = [f"[xfloop_{output_label[1:-1]}_{i}]" for i in range(n_loops)]
|
||||
filters.append(f"{input_label}asplit={n_loops}{''.join(split_labels)}")
|
||||
|
||||
# Process each copy with appropriate delay and fades
|
||||
mix_labels = []
|
||||
for i in range(n_loops):
|
||||
copy_label = split_labels[i]
|
||||
out_label = f"[xfl_{output_label[1:-1]}_{i}]"
|
||||
mix_labels.append(out_label)
|
||||
|
||||
loop_delay = i * loop_len
|
||||
total_delay_ms = delay_ms + int(loop_delay * 1000)
|
||||
|
||||
# Build filter chain for this copy
|
||||
chain_parts = []
|
||||
|
||||
# Fade in at start (except first copy)
|
||||
if i > 0:
|
||||
chain_parts.append(f"afade=t=in:d={overlap:.3f}")
|
||||
|
||||
# Fade out at end (for overlap with next copy)
|
||||
# Calculate fade start time
|
||||
fade_out_start = audio_duration - overlap
|
||||
if fade_out_start > 0:
|
||||
chain_parts.append(f"afade=t=out:st={fade_out_start:.3f}:d={overlap:.3f}")
|
||||
|
||||
chain_parts.append(f"adelay={total_delay_ms}|{total_delay_ms}")
|
||||
chain_parts.append(f"volume={volume:.2f}")
|
||||
|
||||
filter_chain = ",".join(chain_parts)
|
||||
filters.append(f"{copy_label}{filter_chain}{out_label}")
|
||||
|
||||
# Mix all copies together, then trim to needed duration
|
||||
filters.append(
|
||||
f"{''.join(mix_labels)}amix=inputs={n_loops}:duration=longest:normalize=0,"
|
||||
f"atrim=0:{needed_duration + delay_ms/1000:.3f},"
|
||||
f"asetpts=PTS-STARTPTS{output_label}"
|
||||
)
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def render(plan: RenderPlan, output_path: Path, verbose: bool = False) -> None:
|
||||
"""
|
||||
Render the final video using FFmpeg.
|
||||
@@ -56,6 +160,7 @@ def _resolve_video_path(
|
||||
videos_dir: Path,
|
||||
video_source: VideoSource,
|
||||
shared_assets_dir: Path = None,
|
||||
project_path: Path = None,
|
||||
) -> Path:
|
||||
"""Resolve the actual video file path (output_file if exists, else source_file).
|
||||
|
||||
@@ -63,7 +168,10 @@ def _resolve_video_path(
|
||||
compressed alpha channel support.
|
||||
|
||||
If video_source.is_shared is True, looks in shared_assets_dir instead of videos_dir.
|
||||
Uses gnommocache fallback if configured and project_path is provided.
|
||||
"""
|
||||
from .cache import resolve_with_cache
|
||||
|
||||
# Determine base directory based on is_shared flag
|
||||
if video_source.is_shared and shared_assets_dir:
|
||||
base_dir = shared_assets_dir
|
||||
@@ -72,26 +180,47 @@ def _resolve_video_path(
|
||||
|
||||
if video_source.output_file:
|
||||
video_path = base_dir / video_source.output_file
|
||||
if video_path.exists():
|
||||
# Check with cache fallback
|
||||
if project_path:
|
||||
resolved, _ = resolve_with_cache(video_path, project_path)
|
||||
if resolved.exists():
|
||||
return resolved
|
||||
elif video_path.exists():
|
||||
return video_path
|
||||
# Check for WebM variant (preprocessing outputs compressed WebM instead of ProRes)
|
||||
webm_path = video_path.with_suffix(".mov")
|
||||
if webm_path.exists():
|
||||
if project_path:
|
||||
resolved, _ = resolve_with_cache(webm_path, project_path)
|
||||
if resolved.exists():
|
||||
return resolved
|
||||
elif webm_path.exists():
|
||||
return webm_path
|
||||
return base_dir / video_source.source_file
|
||||
|
||||
# Fall back to source_file with cache fallback
|
||||
source_path = base_dir / video_source.source_file
|
||||
if project_path:
|
||||
resolved, _ = resolve_with_cache(source_path, project_path)
|
||||
return resolved
|
||||
return source_path
|
||||
|
||||
|
||||
def _has_audio_stream(video_path: Path) -> bool:
|
||||
"""Check if a video file contains an audio stream using ffprobe."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error",
|
||||
"-select_streams", "a",
|
||||
"-show_entries", "stream=index",
|
||||
"-of", "csv=p=0",
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a",
|
||||
"-show_entries",
|
||||
"stream=index",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
str(video_path),
|
||||
],
|
||||
capture_output=True, text=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
@@ -131,7 +260,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||
# Add -ss seek BEFORE -i for skip parameter and/or partial rendering
|
||||
always_visible_inputs: list[int] = []
|
||||
for video_id, video_source, cutout in plan.narration_videos:
|
||||
video_path = _resolve_video_path(videos_dir, video_source, shared_assets_dir)
|
||||
video_path = _resolve_video_path(videos_dir, video_source, shared_assets_dir, project_path)
|
||||
# Combine video skip setting with partial render offset
|
||||
total_seek = video_source.skip + plan.input_seek_time
|
||||
if total_seek > 0:
|
||||
@@ -141,12 +270,14 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||
input_idx += 1
|
||||
|
||||
# Input: background image/video (if specified)
|
||||
from .cache import resolve_with_cache
|
||||
bg_file = plan.config.background or plan.config.background_video
|
||||
has_background = bool(bg_file)
|
||||
bg_idx = None
|
||||
bg_is_image = False
|
||||
if has_background:
|
||||
bg_path = project_path / bg_file
|
||||
bg_path, _ = resolve_with_cache(bg_path, project_path)
|
||||
if not bg_path.exists():
|
||||
bg_path = project_path.parent / bg_file
|
||||
image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"}
|
||||
@@ -169,6 +300,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||
for event in plan.slide_events:
|
||||
if event.slide_id not in slide_inputs:
|
||||
image_path = slides_dir / event.slide_def.image
|
||||
image_path, _ = resolve_with_cache(image_path, project_path)
|
||||
cmd.extend(["-i", str(image_path)])
|
||||
slide_inputs[event.slide_id] = input_idx
|
||||
input_idx += 1
|
||||
@@ -181,7 +313,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||
|
||||
for i, event in enumerate(plan.video_events):
|
||||
video_path = _resolve_video_path(
|
||||
videos_dir, event.video_source, shared_assets_dir
|
||||
videos_dir, event.video_source, shared_assets_dir, project_path
|
||||
)
|
||||
# Seek to skip point before loading input
|
||||
skip = event.video_source.skip
|
||||
@@ -199,7 +331,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||
|
||||
for i, event in enumerate(plan.outro_events):
|
||||
video_path = _resolve_video_path(
|
||||
videos_dir, event.video_source, shared_assets_dir
|
||||
videos_dir, event.video_source, shared_assets_dir, project_path
|
||||
)
|
||||
# Seek to skip point before loading input
|
||||
skip = event.video_source.skip
|
||||
@@ -217,13 +349,18 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||
# Input: audio files
|
||||
audio_dir = plan.audio_dir.resolve() if plan.audio_dir else project_path
|
||||
audio_inputs: dict[str, int] = {} # audio_id -> input_idx
|
||||
audio_durations: dict[str, float] = {} # audio_id -> duration (for crossfade loops)
|
||||
|
||||
for event in plan.audio_events:
|
||||
if event.audio_id not in audio_inputs:
|
||||
audio_path = audio_dir / event.audio_def.file
|
||||
audio_path, _ = resolve_with_cache(audio_path, project_path)
|
||||
cmd.extend(["-i", str(audio_path)])
|
||||
audio_inputs[event.audio_id] = input_idx
|
||||
input_idx += 1
|
||||
# Cache duration if this audio uses crossfade looping
|
||||
if event.audio_def.loop and event.audio_def.overlap:
|
||||
audio_durations[event.audio_id] = _get_audio_duration(audio_path)
|
||||
|
||||
# Build filter_complex
|
||||
filter_complex = build_filter_complex(
|
||||
@@ -236,6 +373,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||
video_inputs,
|
||||
num_inputs_before_audio,
|
||||
audio_inputs,
|
||||
audio_durations,
|
||||
video_events_with_audio,
|
||||
outro_inputs,
|
||||
outro_events_with_audio,
|
||||
@@ -541,6 +679,7 @@ def build_filter_complex(
|
||||
video_inputs: dict[int, int], # event_index -> input_idx
|
||||
num_inputs_before_audio: int,
|
||||
audio_inputs: dict[str, int],
|
||||
audio_durations: dict[str, float], # audio_id -> duration (for crossfade loops)
|
||||
video_events_with_audio: set[int] = None,
|
||||
outro_inputs: dict[int, int] = None, # outro event_index -> input_idx
|
||||
outro_events_with_audio: set[int] = None,
|
||||
@@ -790,48 +929,65 @@ def build_filter_complex(
|
||||
main_audio_idx = always_visible_inputs[0]
|
||||
audio_labels_to_mix = []
|
||||
|
||||
# Get audio channel setting from first narration video
|
||||
# Get audio channel setting and volume from first narration video
|
||||
channel_filter = ""
|
||||
narration_volume = 1.0
|
||||
if plan.narration_videos:
|
||||
_, first_video_source, _ = plan.narration_videos[0]
|
||||
channel_filter = _build_audio_channel_filter(
|
||||
first_video_source.use_audio_channels
|
||||
)
|
||||
narration_volume = first_video_source.volume
|
||||
|
||||
# Build volume filter if not 1.0
|
||||
volume_filter = f"volume={narration_volume:.2f}" if narration_volume != 1.0 else ""
|
||||
|
||||
# Use narration_end_time to stop audio before outro (if outro exists)
|
||||
audio_end_time = plan.narration_end_time if plan.outro_events else plan.total_duration
|
||||
audio_end_time = (
|
||||
plan.narration_end_time if plan.outro_events else plan.total_duration
|
||||
)
|
||||
|
||||
if not plan.narration_pauses:
|
||||
# Simple case: trim main audio to end before outro (with optional channel filter)
|
||||
# Simple case: trim main audio to end before outro (with optional channel and volume filters)
|
||||
filter_parts = []
|
||||
if channel_filter:
|
||||
filter_parts.append(channel_filter)
|
||||
if volume_filter:
|
||||
filter_parts.append(volume_filter)
|
||||
|
||||
if plan.outro_events:
|
||||
# Trim narration audio to stop before outro
|
||||
if channel_filter:
|
||||
filters.append(f"[{main_audio_idx}:a]{channel_filter}atrim=0:{audio_end_time:.3f},asetpts=PTS-STARTPTS[main_aud]")
|
||||
else:
|
||||
filters.append(f"[{main_audio_idx}:a]atrim=0:{audio_end_time:.3f},asetpts=PTS-STARTPTS[main_aud]")
|
||||
filter_parts.append(f"atrim=0:{audio_end_time:.3f}")
|
||||
filter_parts.append("asetpts=PTS-STARTPTS")
|
||||
filters.append(
|
||||
f"[{main_audio_idx}:a]{','.join(filter_parts)}[main_aud]"
|
||||
)
|
||||
audio_labels_to_mix.append("[main_aud]")
|
||||
elif channel_filter:
|
||||
filters.append(f"[{main_audio_idx}:a]{channel_filter}[main_aud]")
|
||||
elif filter_parts:
|
||||
filters.append(f"[{main_audio_idx}:a]{','.join(filter_parts)}[main_aud]")
|
||||
audio_labels_to_mix.append("[main_aud]")
|
||||
else:
|
||||
audio_labels_to_mix.append(f"[{main_audio_idx}:a]")
|
||||
else:
|
||||
# Complex case: segment the narration audio for pauses
|
||||
segments = _build_narration_segments(
|
||||
plan.narration_pauses, audio_end_time
|
||||
)
|
||||
segments = _build_narration_segments(plan.narration_pauses, audio_end_time)
|
||||
for seg_idx, (src_start, src_end, out_start, out_end) in enumerate(
|
||||
segments
|
||||
):
|
||||
seg_label = f"narr_aud{seg_idx}"
|
||||
delay_ms = int(out_start * 1000)
|
||||
# Trim audio to source range, then delay to output position
|
||||
# Apply channel filter if needed
|
||||
channel_part = f"{channel_filter}," if channel_filter else ""
|
||||
# Apply channel filter, volume filter if needed
|
||||
filter_parts = []
|
||||
if channel_filter:
|
||||
filter_parts.append(channel_filter)
|
||||
filter_parts.append(f"atrim={src_start:.3f}:{src_end:.3f}")
|
||||
filter_parts.append("asetpts=PTS-STARTPTS")
|
||||
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
|
||||
if volume_filter:
|
||||
filter_parts.append(volume_filter)
|
||||
filters.append(
|
||||
f"[{main_audio_idx}:a]{channel_part}atrim={src_start:.3f}:{src_end:.3f},"
|
||||
f"asetpts=PTS-STARTPTS,"
|
||||
f"adelay={delay_ms}|{delay_ms}[{seg_label}]"
|
||||
f"[{main_audio_idx}:a]{','.join(filter_parts)}[{seg_label}]"
|
||||
)
|
||||
audio_labels_to_mix.append(f"[{seg_label}]")
|
||||
|
||||
@@ -850,7 +1006,8 @@ def build_filter_complex(
|
||||
if plan.narration_pauses and not event.audio_def.ignore_pauses:
|
||||
# Build segments that skip narration pauses (pauses by default)
|
||||
relevant_pauses = [
|
||||
p for p in plan.narration_pauses
|
||||
p
|
||||
for p in plan.narration_pauses
|
||||
if p.output_time > event.start_time
|
||||
]
|
||||
src_pos = 0.0
|
||||
@@ -892,6 +1049,22 @@ def build_filter_complex(
|
||||
# Simple loop: no pauses or ignore_pauses=True
|
||||
label = f"aud{i}"
|
||||
delay_ms = int(event.start_time * 1000)
|
||||
|
||||
if event.audio_def.overlap and event.audio_id in audio_durations:
|
||||
# Crossfade loop: overlap copies with fade in/out
|
||||
audio_dur = audio_durations[event.audio_id]
|
||||
crossfade_filters = _build_crossfade_loop_filter(
|
||||
input_label=f"[{audio_idx}:a]",
|
||||
output_label=f"[{label}]",
|
||||
audio_duration=audio_dur,
|
||||
overlap=event.audio_def.overlap,
|
||||
needed_duration=remaining,
|
||||
volume=volume,
|
||||
delay_ms=delay_ms,
|
||||
)
|
||||
filters.extend(crossfade_filters)
|
||||
else:
|
||||
# Standard loop without crossfade
|
||||
filters.append(
|
||||
f"[{audio_idx}:a]aloop=loop=-1:size=2e+09,"
|
||||
f"atrim=0:{remaining:.3f},"
|
||||
@@ -952,8 +1125,9 @@ def build_filter_complex(
|
||||
if len(audio_labels_to_mix) > 1:
|
||||
num_audio_tracks = len(audio_labels_to_mix)
|
||||
audio_mix_inputs = "".join(audio_labels_to_mix)
|
||||
# normalize=0 prevents amix from dividing volume by number of inputs
|
||||
filters.append(
|
||||
f"{audio_mix_inputs}amix=inputs={num_audio_tracks}:duration=longest:dropout_transition=0[aout]"
|
||||
f"{audio_mix_inputs}amix=inputs={num_audio_tracks}:duration=longest:dropout_transition=0:normalize=0[aout]"
|
||||
)
|
||||
elif len(audio_labels_to_mix) == 1:
|
||||
# Single audio track, just copy it
|
||||
|
||||
+98
-2
@@ -5,7 +5,9 @@ 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
|
||||
@@ -78,8 +80,19 @@ def save_transcript(words: list[TranscribedWord], output_path: Path) -> None:
|
||||
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}")
|
||||
|
||||
@@ -89,3 +102,86 @@ def load_transcript(transcript_path: Path) -> list[TranscribedWord]:
|
||||
return [
|
||||
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)
|
||||
|
||||
+58
-11
@@ -442,6 +442,7 @@ def build_render_plan(
|
||||
audio: Optional[dict[str, AudioDefinition]] = None,
|
||||
audio_dir: Optional[Path] = None,
|
||||
slide_range: Optional[tuple[str, Optional[str]]] = None,
|
||||
proxy: Optional[bool] = False,
|
||||
) -> tuple[RenderPlan, list[MarkerTiming]]:
|
||||
"""
|
||||
Build a complete render plan from manuscript and transcription.
|
||||
@@ -461,9 +462,15 @@ def build_render_plan(
|
||||
audio_dir = audio_dir or project_path
|
||||
|
||||
# Find the main narration video first (need skip value for timing adjustment)
|
||||
narration_video_id = config.main_video
|
||||
narration_video_id = "narration_combined.mov" # Default narration video ID
|
||||
# Handle legacy list format - use first element
|
||||
if isinstance(narration_video_id, list):
|
||||
narration_video_id = narration_video_id[0] if narration_video_id else None
|
||||
if not (narration_video_id and narration_video_id in videos):
|
||||
raise ValueError("Main video not specified or not found in videos.")
|
||||
raise ValueError(
|
||||
f"Main video '{narration_video_id}' not specified or not found in videos. "
|
||||
f"Available: {list(videos.keys())}"
|
||||
)
|
||||
narration_video = videos[narration_video_id]
|
||||
|
||||
# Align markers to transcription timestamps
|
||||
@@ -495,8 +502,13 @@ def build_render_plan(
|
||||
narration_video = videos[narration_video_id]
|
||||
cutout = config.cutouts[narration_video.cutout]
|
||||
|
||||
# Track which files are loaded from external cache
|
||||
cached_files: set[str] = set()
|
||||
|
||||
narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = []
|
||||
video_path = _resolve_video_path(videos_dir, narration_video, shared_assets_dir)
|
||||
video_path, is_cached = _resolve_video_path(videos_dir, narration_video, shared_assets_dir, project_path)
|
||||
if is_cached:
|
||||
cached_files.add(narration_video_id)
|
||||
full_duration = get_video_duration(video_path)
|
||||
# Adjust duration for skip (content starts at skip, so effective duration is less)
|
||||
effective_duration = full_duration - narration_skip
|
||||
@@ -536,6 +548,14 @@ def build_render_plan(
|
||||
time_range=(time_offset, render_end_time) if slide_range else None,
|
||||
)
|
||||
|
||||
# Track cached files for triggered videos
|
||||
for event in video_events:
|
||||
_, is_cached = _resolve_video_path(
|
||||
videos_dir, event.video_source, shared_assets_dir, project_path
|
||||
)
|
||||
if is_cached:
|
||||
cached_files.add(event.video_id)
|
||||
|
||||
audio_events = _extract_audio_events(
|
||||
marker_timings,
|
||||
audio,
|
||||
@@ -622,6 +642,8 @@ def build_render_plan(
|
||||
total_duration,
|
||||
videos_dir,
|
||||
shared_assets_dir,
|
||||
project_path,
|
||||
cached_files,
|
||||
)
|
||||
|
||||
# Update total duration to include outro
|
||||
@@ -654,6 +676,7 @@ def build_render_plan(
|
||||
narration_pauses=narration_pauses,
|
||||
outro_events=outro_events,
|
||||
narration_end_time=narration_end_time,
|
||||
cached_files=cached_files,
|
||||
)
|
||||
|
||||
return plan, marker_timings
|
||||
@@ -663,8 +686,16 @@ def _resolve_video_path(
|
||||
videos_dir: Path,
|
||||
video_source: VideoSource,
|
||||
shared_assets_dir: Path = None,
|
||||
) -> Path:
|
||||
"""Resolve the actual video file path."""
|
||||
project_path: Path = None,
|
||||
) -> tuple[Path, bool]:
|
||||
"""Resolve the actual video file path with cache fallback.
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_path, is_cached) where is_cached=True if
|
||||
the file was found in the external cache.
|
||||
"""
|
||||
from .cache import resolve_with_cache
|
||||
|
||||
if video_source.is_shared and shared_assets_dir:
|
||||
base_dir = shared_assets_dir
|
||||
else:
|
||||
@@ -672,12 +703,24 @@ def _resolve_video_path(
|
||||
|
||||
if video_source.output_file:
|
||||
video_path = base_dir / video_source.output_file
|
||||
if video_path.exists():
|
||||
return video_path
|
||||
if project_path:
|
||||
resolved, is_cached = resolve_with_cache(video_path, project_path)
|
||||
if resolved.exists():
|
||||
return resolved, is_cached
|
||||
elif video_path.exists():
|
||||
return video_path, False
|
||||
webm_path = video_path.with_suffix(".mov")
|
||||
if webm_path.exists():
|
||||
return webm_path
|
||||
return base_dir / video_source.source_file
|
||||
if project_path:
|
||||
resolved, is_cached = resolve_with_cache(webm_path, project_path)
|
||||
if resolved.exists():
|
||||
return resolved, is_cached
|
||||
elif webm_path.exists():
|
||||
return webm_path, False
|
||||
|
||||
source_path = base_dir / video_source.source_file
|
||||
if project_path:
|
||||
return resolve_with_cache(source_path, project_path)
|
||||
return source_path, False
|
||||
|
||||
|
||||
def _extract_slide_events(
|
||||
@@ -932,6 +975,8 @@ def _extract_outro_events(
|
||||
narration_end_time: float,
|
||||
videos_dir: Path,
|
||||
shared_assets_dir: Path = None,
|
||||
project_path: Path = None,
|
||||
cached_files: set = None,
|
||||
) -> list[OutroEvent]:
|
||||
"""
|
||||
Extract outro events that play after the narration ends.
|
||||
@@ -949,7 +994,9 @@ def _extract_outro_events(
|
||||
video_source = videos[video_id]
|
||||
|
||||
# Get the video duration
|
||||
video_path = _resolve_video_path(videos_dir, video_source, shared_assets_dir)
|
||||
video_path, is_cached = _resolve_video_path(videos_dir, video_source, shared_assets_dir, project_path)
|
||||
if is_cached and cached_files is not None:
|
||||
cached_files.add(video_id)
|
||||
if video_path.exists():
|
||||
full_duration = get_video_duration(video_path)
|
||||
else:
|
||||
|
||||
+7
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import resolve_with_cache
|
||||
from .errors import ValidationError, ValidationIssue
|
||||
from .models import (
|
||||
ProjectConfig,
|
||||
@@ -98,6 +99,7 @@ def validate_project(
|
||||
|
||||
for slide_id, slide_def in slides.items():
|
||||
image_path = slides_dir / slide_def.image
|
||||
image_path, _ = resolve_with_cache(image_path, project_path)
|
||||
if not image_path.exists():
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
@@ -142,6 +144,7 @@ def validate_project(
|
||||
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(
|
||||
@@ -153,6 +156,7 @@ def validate_project(
|
||||
# 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(
|
||||
@@ -168,9 +172,11 @@ def validate_project(
|
||||
if bg_file:
|
||||
# Check in project folder first, then parent (for shared_assets)
|
||||
bg_path = project_path / bg_file
|
||||
bg_path, _ = resolve_with_cache(bg_path, project_path)
|
||||
if not bg_path.exists():
|
||||
# Try parent directory (shared_assets at repo root)
|
||||
bg_path = project_path.parent / bg_file
|
||||
bg_path, _ = resolve_with_cache(bg_path, project_path.parent)
|
||||
if not bg_path.exists():
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
@@ -188,7 +194,7 @@ def validate_project(
|
||||
|
||||
# Check resolution is reasonable
|
||||
width, height = config.resolution
|
||||
if width < 100 or height < 100:
|
||||
if width < 50 or height < 50:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Resolution too small: {width}x{height}", project_path / "project.json"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACCDr3tCxUf7HC+9s9N0TF9EECMshm6/Epcr6kZzaZGv0AAAAKC+5OiPvuTo
|
||||
jwAAAAtzc2gtZWQyNTUxOQAAACCDr3tCxUf7HC+9s9N0TF9EECMshm6/Epcr6kZzaZGv0A
|
||||
AAAEBKyC2/ZfItNXIf/UcSTYaV/eWjX6uKIrvliO+sdFJUV4Ove0LFR/scL72z03RMX0QQ
|
||||
IyyGbr8SlyvqRnNpka/QAAAAHGplbnMudGFuZHN0YWRAZWFnbGVjb25kb3Iubm8B
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIOve0LFR/scL72z03RMX0QQIyyGbr8SlyvqRnNpka/Q jens.tandstad@eaglecondor.no
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"last_pushed_at": "2026-03-13T09:44:12+00:00",
|
||||
"server_updated_at": "2026-03-13T09:44:12.934Z",
|
||||
"last_pulled_at": "2026-03-13T09:35:00+00:00"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "short_is_universe_pixelated",
|
||||
"name": "Is the universe pixelated?",
|
||||
"description": "What if space is made of tiny blocks? A 60-second take on discrete physics.",
|
||||
"parent_project": "Video1",
|
||||
"hook": "What if reality is fundamentally blocky — like Minecraft, but smaller?",
|
||||
"platform_targets": [
|
||||
"youtube"
|
||||
],
|
||||
"resolution": [
|
||||
1080,
|
||||
1920
|
||||
],
|
||||
"fps": 30,
|
||||
"duration_seconds": 60,
|
||||
"script": "script.md",
|
||||
"output_video": "export/final.mp4",
|
||||
"keynote_file": "../video1/media/video1.key",
|
||||
"background": "../video1/shared_assets/BlackBackground.mp4",
|
||||
"slides": "../video1/media/slides/Video1/slides.json",
|
||||
"defaultSlideType": "fullscreen",
|
||||
"cutouts": {
|
||||
"talkinghead": {
|
||||
"x": "-23%",
|
||||
"y": "10%",
|
||||
"height": "90%"
|
||||
},
|
||||
"fullscreen": {
|
||||
"x": "0%",
|
||||
"y": "0%",
|
||||
"height": "100%"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Short: Is the universe pixelated?
|
||||
|
||||
**HOOK**: What if reality is fundamentally blocky — like Minecraft, but smaller?
|
||||
|
||||
[SLIDE: title_card]
|
||||
|
||||
Everyone assumes space is smooth and continuous.
|
||||
|
||||
[SLIDE: smooth_space]
|
||||
|
||||
But what if it isn't?
|
||||
|
||||
[SLIDE: pixelated_space]
|
||||
|
||||
What if there's a *smallest* unit of space — and below that, nothing exists?
|
||||
|
||||
[SLIDE: planck_length]
|
||||
|
||||
This isn't new-age woo. The Planck length has been sitting in physics for a century.
|
||||
|
||||
[SLIDE: planck_formula]
|
||||
|
||||
The question is: is it a minimum, or just a measurement limit?
|
||||
|
||||
[SLIDE: question_mark]
|
||||
|
||||
That's what we're exploring at Glitch University.
|
||||
|
||||
[SLIDE: outro]
|
||||
|
||||
Link in description. The physics rabbit hole goes deep.
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
|
||||
API_URL="${GNOMMO_API_URL:-https://glitch.university}"
|
||||
CONTENT_API_KEY=782y497821y491y3981212
|
||||
@@ -0,0 +1,110 @@
|
||||
# Gnommo Content Skills
|
||||
|
||||
Skills for generating content for the Gnommo/Glitch.University learning platform.
|
||||
|
||||
## Available Skills
|
||||
|
||||
| Skill | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| DEGLITCH Gates | `deglitch-gate-generator.md` | Generate quiz questions from manuscripts |
|
||||
| Slide Content | `slide-content-generator.md` | Generate image prompts & text for slides |
|
||||
|
||||
---
|
||||
|
||||
# DEGLITCH Gate Generator
|
||||
|
||||
Generate quiz questions from manuscript content for the Gnommo learning platform.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Read `manuscript.txt` (or specified file)
|
||||
2. Identify 3-7 key concepts
|
||||
3. Create 1-2 questions per concept
|
||||
4. Output JSON or submit via API
|
||||
|
||||
## Project Structure
|
||||
|
||||
Each video project has:
|
||||
- `manuscript.txt` - The narration script with `[SX]` slide markers
|
||||
- `project.json` - Contains `coursecode` to identify the tech on the server
|
||||
|
||||
## API Configuration
|
||||
|
||||
```
|
||||
Base URL: ${GNOMMO_API_URL:-http://localhost:3001}
|
||||
Auth: Authorization: Bearer ${CONTENT_API_KEY}
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/content/techs/available` - Find tech_id to link
|
||||
- `POST /api/content/deglitch-gates` - Create gate
|
||||
- `GET /api/content/deglitch-gates` - List gates
|
||||
- `PUT /api/content/deglitch-gates/:id` - Update gate
|
||||
|
||||
## Question JSON Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"tech_id": null,
|
||||
"title": "Gate Title",
|
||||
"description": "What this tests",
|
||||
"passing_score": 0.8,
|
||||
"shuffle_questions": true,
|
||||
"shuffle_options": true,
|
||||
"questions": [
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "Question?",
|
||||
"sort_order": 0,
|
||||
"options": {
|
||||
"a": { "answer": "Wrong", "correct": false, "why": "Explanation" },
|
||||
"b": { "answer": "Right", "correct": true, "why": "Explanation" },
|
||||
"c": { "answer": "Wrong", "correct": false, "why": "Explanation" },
|
||||
"d": { "answer": "Wrong", "correct": false, "why": "Explanation" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Question Types
|
||||
- `radio` - Single answer (most common)
|
||||
- `checkbox` - Multiple answers
|
||||
- `llm` - Free text (AI evaluated)
|
||||
|
||||
## Quality Guidelines
|
||||
|
||||
- Test understanding, not memorization
|
||||
- One clear correct answer per radio question
|
||||
- Plausible wrong answers with educational "why"
|
||||
- Concise questions, avoid trick questions
|
||||
- Vary difficulty across questions
|
||||
|
||||
## Workflow with API Key
|
||||
|
||||
```bash
|
||||
# 1. Read project.json to get coursecode
|
||||
cat /path/to/video/project.json | jq '.coursecode'
|
||||
|
||||
# 2. Find tech_id by matching coursecode
|
||||
curl -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
$GNOMMO_API_URL/api/content/techs
|
||||
|
||||
# 3. Create gate with matched tech_id
|
||||
curl -X POST -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
$GNOMMO_API_URL/api/content/deglitch-gates \
|
||||
-d '{"tech_id": 1, "title":"...","questions":[...]}'
|
||||
```
|
||||
|
||||
## Matching Coursecode to Tech
|
||||
|
||||
The `coursecode` in `project.json` matches the `code` field in the server's tech list:
|
||||
- `♟️_#1.0` → Lightlane series, Video 1
|
||||
- `♟️_#2.0` → Lightlane series, Video 2
|
||||
- `WTF_#1` → What is Glitch University series, Video 1
|
||||
|
||||
## Workflow without API Key
|
||||
|
||||
Output the complete JSON for manual entry or later API submission.
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
# glitch gate API Helper Script
|
||||
# Usage: source this file, then use the functions
|
||||
|
||||
# Configuration - set these or export before sourcing
|
||||
GNOMMO_API_URL="${GNOMMO_API_URL:-http://localhost:3001}"
|
||||
# CONTENT_API_KEY should be set in environment
|
||||
|
||||
# Check if API key is set
|
||||
check_api_key() {
|
||||
if [ -z "$CONTENT_API_KEY" ]; then
|
||||
echo "Error: CONTENT_API_KEY not set"
|
||||
echo "Run: export CONTENT_API_KEY=your-key-here"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# List all techs
|
||||
list_techs() {
|
||||
check_api_key || return 1
|
||||
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/techs" | jq
|
||||
}
|
||||
|
||||
# List techs without gates (available for linking)
|
||||
list_available_techs() {
|
||||
check_api_key || return 1
|
||||
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/techs/available" | jq
|
||||
}
|
||||
|
||||
# List all glitch gates
|
||||
list_gates() {
|
||||
check_api_key || return 1
|
||||
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||
}
|
||||
|
||||
# Get a specific gate by ID
|
||||
get_gate() {
|
||||
check_api_key || return 1
|
||||
local gate_id=$1
|
||||
if [ -z "$gate_id" ]; then
|
||||
echo "Usage: get_gate <gate_id>"
|
||||
return 1
|
||||
fi
|
||||
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||
}
|
||||
|
||||
# Create a gate from JSON file
|
||||
create_gate() {
|
||||
check_api_key || return 1
|
||||
local json_file=$1
|
||||
if [ -z "$json_file" ]; then
|
||||
echo "Usage: create_gate <json_file>"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -f "$json_file" ]; then
|
||||
echo "Error: File not found: $json_file"
|
||||
return 1
|
||||
fi
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @"$json_file" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||
}
|
||||
|
||||
# Create a gate from JSON string
|
||||
create_gate_json() {
|
||||
check_api_key || return 1
|
||||
local json_data=$1
|
||||
if [ -z "$json_data" ]; then
|
||||
echo "Usage: create_gate_json '<json_string>'"
|
||||
return 1
|
||||
fi
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$json_data" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||
}
|
||||
|
||||
# Update a gate from JSON file
|
||||
update_gate() {
|
||||
check_api_key || return 1
|
||||
local gate_id=$1
|
||||
local json_file=$2
|
||||
if [ -z "$gate_id" ] || [ -z "$json_file" ]; then
|
||||
echo "Usage: update_gate <gate_id> <json_file>"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -f "$json_file" ]; then
|
||||
echo "Error: File not found: $json_file"
|
||||
return 1
|
||||
fi
|
||||
curl -s -X PUT \
|
||||
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @"$json_file" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||
}
|
||||
|
||||
# Delete a gate
|
||||
delete_gate() {
|
||||
check_api_key || return 1
|
||||
local gate_id=$1
|
||||
if [ -z "$gate_id" ]; then
|
||||
echo "Usage: delete_gate <gate_id>"
|
||||
return 1
|
||||
fi
|
||||
read -p "Delete gate $gate_id? (y/N) " confirm
|
||||
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||
else
|
||||
echo "Cancelled"
|
||||
fi
|
||||
}
|
||||
|
||||
# Print available commands
|
||||
deglitch_help() {
|
||||
echo "glitch gate API Commands:"
|
||||
echo ""
|
||||
echo " list_techs - List all techs"
|
||||
echo " list_available_techs - List techs without gates"
|
||||
echo " list_gates - List all glitch gates"
|
||||
echo " get_gate <id> - Get gate details"
|
||||
echo " create_gate <file> - Create gate from JSON file"
|
||||
echo " create_gate_json '<json>' - Create gate from JSON string"
|
||||
echo " update_gate <id> <file> - Update gate from JSON file"
|
||||
echo " delete_gate <id> - Delete a gate"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " GNOMMO_API_URL=$GNOMMO_API_URL"
|
||||
echo " CONTENT_API_KEY=$([ -n "$CONTENT_API_KEY" ] && echo "[set]" || echo "[not set]")"
|
||||
}
|
||||
|
||||
echo "DEGLITCH API helper loaded. Run 'deglitch_help' for commands."
|
||||
@@ -0,0 +1,251 @@
|
||||
# glitch gate Generator Skill
|
||||
|
||||
You are a quiz/assessment generator for the Gnommo learning platform. Your job is to read educational manuscript content and create glitch gate questions that test understanding.
|
||||
|
||||
## What is a glitch gate?
|
||||
|
||||
A glitch gate is a quiz that learners must pass to demonstrate mastery of a tech (lesson). Gates have:
|
||||
- A title and description
|
||||
- A passing score (default 80%)
|
||||
- Multiple questions with explanations for why each answer is correct/incorrect
|
||||
|
||||
## Question Philosophy
|
||||
|
||||
Create questions that test **understanding**, not memorization:
|
||||
|
||||
- **Intuition questions**: Test pattern recognition and conceptual understanding
|
||||
- **Grit questions**: Present tricky scenarios requiring careful thinking
|
||||
- **Craft questions**: Test precise technical knowledge and attention to detail
|
||||
|
||||
### Good Question Characteristics
|
||||
- Tests a single concept clearly
|
||||
- Has one unambiguously correct answer
|
||||
- Wrong answers are plausible (not obviously wrong)
|
||||
- Each answer has a "why" explanation
|
||||
- Avoids trick questions or gotchas
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Read the Manuscript
|
||||
|
||||
First, read the manuscript file to understand the content:
|
||||
|
||||
```
|
||||
Read the file: manuscript.txt
|
||||
```
|
||||
|
||||
Or if given a specific path:
|
||||
```
|
||||
Read the file: /path/to/manuscript.txt
|
||||
```
|
||||
|
||||
### Step 2: Identify Key Concepts
|
||||
|
||||
After reading, identify 3-7 key concepts that learners should understand. Consider:
|
||||
- Core principles explained in the text
|
||||
- Common misconceptions to address
|
||||
- Practical applications mentioned
|
||||
- Relationships between concepts
|
||||
|
||||
### Step 3: Generate Questions
|
||||
|
||||
For each key concept, create 1-2 questions. Use this JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"tech_id": null,
|
||||
"title": "Gate Title Based on Content",
|
||||
"description": "Brief description of what this gate tests",
|
||||
"passing_score": 0.8,
|
||||
"shuffle_questions": true,
|
||||
"shuffle_options": true,
|
||||
"is_active": true,
|
||||
"questions": [
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "Question text here?",
|
||||
"sort_order": 0,
|
||||
"options": {
|
||||
"a": {
|
||||
"answer": "First option",
|
||||
"correct": false,
|
||||
"why": "Explanation of why this is incorrect"
|
||||
},
|
||||
"b": {
|
||||
"answer": "Second option (correct)",
|
||||
"correct": true,
|
||||
"why": "Explanation of why this is correct"
|
||||
},
|
||||
"c": {
|
||||
"answer": "Third option",
|
||||
"correct": false,
|
||||
"why": "Explanation of why this is incorrect"
|
||||
},
|
||||
"d": {
|
||||
"answer": "Fourth option",
|
||||
"correct": false,
|
||||
"why": "Explanation of why this is incorrect"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Question Types
|
||||
|
||||
- `radio` - Single correct answer (most common)
|
||||
- `checkbox` - Multiple correct answers
|
||||
- `llm` - Free text evaluated by AI (use sparingly)
|
||||
|
||||
## Step 4: Submit to API (if API key available)
|
||||
|
||||
If you have the Content API key, you can directly create the gate:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/content/deglitch-gates \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d 'YOUR_JSON_HERE'
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/content/techs` | List all techs to find tech_id |
|
||||
| GET | `/api/content/techs/available` | Techs without gates |
|
||||
| GET | `/api/content/deglitch-gates` | List existing gates |
|
||||
| POST | `/api/content/deglitch-gates` | Create new gate |
|
||||
| PUT | `/api/content/deglitch-gates/:id` | Update gate |
|
||||
| DELETE | `/api/content/deglitch-gates/:id` | Delete gate |
|
||||
|
||||
### Finding the Right Tech ID
|
||||
|
||||
Before creating a gate, list available techs to find the correct `tech_id`:
|
||||
|
||||
```bash
|
||||
curl -X GET https://your-domain.com/api/content/techs/available \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
## Example: Complete Question Set
|
||||
|
||||
Here's an example of a well-structured gate for an "Atomic Structure" lesson:
|
||||
|
||||
```json
|
||||
{
|
||||
"tech_id": 1,
|
||||
"title": "Atomic Structure Fundamentals",
|
||||
"description": "Test your understanding of basic atomic structure and the components of atoms.",
|
||||
"passing_score": 0.8,
|
||||
"shuffle_questions": true,
|
||||
"shuffle_options": true,
|
||||
"is_active": true,
|
||||
"questions": [
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "What determines the chemical properties of an atom?",
|
||||
"sort_order": 0,
|
||||
"options": {
|
||||
"a": {
|
||||
"answer": "The number of neutrons",
|
||||
"correct": false,
|
||||
"why": "Neutrons affect atomic mass and stability, but not chemical properties directly."
|
||||
},
|
||||
"b": {
|
||||
"answer": "The number of protons",
|
||||
"correct": false,
|
||||
"why": "Protons determine the element, but electrons determine how it bonds."
|
||||
},
|
||||
"c": {
|
||||
"answer": "The number of electrons in the outer shell",
|
||||
"correct": true,
|
||||
"why": "Valence electrons determine how an atom bonds with others, defining its chemical behavior."
|
||||
},
|
||||
"d": {
|
||||
"answer": "The total atomic mass",
|
||||
"correct": false,
|
||||
"why": "Atomic mass affects physical properties like density, not chemical reactivity."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "An atom has 6 protons and 8 neutrons. What element is it?",
|
||||
"sort_order": 1,
|
||||
"options": {
|
||||
"a": {
|
||||
"answer": "Oxygen",
|
||||
"correct": false,
|
||||
"why": "Oxygen has 8 protons. The number of protons defines the element."
|
||||
},
|
||||
"b": {
|
||||
"answer": "Carbon",
|
||||
"correct": true,
|
||||
"why": "Carbon has 6 protons. This is carbon-14, an isotope with 8 neutrons."
|
||||
},
|
||||
"c": {
|
||||
"answer": "Nitrogen",
|
||||
"correct": false,
|
||||
"why": "Nitrogen has 7 protons."
|
||||
},
|
||||
"d": {
|
||||
"answer": "Carbon-14 is not carbon",
|
||||
"correct": false,
|
||||
"why": "Isotopes are variants of the same element. Carbon-14 is still carbon."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"question_type": "radio",
|
||||
"text": "Why are noble gases chemically inert?",
|
||||
"sort_order": 2,
|
||||
"options": {
|
||||
"a": {
|
||||
"answer": "They have no electrons",
|
||||
"correct": false,
|
||||
"why": "Noble gases have electrons; helium has 2, neon has 10, etc."
|
||||
},
|
||||
"b": {
|
||||
"answer": "Their outer electron shell is full",
|
||||
"correct": true,
|
||||
"why": "A full valence shell means no tendency to gain, lose, or share electrons."
|
||||
},
|
||||
"c": {
|
||||
"answer": "They are too heavy to react",
|
||||
"correct": false,
|
||||
"why": "Mass doesn't determine reactivity. Francium is heavy but highly reactive."
|
||||
},
|
||||
"d": {
|
||||
"answer": "They only exist at very low temperatures",
|
||||
"correct": false,
|
||||
"why": "Noble gases exist at all temperatures; they're gases at room temperature."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Tips for Quality Questions
|
||||
|
||||
1. **Start with the concept**, then craft the question around it
|
||||
2. **Make wrong answers educational** - the "why" should teach something
|
||||
3. **Vary difficulty** - include some easier and some harder questions
|
||||
4. **Avoid "all of the above"** or "none of the above" options
|
||||
5. **Keep questions concise** - if it needs a lot of context, split it
|
||||
6. **Test understanding, not recall** - ask "why" and "how", not just "what"
|
||||
|
||||
## Environment Variables
|
||||
|
||||
If using the API programmatically, you need:
|
||||
- `CONTENT_API_KEY` - Your API key for authentication
|
||||
- API base URL (e.g., `https://gnommo.com` or `http://localhost:3001`)
|
||||
|
||||
## Output Format
|
||||
|
||||
When generating questions without API access, output:
|
||||
1. A summary of key concepts identified
|
||||
2. The complete JSON structure ready to copy
|
||||
3. Any notes about the questions or suggestions for the tech linking
|
||||
@@ -0,0 +1,165 @@
|
||||
# Slide Content Generator Skill
|
||||
|
||||
Generate slide content (image prompts or text) from Gnommo manuscript files.
|
||||
|
||||
## Context
|
||||
|
||||
Gnommo presentations use a **square slide area next to a talking head**. Slides should be:
|
||||
- Visually impactful but not cluttered
|
||||
- Timed to appear with the first word after the `[SX]` marker
|
||||
- Either **image-based** (generated via AI) or **text-based** (minimal, punchy text)
|
||||
|
||||
## Manuscript Format
|
||||
|
||||
Manuscripts use slide markers like `[S1]`, `[S2]`, etc. The content following each marker is what the presenter says while that slide is displayed.
|
||||
|
||||
```
|
||||
[S1]
|
||||
Welcome to the course...
|
||||
|
||||
[S2]
|
||||
What if the universe is discrete?
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Read the Manuscript
|
||||
|
||||
```
|
||||
Read the file: /path/to/manuscript.txt
|
||||
```
|
||||
|
||||
### Step 2: Analyze Each Slide
|
||||
|
||||
For each `[SX]` marker, determine:
|
||||
1. **What is the core message?** - The key idea being communicated
|
||||
2. **Visual or text?** - Would an image or text better support the message?
|
||||
3. **Emotional tone?** - Dramatic, contemplative, humorous, technical?
|
||||
|
||||
### Step 3: Generate Content
|
||||
|
||||
For each slide, output one of:
|
||||
|
||||
#### IMAGE PROMPT
|
||||
For conceptual, emotional, or complex ideas that benefit from visualization.
|
||||
|
||||
```
|
||||
**[SX]** - "First few words..."
|
||||
**IMAGE PROMPT:**
|
||||
`Detailed description for AI image generation, style, mood, composition, lighting, specific elements to include`
|
||||
```
|
||||
|
||||
#### TEXT SLIDE
|
||||
For lists, key terms, definitions, or when words ARE the point.
|
||||
|
||||
```
|
||||
**[SX]** - "First few words..."
|
||||
**TEXT SLIDE:**
|
||||
```
|
||||
HEADLINE
|
||||
|
||||
• Bullet point
|
||||
• Another point
|
||||
```
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
### When to Use IMAGE PROMPTS
|
||||
|
||||
- Abstract concepts (e.g., "the fabric of spacetime")
|
||||
- Metaphors and analogies (e.g., "like changing engines while driving")
|
||||
- Emotional moments (e.g., "this sounds insane")
|
||||
- Scene-setting (e.g., "imagine a Minecraft universe")
|
||||
|
||||
### When to Use TEXT SLIDES
|
||||
|
||||
- Lists of items being enumerated
|
||||
- Technical terms being defined
|
||||
- Key questions or frameworks
|
||||
- Course titles, section headers
|
||||
- Quotes or key phrases
|
||||
|
||||
### Image Prompt Best Practices
|
||||
|
||||
1. **Be specific about style**: "isometric illustration", "cinematic lighting", "minimal vector style"
|
||||
2. **Include mood/tone**: "mysterious", "hopeful", "dramatic contrast"
|
||||
3. **Describe composition**: "split image", "centered subject", "deep space background"
|
||||
4. **Avoid text in images**: AI image generators struggle with text - use text slides instead
|
||||
5. **Keep it achievable**: Don't describe impossibly complex scenes
|
||||
|
||||
### Text Slide Best Practices
|
||||
|
||||
1. **Minimal words**: 3-7 words per line, 1-5 lines max
|
||||
2. **Use hierarchy**: HEADLINES in caps, details below
|
||||
3. **Bullets for lists**: Keep them short and scannable
|
||||
4. **Leave breathing room**: Don't fill the entire square
|
||||
|
||||
## Output Format
|
||||
|
||||
Output slides in order, with clear separation:
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
**[S1]** - "First words of narration..."
|
||||
**TYPE:** (IMAGE PROMPT or TEXT SLIDE)
|
||||
Content here
|
||||
|
||||
---
|
||||
|
||||
**[S2]** - "First words of narration..."
|
||||
...
|
||||
```
|
||||
|
||||
## Example Output
|
||||
|
||||
---
|
||||
|
||||
**[S1]** - "Welcome to Glitch.University..."
|
||||
**TEXT SLIDE:**
|
||||
```
|
||||
GLITCH.UNIVERSITY
|
||||
WTF_#1
|
||||
|
||||
What is Glitch University?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**[S2]** - "What if the universe is fundamentally discrete..."
|
||||
**IMAGE PROMPT:**
|
||||
`A hyper-detailed Minecraft-style voxel universe, showing galaxies and stars rendered as tiny glowing cubes, deep space background with blocky nebulae, cosmic scale but pixelated, dark background with vibrant cube-shaped stars, cinematic lighting`
|
||||
|
||||
---
|
||||
|
||||
## Customization Options
|
||||
|
||||
### Style Presets
|
||||
|
||||
You can request specific visual styles:
|
||||
- **Tech/Corporate**: Clean vectors, isometric, blues and whites
|
||||
- **Cosmic/Physics**: Deep space, nebulae, particle effects
|
||||
- **Playful/Minecraft**: Voxels, bright colors, blocky
|
||||
- **Philosophical**: Abstract, minimal, contemplative
|
||||
- **Dramatic**: High contrast, cinematic, intense lighting
|
||||
|
||||
### Text Tone
|
||||
|
||||
- **Academic**: Formal terminology, structured
|
||||
- **Casual**: Conversational, approachable
|
||||
- **Punchy**: Short, impactful, memorable
|
||||
|
||||
## Integration with Gnommo
|
||||
|
||||
The generated content can be used to:
|
||||
1. Create slides in Keynote/PowerPoint
|
||||
2. Generate images via Midjourney/DALL-E/Stable Diffusion
|
||||
3. Populate the `slides.json` file in the project's media folder
|
||||
|
||||
## Tips
|
||||
|
||||
- Read the ENTIRE manuscript first to understand the arc
|
||||
- Match slide density to pacing - fast sections need simpler slides
|
||||
- Create visual continuity - recurring metaphors should have consistent imagery
|
||||
- Consider what the talking head is doing - slides complement, not compete
|
||||
Executable
+300
@@ -0,0 +1,300 @@
|
||||
#!/bin/zsh
|
||||
#
|
||||
# Video Transcoding Script
|
||||
# Converts video files to H.265/HEVC at 1080p for significant size reduction
|
||||
#
|
||||
# Usage: ./transcode.sh <folder> [options]
|
||||
#
|
||||
# Options:
|
||||
# --replace Delete original files after successful transcoding
|
||||
# --dry-run Show what would be transcoded without doing it
|
||||
# --crf <N> Quality level (default: 23, lower=better quality, 18-28 typical)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
DEFAULT_CRF=23
|
||||
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 20 # Higher quality
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
FOLDER=""
|
||||
REPLACE=false
|
||||
DRY_RUN=false
|
||||
CRF=$DEFAULT_CRF
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--replace)
|
||||
REPLACE=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--crf)
|
||||
CRF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$FOLDER" ]]; then
|
||||
FOLDER="$1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate arguments
|
||||
if [[ -z "$FOLDER" ]]; then
|
||||
echo "Error: Folder path is required"
|
||||
echo ""
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ ! -d "$FOLDER" && ! -f "$FOLDER" ]]; then
|
||||
echo "Error: Path not found: $FOLDER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for ffmpeg
|
||||
if ! command -v ffmpeg &> /dev/null; then
|
||||
echo "Error: ffmpeg is not installed"
|
||||
echo "Install with: brew install ffmpeg"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build find pattern for video files
|
||||
build_find_pattern() {
|
||||
local pattern=""
|
||||
for ext in "${EXTENSIONS[@]}"; do
|
||||
if [[ -n "$pattern" ]]; then
|
||||
pattern="$pattern -o"
|
||||
fi
|
||||
pattern="$pattern -iname '*.$ext'"
|
||||
done
|
||||
echo "$pattern"
|
||||
}
|
||||
|
||||
# Format file size for display
|
||||
format_size() {
|
||||
local bytes=$1
|
||||
if (( bytes >= 1073741824 )); then
|
||||
printf "%.1fG" $(echo "scale=1; $bytes / 1073741824" | bc)
|
||||
elif (( bytes >= 1048576 )); then
|
||||
printf "%.1fM" $(echo "scale=1; $bytes / 1048576" | bc)
|
||||
else
|
||||
printf "%.1fK" $(echo "scale=1; $bytes / 1024" | bc)
|
||||
fi
|
||||
}
|
||||
|
||||
# Get file size in bytes
|
||||
get_size() {
|
||||
stat -f%z "$1" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
echo "========================================"
|
||||
echo "Video Transcoder"
|
||||
echo "========================================"
|
||||
echo "Folder: $FOLDER"
|
||||
echo "Codec: H.265/HEVC"
|
||||
echo "Resolution: 1080p (scaled down)"
|
||||
echo "Quality: CRF $CRF"
|
||||
echo "Replace: $REPLACE"
|
||||
[[ "$DRY_RUN" == true ]] && echo "DRY RUN: Yes"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Check if input is a file or folder
|
||||
IS_SINGLE_FILE=false
|
||||
if [[ -f "$FOLDER" ]]; then
|
||||
IS_SINGLE_FILE=true
|
||||
VIDEO_FILES=("$FOLDER")
|
||||
echo "Processing single file"
|
||||
echo ""
|
||||
else
|
||||
# Find all video files (excluding already compressed ones), sorted by size (smallest first)
|
||||
FIND_PATTERN=$(build_find_pattern)
|
||||
|
||||
# Use Python for robust sorting by size (handles spaces in paths correctly)
|
||||
VIDEO_FILES=()
|
||||
while IFS= read -r file; do
|
||||
VIDEO_FILES+=("$file")
|
||||
done < <(eval "find \"$FOLDER\" -type f \( $FIND_PATTERN \)" 2>/dev/null | python3 -c "
|
||||
import sys
|
||||
import os
|
||||
|
||||
files = []
|
||||
for line in sys.stdin:
|
||||
path = line.rstrip('\n')
|
||||
if '_compressed.' in path:
|
||||
continue
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
files.append((size, path))
|
||||
except:
|
||||
pass
|
||||
|
||||
files.sort(key=lambda x: x[0])
|
||||
for size, path in files:
|
||||
print(path)
|
||||
")
|
||||
|
||||
if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
|
||||
echo "No video files found in $FOLDER"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found ${#VIDEO_FILES[@]} video file(s) to process (smallest first)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Track totals
|
||||
TOTAL_ORIGINAL=0
|
||||
TOTAL_COMPRESSED=0
|
||||
SUCCESS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
# Process each file
|
||||
for input_file in "${VIDEO_FILES[@]}"; do
|
||||
# Generate output filename
|
||||
dir=$(dirname "$input_file")
|
||||
basename=$(basename "$input_file")
|
||||
name="${basename%.*}"
|
||||
output_file="$dir/${name}_compressed.mp4"
|
||||
|
||||
# Get original size
|
||||
original_size=$(get_size "$input_file")
|
||||
original_size_fmt=$(format_size $original_size)
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo "Input: $input_file ($original_size_fmt)"
|
||||
echo "Output: $output_file"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Action: [DRY RUN] Would transcode"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if output already exists
|
||||
if [[ -f "$output_file" ]]; then
|
||||
echo "Action: Skipped (output already exists)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Transcode with ffmpeg
|
||||
# -vf scale=-2:1080 = scale to 1080p height, auto width (divisible by 2)
|
||||
# -c:v libx265 = H.265/HEVC codec
|
||||
# -crf = quality (lower = better)
|
||||
# -preset medium = encoding speed/compression tradeoff
|
||||
# -c:a aac -b:a 128k = audio to AAC at 128kbps
|
||||
# -tag:v hvc1 = compatibility tag for Apple devices
|
||||
echo "Action: Transcoding..."
|
||||
|
||||
if ffmpeg -i "$input_file" \
|
||||
-vf "scale=-2:1080" \
|
||||
-c:v libx265 \
|
||||
-crf "$CRF" \
|
||||
-preset medium \
|
||||
-c:a aac -b:a 128k \
|
||||
-tag:v hvc1 \
|
||||
-y \
|
||||
"$output_file" \
|
||||
-loglevel warning -stats 2>&1; then
|
||||
|
||||
# Get compressed size
|
||||
compressed_size=$(get_size "$output_file")
|
||||
compressed_size_fmt=$(format_size $compressed_size)
|
||||
|
||||
# Calculate reduction
|
||||
if (( original_size > 0 )); then
|
||||
reduction=$(echo "scale=1; 100 - ($compressed_size * 100 / $original_size)" | bc)
|
||||
else
|
||||
reduction=0
|
||||
fi
|
||||
|
||||
echo "Result: $original_size_fmt → $compressed_size_fmt (${reduction}% reduction)"
|
||||
|
||||
TOTAL_ORIGINAL=$((TOTAL_ORIGINAL + original_size))
|
||||
TOTAL_COMPRESSED=$((TOTAL_COMPRESSED + compressed_size))
|
||||
((SUCCESS_COUNT++))
|
||||
|
||||
# Delete original if --replace is set
|
||||
if [[ "$REPLACE" == true ]]; then
|
||||
rm "$input_file"
|
||||
echo "Deleted: $input_file"
|
||||
fi
|
||||
else
|
||||
echo "Result: FAILED"
|
||||
((FAIL_COUNT++))
|
||||
# Remove partial output file if it exists
|
||||
[[ -f "$output_file" ]] && rm "$output_file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Summary"
|
||||
echo "========================================"
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "DRY RUN - no files were transcoded"
|
||||
else
|
||||
echo "Processed: $SUCCESS_COUNT succeeded, $FAIL_COUNT failed"
|
||||
if (( SUCCESS_COUNT > 0 )); then
|
||||
total_orig_fmt=$(format_size $TOTAL_ORIGINAL)
|
||||
total_comp_fmt=$(format_size $TOTAL_COMPRESSED)
|
||||
if (( TOTAL_ORIGINAL > 0 )); then
|
||||
total_reduction=$(echo "scale=1; 100 - ($TOTAL_COMPRESSED * 100 / $TOTAL_ORIGINAL)" | bc)
|
||||
else
|
||||
total_reduction=0
|
||||
fi
|
||||
echo "Total: $total_orig_fmt → $total_comp_fmt (${total_reduction}% reduction)"
|
||||
|
||||
if [[ "$REPLACE" == true ]]; then
|
||||
saved=$(format_size $((TOTAL_ORIGINAL - TOTAL_COMPRESSED)))
|
||||
echo "Freed: $saved"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "========================================"
|
||||
Reference in New Issue
Block a user