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
|
### Parallel Rendering Pipeline
|
||||||
```bash
|
```bash
|
||||||
# Render in parallel, then concatenate
|
# Render in parallel, then stitch
|
||||||
gnommo render proj.json seg1.mp4 --slides S1:S10 &
|
gnommo render proj.json seg1.mp4 --slides S1:S10 &
|
||||||
gnommo render proj.json seg2.mp4 --slides S10:S20 &
|
gnommo render proj.json seg2.mp4 --slides S10:S20 &
|
||||||
gnommo render proj.json seg3.mp4 --slides S20: &
|
gnommo render proj.json seg3.mp4 --slides S20: &
|
||||||
|
|||||||
+25
-5
@@ -1,12 +1,12 @@
|
|||||||
[S1]
|
[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]
|
[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]
|
[S3]
|
||||||
[video:Zoomin_MontageZoom]
|
[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. [cite:FFmpeg Documentation - https://ffmpeg.org/documentation.html]
|
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]
|
[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
|
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]
|
[S5]
|
||||||
[video:gnommologo]
|
[video:gnommologo]
|
||||||
|
|
||||||
Notice how my voice continues after the video finished.
|
Notice how my voice continues after the video finished
|
||||||
|
|
||||||
[S6]
|
[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": {
|
"S6": {
|
||||||
"image": "example.006.png",
|
"image": "example.006.png",
|
||||||
"type": "fullscreen"
|
"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": {
|
"talking_head_S1": {
|
||||||
"source_file": "talking_head.mov",
|
"source_file": "talking_head_S1.mov",
|
||||||
"output_file": "talking_head_processed.mov",
|
"output_file": "talking_head_S1_processed.mov",
|
||||||
"cutout": "talkinghead",
|
"cutout": "talkinghead",
|
||||||
"always_visible": true,
|
"always_visible": true,
|
||||||
"filter": [
|
"filter": "talkinghead"
|
||||||
{
|
|
||||||
"type": "chroma_key",
|
|
||||||
"color": [131, 177, 83],
|
|
||||||
"similarity": 0.04,
|
|
||||||
"blend": 0.025,
|
|
||||||
"spill": 0.05
|
|
||||||
},
|
},
|
||||||
{
|
"talking_head_S3": {
|
||||||
"type": "mask",
|
"source_file": "talking_head_S3.mov",
|
||||||
"left": 0.05,
|
"output_file": "talking_head_S3_processed.mov",
|
||||||
"right": 0.10
|
"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": {
|
"gnommologo": {
|
||||||
"source_file": "Logo.mov",
|
"source_file": "Logo.mov",
|
||||||
"is_shared": true,
|
"is_shared": true,
|
||||||
"cutout": "fullscreen",
|
"cutout": "fullscreen",
|
||||||
"pause_narration": 0 ,
|
"pause_narration": 17,
|
||||||
"take": 10,
|
"take": 25,
|
||||||
"skip": 0
|
"skip": 0
|
||||||
},
|
},
|
||||||
"Zoomin_MontageZoom": {
|
"Zoomin_MontageZoom": {
|
||||||
"description": "Montage zoom",
|
"description": "Montage zoom",
|
||||||
"source_file": "MontageZoom.mp4",
|
"source_file": "MontageZoom.mp4",
|
||||||
"output_file": "MontageZoom.mp4",
|
"output_file": "MontageZoom.mp4",
|
||||||
"pause_narration":3,
|
"pause_narration": 5,
|
||||||
"cutout": "square",
|
"cutout": "square",
|
||||||
"is_shared": true,
|
"is_shared": true,
|
||||||
"filter": []
|
"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",
|
"videos": "media/videos/videos.json",
|
||||||
"slides": "media/slides/Example/slides.json",
|
"slides": "media/slides/Example/slides.json",
|
||||||
"audio": "media/audio/audio.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": {
|
"cutouts": {
|
||||||
"talkinghead": {
|
"talkinghead": {
|
||||||
"x": "-10%",
|
"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 = []
|
chapters = []
|
||||||
|
|
||||||
# Build timing lookup
|
# 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
|
# Process slides in order
|
||||||
slide_ids = sorted(
|
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 dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -41,13 +41,18 @@ class ProjectConfig:
|
|||||||
cutouts: dict[str, CutoutDefinition] = field(
|
cutouts: dict[str, CutoutDefinition] = field(
|
||||||
default_factory=dict
|
default_factory=dict
|
||||||
) # Named zones for video placement
|
) # 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: str = "" # Background image or video path (in shared_assets/)
|
||||||
background_video: str = "" # Deprecated: use background instead
|
background_video: str = "" # Deprecated: use background instead
|
||||||
slides_path: str = "slides.json" # path to slides.json relative to project
|
slides_path: str = "slides.json" # path to slides.json relative to project
|
||||||
videos_path: str = "videos.json" # path to videos.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_path: str = "audio.json" # path to audio.json relative to project
|
||||||
audio_source: Optional[str] = None # defaults to talking head
|
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[
|
gnommo_scratch: Optional[
|
||||||
str
|
str
|
||||||
] = None # directory for intermediate files (e.g., external SSD)
|
] = None # directory for intermediate files (e.g., external SSD)
|
||||||
@@ -165,6 +170,16 @@ class ColorGradeConfig:
|
|||||||
curves_master: str = "" # Master (luminance) curve
|
curves_master: str = "" # Master (luminance) curve
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EQBand:
|
||||||
|
"""A single parametric EQ band."""
|
||||||
|
|
||||||
|
freq: float # Center frequency in Hz
|
||||||
|
gain: float # Gain in dB (negative = cut, positive = boost)
|
||||||
|
q: float = 1.0 # Q factor (bandwidth), higher = narrower
|
||||||
|
type: str = "peak" # "peak", "lowshelf", or "highshelf"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioNormalizeConfig:
|
class AudioNormalizeConfig:
|
||||||
"""Configuration for audio normalization filter.
|
"""Configuration for audio normalization filter.
|
||||||
@@ -173,9 +188,43 @@ class AudioNormalizeConfig:
|
|||||||
to improve audio quality and consistency.
|
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)
|
# Noise reduction (afftdn filter)
|
||||||
denoise: bool = True # Enable noise reduction
|
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)
|
# Compression (acompressor filter)
|
||||||
compress: bool = True # Enable dynamic range compression
|
compress: bool = True # Enable dynamic range compression
|
||||||
@@ -187,7 +236,9 @@ class AudioNormalizeConfig:
|
|||||||
|
|
||||||
# Loudness normalization (loudnorm filter - EBU R128)
|
# Loudness normalization (loudnorm filter - EBU R128)
|
||||||
normalize: bool = True # Enable loudness normalization
|
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_lra: float = 11.0 # Target loudness range
|
||||||
target_tp: float = -1.5 # Target true peak in dB
|
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)
|
0.0 # Seconds to pause narration during this video (0 = no pause)
|
||||||
)
|
)
|
||||||
attribution: Optional[Attribution] = None # Attribution for stock footage
|
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
|
@dataclass
|
||||||
@@ -270,7 +327,10 @@ class AudioDefinition:
|
|||||||
file: str # Audio filename (relative to audio.json location)
|
file: str # Audio filename (relative to audio.json location)
|
||||||
volume: float = 1.0 # Volume multiplier (0.0-1.0)
|
volume: float = 1.0 # Volume multiplier (0.0-1.0)
|
||||||
loop: bool = False # If True, loop for entire duration from trigger point
|
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
|
@dataclass
|
||||||
@@ -441,6 +501,10 @@ class RenderPlan:
|
|||||||
default_factory=list
|
default_factory=list
|
||||||
) # Videos that play after narration ends
|
) # Videos that play after narration ends
|
||||||
narration_end_time: float = 0.0 # When narration ends (before outro starts)
|
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)
|
# Slide layout configurations (hardcoded for POC)
|
||||||
|
|||||||
+220
-22
@@ -5,6 +5,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .cache import resolve_with_cache
|
||||||
from .errors import ParseError
|
from .errors import ParseError
|
||||||
from .models import (
|
from .models import (
|
||||||
Attribution,
|
Attribution,
|
||||||
@@ -24,8 +25,9 @@ def parse_manuscript(
|
|||||||
"""
|
"""
|
||||||
Parse manuscript.txt and extract text content and slide markers.
|
Parse manuscript.txt and extract text content and slide markers.
|
||||||
|
|
||||||
Strips [cite:...] markers from the returned text so they never pollute
|
Strips [cite:...] and [marker:...] markers from the returned text so they
|
||||||
alignment contexts. Citations are extracted and returned separately.
|
never pollute alignment contexts. Citations are extracted and returned
|
||||||
|
separately. Marker cues are personal recording notes and are simply discarded.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (full text, list of marker IDs found, list of malformed markers, list of citations)
|
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
|
# Strip [cite:...] markers from text so they don't pollute alignment
|
||||||
text = re.sub(r"\[cite:[^\]]+\]", "", text)
|
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.
|
# 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)
|
# Include . in pattern to catch markers with file extensions (so validator can warn about them)
|
||||||
markers = re.findall(r"\[([A-Za-z0-9_:.]+)\]", text)
|
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:
|
def save_citations(citations: list[Citation], path: Path) -> None:
|
||||||
"""Save citations to a JSON file."""
|
"""Save citations to a JSON file."""
|
||||||
data = [
|
data = [{"reference": c.reference, "context": c.context} for c in citations]
|
||||||
{"reference": c.reference, "context": c.context}
|
|
||||||
for c in citations
|
|
||||||
]
|
|
||||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
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:
|
if not isinstance(resolution, list) or len(resolution) != 2:
|
||||||
raise ParseError("resolution must be [width, height]", config_path)
|
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(
|
return ProjectConfig(
|
||||||
resolution=tuple(resolution),
|
resolution=tuple(resolution),
|
||||||
fps=data.get("fps", 30),
|
fps=data.get("fps", 30),
|
||||||
default_slide_type=data.get("defaultSlideType", "square"),
|
default_slide_type=data.get("defaultSlideType", "square"),
|
||||||
cutouts=cutouts,
|
cutouts=cutouts,
|
||||||
|
default_filters=default_filters,
|
||||||
background=data.get("background", ""),
|
background=data.get("background", ""),
|
||||||
background_video=data.get("background_video", ""), # Deprecated
|
background_video=data.get("background_video", ""), # Deprecated
|
||||||
slides_path=data.get("slides", "slides.json"),
|
slides_path=data.get("slides", "slides.json"),
|
||||||
@@ -220,12 +227,14 @@ def parse_slides(
|
|||||||
) -> dict[str, SlideDefinition]:
|
) -> dict[str, SlideDefinition]:
|
||||||
"""Parse slides.json into slide definitions."""
|
"""Parse slides.json into slide definitions."""
|
||||||
if config and config.slides_path:
|
if config and config.slides_path:
|
||||||
slides_path = project_path / config.slides_path
|
local_slides_path = project_path / config.slides_path
|
||||||
else:
|
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():
|
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:
|
try:
|
||||||
data = json.loads(slides_path.read_text(encoding="utf-8"))
|
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).
|
containing audio.json (for resolving relative file paths).
|
||||||
"""
|
"""
|
||||||
if config and config.audio_path:
|
if config and config.audio_path:
|
||||||
audio_path = project_path / config.audio_path
|
local_audio_path = project_path / config.audio_path
|
||||||
else:
|
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
|
# Audio is optional - return empty dict if not found
|
||||||
if not audio_path.exists():
|
if not audio_path.exists():
|
||||||
return {}, project_path
|
return {}, audio_dir
|
||||||
|
|
||||||
audio_dir = audio_path.parent
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(audio_path.read_text(encoding="utf-8"))
|
data = json.loads(audio_path.read_text(encoding="utf-8"))
|
||||||
@@ -278,41 +291,102 @@ def parse_audio(
|
|||||||
raise ParseError(
|
raise ParseError(
|
||||||
f"Audio '{audio_id}' missing required field 'file'", audio_path
|
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(
|
audio[audio_id] = AudioDefinition(
|
||||||
file=audio_data["file"],
|
file=audio_data["file"],
|
||||||
volume=float(audio_data.get("volume", 1.0)),
|
volume=float(audio_data.get("volume", 1.0)),
|
||||||
loop=bool(audio_data.get("loop", False)),
|
loop=bool(audio_data.get("loop", False)),
|
||||||
|
overlap=overlap,
|
||||||
ignore_pauses=bool(audio_data.get("ignore_pauses", False)),
|
ignore_pauses=bool(audio_data.get("ignore_pauses", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return audio, audio_dir
|
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(
|
def parse_videos(
|
||||||
project_path: Path, config: Optional[ProjectConfig] = None
|
project_path: Path, config: Optional[ProjectConfig] = None
|
||||||
) -> tuple[dict[str, VideoSource], Path]:
|
) -> tuple[dict[str, VideoSource], Path]:
|
||||||
"""
|
"""
|
||||||
Parse videos.json into video source definitions.
|
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:
|
Returns:
|
||||||
Tuple of (videos dict, videos_dir) where videos_dir is the directory
|
Tuple of (videos dict, videos_dir) where videos_dir is the directory
|
||||||
containing videos.json (for resolving relative file paths).
|
containing videos.json (for resolving relative file paths).
|
||||||
"""
|
"""
|
||||||
if config and config.videos_path:
|
if config and config.videos_path:
|
||||||
videos_path = project_path / config.videos_path
|
local_videos_path = project_path / config.videos_path
|
||||||
else:
|
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():
|
if not videos_path.exists():
|
||||||
raise ParseError(f"videos.json not found: {videos_path}", videos_path)
|
raise ParseError(f"videos.json not found: {local_videos_path}", local_videos_path)
|
||||||
|
|
||||||
videos_dir = videos_path.parent
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(videos_path.read_text(encoding="utf-8"))
|
data = json.loads(videos_path.read_text(encoding="utf-8"))
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise ParseError(f"Invalid JSON: {e}", videos_path)
|
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 = {}
|
videos = {}
|
||||||
for video_id, video_data in data.items():
|
for video_id, video_data in data.items():
|
||||||
if "source_file" not in video_data:
|
if "source_file" not in video_data:
|
||||||
@@ -330,12 +404,39 @@ def parse_videos(
|
|||||||
url=attr_data.get("url"),
|
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(
|
videos[video_id] = VideoSource(
|
||||||
source_file=video_data["source_file"],
|
source_file=video_data["source_file"],
|
||||||
filter=video_data.get("filter", []),
|
filter=filter_list,
|
||||||
output_file=video_data.get("output_file"),
|
output_file=video_data.get("output_file"),
|
||||||
take=video_data.get("take"),
|
take=take,
|
||||||
skip=video_data.get("skip", 0.0),
|
skip=skip,
|
||||||
zoom=video_data.get("zoom", 1.0),
|
zoom=video_data.get("zoom", 1.0),
|
||||||
cutout=video_data.get("cutout"),
|
cutout=video_data.get("cutout"),
|
||||||
always_visible=video_data.get("always_visible", False),
|
always_visible=video_data.get("always_visible", False),
|
||||||
@@ -343,11 +444,108 @@ def parse_videos(
|
|||||||
pause_narration=float(video_data.get("pause_narration", 0)),
|
pause_narration=float(video_data.get("pause_narration", 0)),
|
||||||
attribution=attribution,
|
attribution=attribution,
|
||||||
use_audio_channels=video_data.get("use_audio_channels", "both"),
|
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
|
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:
|
def get_video_duration(video_path: Path) -> float:
|
||||||
"""Get duration of a video file using ffprobe."""
|
"""Get duration of a video file using ffprobe."""
|
||||||
import subprocess
|
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
|
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:
|
def render(plan: RenderPlan, output_path: Path, verbose: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Render the final video using FFmpeg.
|
Render the final video using FFmpeg.
|
||||||
@@ -56,6 +160,7 @@ def _resolve_video_path(
|
|||||||
videos_dir: Path,
|
videos_dir: Path,
|
||||||
video_source: VideoSource,
|
video_source: VideoSource,
|
||||||
shared_assets_dir: Path = None,
|
shared_assets_dir: Path = None,
|
||||||
|
project_path: Path = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Resolve the actual video file path (output_file if exists, else source_file).
|
"""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.
|
compressed alpha channel support.
|
||||||
|
|
||||||
If video_source.is_shared is True, looks in shared_assets_dir instead of videos_dir.
|
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
|
# Determine base directory based on is_shared flag
|
||||||
if video_source.is_shared and shared_assets_dir:
|
if video_source.is_shared and shared_assets_dir:
|
||||||
base_dir = shared_assets_dir
|
base_dir = shared_assets_dir
|
||||||
@@ -72,26 +180,47 @@ def _resolve_video_path(
|
|||||||
|
|
||||||
if video_source.output_file:
|
if video_source.output_file:
|
||||||
video_path = base_dir / 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
|
return video_path
|
||||||
# Check for WebM variant (preprocessing outputs compressed WebM instead of ProRes)
|
# Check for WebM variant (preprocessing outputs compressed WebM instead of ProRes)
|
||||||
webm_path = video_path.with_suffix(".mov")
|
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 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:
|
def _has_audio_stream(video_path: Path) -> bool:
|
||||||
"""Check if a video file contains an audio stream using ffprobe."""
|
"""Check if a video file contains an audio stream using ffprobe."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"ffprobe", "-v", "error",
|
"ffprobe",
|
||||||
"-select_streams", "a",
|
"-v",
|
||||||
"-show_entries", "stream=index",
|
"error",
|
||||||
"-of", "csv=p=0",
|
"-select_streams",
|
||||||
|
"a",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=index",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0",
|
||||||
str(video_path),
|
str(video_path),
|
||||||
],
|
],
|
||||||
capture_output=True, text=True,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
return bool(result.stdout.strip())
|
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
|
# Add -ss seek BEFORE -i for skip parameter and/or partial rendering
|
||||||
always_visible_inputs: list[int] = []
|
always_visible_inputs: list[int] = []
|
||||||
for video_id, video_source, cutout in plan.narration_videos:
|
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
|
# Combine video skip setting with partial render offset
|
||||||
total_seek = video_source.skip + plan.input_seek_time
|
total_seek = video_source.skip + plan.input_seek_time
|
||||||
if total_seek > 0:
|
if total_seek > 0:
|
||||||
@@ -141,12 +270,14 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
input_idx += 1
|
input_idx += 1
|
||||||
|
|
||||||
# Input: background image/video (if specified)
|
# Input: background image/video (if specified)
|
||||||
|
from .cache import resolve_with_cache
|
||||||
bg_file = plan.config.background or plan.config.background_video
|
bg_file = plan.config.background or plan.config.background_video
|
||||||
has_background = bool(bg_file)
|
has_background = bool(bg_file)
|
||||||
bg_idx = None
|
bg_idx = None
|
||||||
bg_is_image = False
|
bg_is_image = False
|
||||||
if has_background:
|
if has_background:
|
||||||
bg_path = project_path / bg_file
|
bg_path = project_path / bg_file
|
||||||
|
bg_path, _ = resolve_with_cache(bg_path, project_path)
|
||||||
if not bg_path.exists():
|
if not bg_path.exists():
|
||||||
bg_path = project_path.parent / bg_file
|
bg_path = project_path.parent / bg_file
|
||||||
image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"}
|
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:
|
for event in plan.slide_events:
|
||||||
if event.slide_id not in slide_inputs:
|
if event.slide_id not in slide_inputs:
|
||||||
image_path = slides_dir / event.slide_def.image
|
image_path = slides_dir / event.slide_def.image
|
||||||
|
image_path, _ = resolve_with_cache(image_path, project_path)
|
||||||
cmd.extend(["-i", str(image_path)])
|
cmd.extend(["-i", str(image_path)])
|
||||||
slide_inputs[event.slide_id] = input_idx
|
slide_inputs[event.slide_id] = input_idx
|
||||||
input_idx += 1
|
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):
|
for i, event in enumerate(plan.video_events):
|
||||||
video_path = _resolve_video_path(
|
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
|
# Seek to skip point before loading input
|
||||||
skip = event.video_source.skip
|
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):
|
for i, event in enumerate(plan.outro_events):
|
||||||
video_path = _resolve_video_path(
|
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
|
# Seek to skip point before loading input
|
||||||
skip = event.video_source.skip
|
skip = event.video_source.skip
|
||||||
@@ -217,13 +349,18 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
# Input: audio files
|
# Input: audio files
|
||||||
audio_dir = plan.audio_dir.resolve() if plan.audio_dir else project_path
|
audio_dir = plan.audio_dir.resolve() if plan.audio_dir else project_path
|
||||||
audio_inputs: dict[str, int] = {} # audio_id -> input_idx
|
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:
|
for event in plan.audio_events:
|
||||||
if event.audio_id not in audio_inputs:
|
if event.audio_id not in audio_inputs:
|
||||||
audio_path = audio_dir / event.audio_def.file
|
audio_path = audio_dir / event.audio_def.file
|
||||||
|
audio_path, _ = resolve_with_cache(audio_path, project_path)
|
||||||
cmd.extend(["-i", str(audio_path)])
|
cmd.extend(["-i", str(audio_path)])
|
||||||
audio_inputs[event.audio_id] = input_idx
|
audio_inputs[event.audio_id] = input_idx
|
||||||
input_idx += 1
|
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
|
# Build filter_complex
|
||||||
filter_complex = 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,
|
video_inputs,
|
||||||
num_inputs_before_audio,
|
num_inputs_before_audio,
|
||||||
audio_inputs,
|
audio_inputs,
|
||||||
|
audio_durations,
|
||||||
video_events_with_audio,
|
video_events_with_audio,
|
||||||
outro_inputs,
|
outro_inputs,
|
||||||
outro_events_with_audio,
|
outro_events_with_audio,
|
||||||
@@ -541,6 +679,7 @@ def build_filter_complex(
|
|||||||
video_inputs: dict[int, int], # event_index -> input_idx
|
video_inputs: dict[int, int], # event_index -> input_idx
|
||||||
num_inputs_before_audio: int,
|
num_inputs_before_audio: int,
|
||||||
audio_inputs: dict[str, int],
|
audio_inputs: dict[str, int],
|
||||||
|
audio_durations: dict[str, float], # audio_id -> duration (for crossfade loops)
|
||||||
video_events_with_audio: set[int] = None,
|
video_events_with_audio: set[int] = None,
|
||||||
outro_inputs: dict[int, int] = None, # outro event_index -> input_idx
|
outro_inputs: dict[int, int] = None, # outro event_index -> input_idx
|
||||||
outro_events_with_audio: set[int] = None,
|
outro_events_with_audio: set[int] = None,
|
||||||
@@ -790,48 +929,65 @@ def build_filter_complex(
|
|||||||
main_audio_idx = always_visible_inputs[0]
|
main_audio_idx = always_visible_inputs[0]
|
||||||
audio_labels_to_mix = []
|
audio_labels_to_mix = []
|
||||||
|
|
||||||
# Get audio channel setting from first narration video
|
# Get audio channel setting and volume from first narration video
|
||||||
channel_filter = ""
|
channel_filter = ""
|
||||||
|
narration_volume = 1.0
|
||||||
if plan.narration_videos:
|
if plan.narration_videos:
|
||||||
_, first_video_source, _ = plan.narration_videos[0]
|
_, first_video_source, _ = plan.narration_videos[0]
|
||||||
channel_filter = _build_audio_channel_filter(
|
channel_filter = _build_audio_channel_filter(
|
||||||
first_video_source.use_audio_channels
|
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)
|
# 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:
|
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:
|
if plan.outro_events:
|
||||||
# Trim narration audio to stop before outro
|
# Trim narration audio to stop before outro
|
||||||
if channel_filter:
|
filter_parts.append(f"atrim=0:{audio_end_time:.3f}")
|
||||||
filters.append(f"[{main_audio_idx}:a]{channel_filter}atrim=0:{audio_end_time:.3f},asetpts=PTS-STARTPTS[main_aud]")
|
filter_parts.append("asetpts=PTS-STARTPTS")
|
||||||
else:
|
filters.append(
|
||||||
filters.append(f"[{main_audio_idx}:a]atrim=0:{audio_end_time:.3f},asetpts=PTS-STARTPTS[main_aud]")
|
f"[{main_audio_idx}:a]{','.join(filter_parts)}[main_aud]"
|
||||||
|
)
|
||||||
audio_labels_to_mix.append("[main_aud]")
|
audio_labels_to_mix.append("[main_aud]")
|
||||||
elif channel_filter:
|
elif filter_parts:
|
||||||
filters.append(f"[{main_audio_idx}:a]{channel_filter}[main_aud]")
|
filters.append(f"[{main_audio_idx}:a]{','.join(filter_parts)}[main_aud]")
|
||||||
audio_labels_to_mix.append("[main_aud]")
|
audio_labels_to_mix.append("[main_aud]")
|
||||||
else:
|
else:
|
||||||
audio_labels_to_mix.append(f"[{main_audio_idx}:a]")
|
audio_labels_to_mix.append(f"[{main_audio_idx}:a]")
|
||||||
else:
|
else:
|
||||||
# Complex case: segment the narration audio for pauses
|
# Complex case: segment the narration audio for pauses
|
||||||
segments = _build_narration_segments(
|
segments = _build_narration_segments(plan.narration_pauses, audio_end_time)
|
||||||
plan.narration_pauses, audio_end_time
|
|
||||||
)
|
|
||||||
for seg_idx, (src_start, src_end, out_start, out_end) in enumerate(
|
for seg_idx, (src_start, src_end, out_start, out_end) in enumerate(
|
||||||
segments
|
segments
|
||||||
):
|
):
|
||||||
seg_label = f"narr_aud{seg_idx}"
|
seg_label = f"narr_aud{seg_idx}"
|
||||||
delay_ms = int(out_start * 1000)
|
delay_ms = int(out_start * 1000)
|
||||||
# Trim audio to source range, then delay to output position
|
# Trim audio to source range, then delay to output position
|
||||||
# Apply channel filter if needed
|
# Apply channel filter, volume filter if needed
|
||||||
channel_part = f"{channel_filter}," if channel_filter else ""
|
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(
|
filters.append(
|
||||||
f"[{main_audio_idx}:a]{channel_part}atrim={src_start:.3f}:{src_end:.3f},"
|
f"[{main_audio_idx}:a]{','.join(filter_parts)}[{seg_label}]"
|
||||||
f"asetpts=PTS-STARTPTS,"
|
|
||||||
f"adelay={delay_ms}|{delay_ms}[{seg_label}]"
|
|
||||||
)
|
)
|
||||||
audio_labels_to_mix.append(f"[{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:
|
if plan.narration_pauses and not event.audio_def.ignore_pauses:
|
||||||
# Build segments that skip narration pauses (pauses by default)
|
# Build segments that skip narration pauses (pauses by default)
|
||||||
relevant_pauses = [
|
relevant_pauses = [
|
||||||
p for p in plan.narration_pauses
|
p
|
||||||
|
for p in plan.narration_pauses
|
||||||
if p.output_time > event.start_time
|
if p.output_time > event.start_time
|
||||||
]
|
]
|
||||||
src_pos = 0.0
|
src_pos = 0.0
|
||||||
@@ -892,6 +1049,22 @@ def build_filter_complex(
|
|||||||
# Simple loop: no pauses or ignore_pauses=True
|
# Simple loop: no pauses or ignore_pauses=True
|
||||||
label = f"aud{i}"
|
label = f"aud{i}"
|
||||||
delay_ms = int(event.start_time * 1000)
|
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(
|
filters.append(
|
||||||
f"[{audio_idx}:a]aloop=loop=-1:size=2e+09,"
|
f"[{audio_idx}:a]aloop=loop=-1:size=2e+09,"
|
||||||
f"atrim=0:{remaining:.3f},"
|
f"atrim=0:{remaining:.3f},"
|
||||||
@@ -952,8 +1125,9 @@ def build_filter_complex(
|
|||||||
if len(audio_labels_to_mix) > 1:
|
if len(audio_labels_to_mix) > 1:
|
||||||
num_audio_tracks = len(audio_labels_to_mix)
|
num_audio_tracks = len(audio_labels_to_mix)
|
||||||
audio_mix_inputs = "".join(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(
|
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:
|
elif len(audio_labels_to_mix) == 1:
|
||||||
# Single audio track, just copy it
|
# Single audio track, just copy it
|
||||||
|
|||||||
+98
-2
@@ -5,7 +5,9 @@ import subprocess
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .cache import resolve_with_cache
|
||||||
from .errors import GnommoError
|
from .errors import GnommoError
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -78,8 +80,19 @@ def save_transcript(words: list[TranscribedWord], output_path: Path) -> None:
|
|||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def load_transcript(transcript_path: Path) -> list[TranscribedWord]:
|
def load_transcript(
|
||||||
"""Load transcribed words from a JSON file."""
|
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():
|
if not transcript_path.exists():
|
||||||
raise TranscriptionError(f"Transcript file not found: {transcript_path}")
|
raise TranscriptionError(f"Transcript file not found: {transcript_path}")
|
||||||
|
|
||||||
@@ -89,3 +102,86 @@ def load_transcript(transcript_path: Path) -> list[TranscribedWord]:
|
|||||||
return [
|
return [
|
||||||
TranscribedWord(word=w["word"], start=w["start"], end=w["end"]) for w in data
|
TranscribedWord(word=w["word"], start=w["start"], end=w["end"]) for w in data
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _format_srt_timestamp(seconds: float) -> str:
|
||||||
|
"""Format seconds as SRT timestamp: HH:MM:SS,mmm"""
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
millis = int((seconds % 1) * 1000)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def words_to_srt(
|
||||||
|
words: list[TranscribedWord],
|
||||||
|
max_words_per_line: int = 10,
|
||||||
|
max_duration: float = 5.0,
|
||||||
|
gap_threshold: float = 1.0,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Convert word-level timestamps to SRT caption format.
|
||||||
|
|
||||||
|
Groups words into readable caption segments based on:
|
||||||
|
- Maximum words per line (default: 10)
|
||||||
|
- Maximum segment duration (default: 5 seconds)
|
||||||
|
- Natural gaps between words (default: 1 second pause triggers new segment)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
words: List of TranscribedWord with timestamps
|
||||||
|
max_words_per_line: Maximum words before splitting to new segment
|
||||||
|
max_duration: Maximum duration of a single caption segment
|
||||||
|
gap_threshold: Pause duration that triggers a new segment
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SRT formatted string ready for YouTube upload
|
||||||
|
"""
|
||||||
|
if not words:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
segments: list[tuple[float, float, str]] = [] # (start, end, text)
|
||||||
|
current_words: list[str] = []
|
||||||
|
segment_start: float = words[0].start
|
||||||
|
segment_end: float = words[0].end
|
||||||
|
|
||||||
|
for i, word in enumerate(words):
|
||||||
|
# Check if we should start a new segment
|
||||||
|
start_new_segment = False
|
||||||
|
|
||||||
|
# Gap between words
|
||||||
|
if current_words and (word.start - segment_end) > gap_threshold:
|
||||||
|
start_new_segment = True
|
||||||
|
|
||||||
|
# Too many words
|
||||||
|
if len(current_words) >= max_words_per_line:
|
||||||
|
start_new_segment = True
|
||||||
|
|
||||||
|
# Segment too long
|
||||||
|
if current_words and (word.end - segment_start) > max_duration:
|
||||||
|
start_new_segment = True
|
||||||
|
|
||||||
|
if start_new_segment and current_words:
|
||||||
|
# Save current segment
|
||||||
|
text = " ".join(current_words)
|
||||||
|
segments.append((segment_start, segment_end, text))
|
||||||
|
# Start new segment
|
||||||
|
current_words = []
|
||||||
|
segment_start = word.start
|
||||||
|
|
||||||
|
current_words.append(word.word)
|
||||||
|
segment_end = word.end
|
||||||
|
|
||||||
|
# Don't forget the last segment
|
||||||
|
if current_words:
|
||||||
|
text = " ".join(current_words)
|
||||||
|
segments.append((segment_start, segment_end, text))
|
||||||
|
|
||||||
|
# Format as SRT
|
||||||
|
srt_lines = []
|
||||||
|
for idx, (start, end, text) in enumerate(segments, 1):
|
||||||
|
srt_lines.append(str(idx))
|
||||||
|
srt_lines.append(f"{_format_srt_timestamp(start)} --> {_format_srt_timestamp(end)}")
|
||||||
|
srt_lines.append(text)
|
||||||
|
srt_lines.append("") # Blank line between entries
|
||||||
|
|
||||||
|
return "\n".join(srt_lines)
|
||||||
|
|||||||
+58
-11
@@ -442,6 +442,7 @@ def build_render_plan(
|
|||||||
audio: Optional[dict[str, AudioDefinition]] = None,
|
audio: Optional[dict[str, AudioDefinition]] = None,
|
||||||
audio_dir: Optional[Path] = None,
|
audio_dir: Optional[Path] = None,
|
||||||
slide_range: Optional[tuple[str, Optional[str]]] = None,
|
slide_range: Optional[tuple[str, Optional[str]]] = None,
|
||||||
|
proxy: Optional[bool] = False,
|
||||||
) -> tuple[RenderPlan, list[MarkerTiming]]:
|
) -> tuple[RenderPlan, list[MarkerTiming]]:
|
||||||
"""
|
"""
|
||||||
Build a complete render plan from manuscript and transcription.
|
Build a complete render plan from manuscript and transcription.
|
||||||
@@ -461,9 +462,15 @@ def build_render_plan(
|
|||||||
audio_dir = audio_dir or project_path
|
audio_dir = audio_dir or project_path
|
||||||
|
|
||||||
# Find the main narration video first (need skip value for timing adjustment)
|
# 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):
|
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]
|
narration_video = videos[narration_video_id]
|
||||||
|
|
||||||
# Align markers to transcription timestamps
|
# Align markers to transcription timestamps
|
||||||
@@ -495,8 +502,13 @@ def build_render_plan(
|
|||||||
narration_video = videos[narration_video_id]
|
narration_video = videos[narration_video_id]
|
||||||
cutout = config.cutouts[narration_video.cutout]
|
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]] = []
|
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)
|
full_duration = get_video_duration(video_path)
|
||||||
# Adjust duration for skip (content starts at skip, so effective duration is less)
|
# Adjust duration for skip (content starts at skip, so effective duration is less)
|
||||||
effective_duration = full_duration - narration_skip
|
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,
|
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(
|
audio_events = _extract_audio_events(
|
||||||
marker_timings,
|
marker_timings,
|
||||||
audio,
|
audio,
|
||||||
@@ -622,6 +642,8 @@ def build_render_plan(
|
|||||||
total_duration,
|
total_duration,
|
||||||
videos_dir,
|
videos_dir,
|
||||||
shared_assets_dir,
|
shared_assets_dir,
|
||||||
|
project_path,
|
||||||
|
cached_files,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update total duration to include outro
|
# Update total duration to include outro
|
||||||
@@ -654,6 +676,7 @@ def build_render_plan(
|
|||||||
narration_pauses=narration_pauses,
|
narration_pauses=narration_pauses,
|
||||||
outro_events=outro_events,
|
outro_events=outro_events,
|
||||||
narration_end_time=narration_end_time,
|
narration_end_time=narration_end_time,
|
||||||
|
cached_files=cached_files,
|
||||||
)
|
)
|
||||||
|
|
||||||
return plan, marker_timings
|
return plan, marker_timings
|
||||||
@@ -663,8 +686,16 @@ def _resolve_video_path(
|
|||||||
videos_dir: Path,
|
videos_dir: Path,
|
||||||
video_source: VideoSource,
|
video_source: VideoSource,
|
||||||
shared_assets_dir: Path = None,
|
shared_assets_dir: Path = None,
|
||||||
) -> Path:
|
project_path: Path = None,
|
||||||
"""Resolve the actual video file path."""
|
) -> 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:
|
if video_source.is_shared and shared_assets_dir:
|
||||||
base_dir = shared_assets_dir
|
base_dir = shared_assets_dir
|
||||||
else:
|
else:
|
||||||
@@ -672,12 +703,24 @@ def _resolve_video_path(
|
|||||||
|
|
||||||
if video_source.output_file:
|
if video_source.output_file:
|
||||||
video_path = base_dir / video_source.output_file
|
video_path = base_dir / video_source.output_file
|
||||||
if video_path.exists():
|
if project_path:
|
||||||
return video_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")
|
webm_path = video_path.with_suffix(".mov")
|
||||||
if webm_path.exists():
|
if project_path:
|
||||||
return webm_path
|
resolved, is_cached = resolve_with_cache(webm_path, project_path)
|
||||||
return base_dir / video_source.source_file
|
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(
|
def _extract_slide_events(
|
||||||
@@ -932,6 +975,8 @@ def _extract_outro_events(
|
|||||||
narration_end_time: float,
|
narration_end_time: float,
|
||||||
videos_dir: Path,
|
videos_dir: Path,
|
||||||
shared_assets_dir: Path = None,
|
shared_assets_dir: Path = None,
|
||||||
|
project_path: Path = None,
|
||||||
|
cached_files: set = None,
|
||||||
) -> list[OutroEvent]:
|
) -> list[OutroEvent]:
|
||||||
"""
|
"""
|
||||||
Extract outro events that play after the narration ends.
|
Extract outro events that play after the narration ends.
|
||||||
@@ -949,7 +994,9 @@ def _extract_outro_events(
|
|||||||
video_source = videos[video_id]
|
video_source = videos[video_id]
|
||||||
|
|
||||||
# Get the video duration
|
# 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():
|
if video_path.exists():
|
||||||
full_duration = get_video_duration(video_path)
|
full_duration = get_video_duration(video_path)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+7
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .cache import resolve_with_cache
|
||||||
from .errors import ValidationError, ValidationIssue
|
from .errors import ValidationError, ValidationIssue
|
||||||
from .models import (
|
from .models import (
|
||||||
ProjectConfig,
|
ProjectConfig,
|
||||||
@@ -98,6 +99,7 @@ def validate_project(
|
|||||||
|
|
||||||
for slide_id, slide_def in slides.items():
|
for slide_id, slide_def in slides.items():
|
||||||
image_path = slides_dir / slide_def.image
|
image_path = slides_dir / slide_def.image
|
||||||
|
image_path, _ = resolve_with_cache(image_path, project_path)
|
||||||
if not image_path.exists():
|
if not image_path.exists():
|
||||||
issues.append(
|
issues.append(
|
||||||
ValidationIssue(
|
ValidationIssue(
|
||||||
@@ -142,6 +144,7 @@ def validate_project(
|
|||||||
base_dir = videos_dir
|
base_dir = videos_dir
|
||||||
|
|
||||||
video_path = base_dir / video_source.source_file
|
video_path = base_dir / video_source.source_file
|
||||||
|
video_path, _ = resolve_with_cache(video_path, project_path)
|
||||||
if not video_path.exists():
|
if not video_path.exists():
|
||||||
issues.append(
|
issues.append(
|
||||||
ValidationIssue(
|
ValidationIssue(
|
||||||
@@ -153,6 +156,7 @@ def validate_project(
|
|||||||
# Check preprocessed output exists if filters are defined
|
# Check preprocessed output exists if filters are defined
|
||||||
if video_source.filter and video_source.output_file:
|
if video_source.filter and video_source.output_file:
|
||||||
output_path = base_dir / 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():
|
if not output_path.exists():
|
||||||
issues.append(
|
issues.append(
|
||||||
ValidationIssue(
|
ValidationIssue(
|
||||||
@@ -168,9 +172,11 @@ def validate_project(
|
|||||||
if bg_file:
|
if bg_file:
|
||||||
# Check in project folder first, then parent (for shared_assets)
|
# Check in project folder first, then parent (for shared_assets)
|
||||||
bg_path = project_path / bg_file
|
bg_path = project_path / bg_file
|
||||||
|
bg_path, _ = resolve_with_cache(bg_path, project_path)
|
||||||
if not bg_path.exists():
|
if not bg_path.exists():
|
||||||
# Try parent directory (shared_assets at repo root)
|
# Try parent directory (shared_assets at repo root)
|
||||||
bg_path = project_path.parent / bg_file
|
bg_path = project_path.parent / bg_file
|
||||||
|
bg_path, _ = resolve_with_cache(bg_path, project_path.parent)
|
||||||
if not bg_path.exists():
|
if not bg_path.exists():
|
||||||
issues.append(
|
issues.append(
|
||||||
ValidationIssue(
|
ValidationIssue(
|
||||||
@@ -188,7 +194,7 @@ def validate_project(
|
|||||||
|
|
||||||
# Check resolution is reasonable
|
# Check resolution is reasonable
|
||||||
width, height = config.resolution
|
width, height = config.resolution
|
||||||
if width < 100 or height < 100:
|
if width < 50 or height < 50:
|
||||||
issues.append(
|
issues.append(
|
||||||
ValidationIssue(
|
ValidationIssue(
|
||||||
f"Resolution too small: {width}x{height}", project_path / "project.json"
|
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