Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4d6caacf | |||
| 7c75610fce | |||
| 0e22fcfbb3 | |||
| e734dbfcac | |||
| 757d966803 | |||
| 6949124fa7 | |||
| b6bc5a0463 | |||
| b21ca6b394 | |||
| 3dcd7961c6 | |||
| fdd275ac0e |
Binary file not shown.
@@ -0,0 +1,31 @@
|
|||||||
|
[S1]
|
||||||
|
What if the universe isn’t continuous?
|
||||||
|
What if it only looks smooth… because we’ve never zoomed in the right way?
|
||||||
|
|
||||||
|
[S2]
|
||||||
|
At Glitch University, our first public course asks a strange question:
|
||||||
|
Is the universe fundamentally pixelated?
|
||||||
|
Blocky?
|
||||||
|
Like Minecraft - just with absurdly tiny blocks?
|
||||||
|
|
||||||
|
[S3]
|
||||||
|
This question has been around forever.
|
||||||
|
But it's always been filed under "too weird to bother."
|
||||||
|
That's about to change.
|
||||||
|
|
||||||
|
[S4]
|
||||||
|
Explore the tech-tree.
|
||||||
|
Level up.
|
||||||
|
Run experiments on real data from space.
|
||||||
|
We're committed to scientific rigour.
|
||||||
|
Falsifiability. Truth-seeking.
|
||||||
|
And not being a complete bore.
|
||||||
|
|
||||||
|
|
||||||
|
[S5]
|
||||||
|
Don’t enroll now.
|
||||||
|
Enroll later.
|
||||||
|
|
||||||
|
[S6]
|
||||||
|
Glitch University
|
||||||
|
Later is now.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"S1": {
|
||||||
|
"image": "GlitchTrailer.001.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S2": {
|
||||||
|
"image": "GlitchTrailer.002.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S3": {
|
||||||
|
"image": "GlitchTrailer.003.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S4": {
|
||||||
|
"image": "GlitchTrailer.004.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S5": {
|
||||||
|
"image": "GlitchTrailer.005.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S6": {
|
||||||
|
"image": "GlitchTrailer.006.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"id": "GlitchTrailer",
|
||||||
|
"coursecode": "TRAILER",
|
||||||
|
"name": "Glitch University trailer",
|
||||||
|
"description": "Welcome to Glitch University.",
|
||||||
|
"hook": null,
|
||||||
|
"platform_targets": ["youtube"],
|
||||||
|
"status": "scripted",
|
||||||
|
"youtube_url": null,
|
||||||
|
"resolution": [1960, 1080],
|
||||||
|
"fps": 30,
|
||||||
|
"duration_seconds": null,
|
||||||
|
"default_filters": {
|
||||||
|
"audioonly": [
|
||||||
|
{
|
||||||
|
"type": "audio_normalize",
|
||||||
|
"compress": false,
|
||||||
|
"normalize": true,
|
||||||
|
"target_lufs": -14,
|
||||||
|
"target_lra": 11,
|
||||||
|
"target_tp": -1.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"talkinghead": [
|
||||||
|
{
|
||||||
|
"type": "audio_normalize",
|
||||||
|
"normalize": true,
|
||||||
|
"target_lufs": -14,
|
||||||
|
"target_lra": 11,
|
||||||
|
"target_tp": -1.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "color_grade",
|
||||||
|
"saturation": 1.15,
|
||||||
|
"contrast": 1.05,
|
||||||
|
"bm": -0.1,
|
||||||
|
"rm": 0.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gnommokey",
|
||||||
|
"screen_color": [
|
||||||
|
81,
|
||||||
|
137,
|
||||||
|
65
|
||||||
|
],
|
||||||
|
"screen_gain": 175,
|
||||||
|
"screen_balance": 58,
|
||||||
|
"despill_bias": [
|
||||||
|
217,
|
||||||
|
240,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"despill_strength": 5.0,
|
||||||
|
"edge_erode": 1.0,
|
||||||
|
"clip_black": 0,
|
||||||
|
"clip_white": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "mask",
|
||||||
|
"left": 0.05,
|
||||||
|
"right": 0.1,
|
||||||
|
"top": 0.1,
|
||||||
|
"bottom": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cutouts": {
|
||||||
|
"talkinghead": {
|
||||||
|
"x": "-23%",
|
||||||
|
"y": "10%",
|
||||||
|
"height": "90%"
|
||||||
|
},
|
||||||
|
"square": {
|
||||||
|
"x": "46.5%",
|
||||||
|
"y": "4.5%",
|
||||||
|
"width": "50%",
|
||||||
|
"height": "90%"
|
||||||
|
},
|
||||||
|
"fullscreen": {
|
||||||
|
"x": "0%",
|
||||||
|
"y": "0%",
|
||||||
|
"height": "100%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manuscript": "manuscript.txt",
|
||||||
|
"shorts": [],
|
||||||
|
"output_video": "TRAILER.mp4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# Gnommo
|
||||||
|
|
||||||
|
Gnommo is ADHD friendly video-editor for coders.
|
||||||
|
|
||||||
|
1. Design the presentation in keynote
|
||||||
|
2. Set up the greenscreen and audio settings once
|
||||||
|
3. Automatically times slides and videos to your voice.
|
||||||
|
4. Limited options means you waste less time on stuff that isn't important.
|
||||||
|
|
||||||
|
A code-first video editing pipeline for creating narrated presentations with slides, video overlays, and synchronized audio.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a project
|
||||||
|
gnommo -p myproject init
|
||||||
|
|
||||||
|
# Import slides and presenter notes from Keynote file
|
||||||
|
gnommo -p myproject import
|
||||||
|
|
||||||
|
# Process the narration videos with video and audio filters
|
||||||
|
gnommo -p myproject pre
|
||||||
|
|
||||||
|
# Stitch together the narration segments to one full length narration.
|
||||||
|
gnommo -p myproject stitch
|
||||||
|
|
||||||
|
# Transcribe the actual narrated content
|
||||||
|
gnommo -p myproject transcribe
|
||||||
|
|
||||||
|
# Generate the final video
|
||||||
|
gnommo -p myproject render
|
||||||
|
|
||||||
|
# Generate the final youtube assets. Manuscript file, description
|
||||||
|
gnommo -p myproject youtubeready
|
||||||
|
|
||||||
|
# Free up disk space locally by saving your project to an external drive
|
||||||
|
gnommo -p myproject archive
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resolution modes
|
||||||
|
|
||||||
|
All commands accept `--res` to trade quality for speed during iteration:
|
||||||
|
|
||||||
|
| Flag | Resolution | Use case |
|
||||||
|
|---|---|---|
|
||||||
|
| `--res full` | Project resolution (default) | Final output |
|
||||||
|
| `--res low` | 490×270 | Fast preview render |
|
||||||
|
| `--res tiny` | 320×180 | Ultrafast iteration (preprocess, stitch, render) |
|
||||||
|
|
||||||
|
`--res tiny` and `--res low` create downscaled copies of source files in subdirectories (`proxy/` and `low/` respectively) and work from those. The originals are never modified.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject pre --res tiny # fast preprocess
|
||||||
|
gnommo -p myproject stitch --res tiny # fast stitch
|
||||||
|
gnommo -p myproject render --res tiny # fast preview render
|
||||||
|
gnommo -p myproject render --res low # medium preview render
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
myproject/
|
||||||
|
├── project.json # Project configuration
|
||||||
|
├── manuscript.txt # Narration script with [markers]
|
||||||
|
├── media/
|
||||||
|
│ ├── slides/
|
||||||
|
│ │ ├── slides.json # Slide definitions
|
||||||
|
│ │ └── *.png # Slide images
|
||||||
|
│ ├── videos/
|
||||||
|
│ │ ├── videos.json # Video source definitions
|
||||||
|
│ │ └── *.mov # Video files
|
||||||
|
│ ├── narration/
|
||||||
|
│ │ ├── narration.json # Narration segment definitions
|
||||||
|
│ │ └── *.mov # Raw narration recordings
|
||||||
|
│ └── audio/
|
||||||
|
│ ├── audio.json # Audio effect definitions
|
||||||
|
│ └── *.mp3 # Sound effects
|
||||||
|
└── output/
|
||||||
|
└── final.mp4 # Rendered output
|
||||||
|
└── preview.mp4 # Preview (lower resolution, faster render)
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Five Stages
|
||||||
|
|
||||||
|
Gnommo uses a five-stage pipeline for processing video projects:
|
||||||
|
|
||||||
|
### Stage 1: Init
|
||||||
|
|
||||||
|
Creates a folder and a default project.json file inside it.
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject init
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stage 2: Import
|
||||||
|
First : Place the myproject.key Keynote presentation in the myproject folder.
|
||||||
|
Place videos, audio and narration you want to use in their respective folders in side myproject/media
|
||||||
|
Then : This command media scans directories and generates JSON definition files.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject import
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Opens the keynote presentation and exports all slides a PNG images into media/slides/
|
||||||
|
- Scans `media/slides/` for images → generates `slides.json`
|
||||||
|
- Scans `media/videos/` for video files → generates `videos.json`
|
||||||
|
- Scans `media/narration/` for recordings → generates `narration.json`
|
||||||
|
- Scans `media/audio/` for sound effects → generates `audio.json`
|
||||||
|
|
||||||
|
**When to use:** After adding new media files to populate the JSON definitions with the actual files in the folders
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Stage 3: Preprocess
|
||||||
|
|
||||||
|
Applies video filters (chroma key, scaling, etc.) to narration segments.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject pre
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Reads filter definitions from `project.json` and `narration.json`
|
||||||
|
- Processes each narration segment with its configured filters
|
||||||
|
- Outputs processed files (e.g., `segment1_processed.mov`)
|
||||||
|
|
||||||
|
**When to use:** After recording narration that needs background removal, sound normalization or other processing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Stage 4: stitch
|
||||||
|
First : Go through the source videos, and add trim settings to `begin` and `end` parameters in `narration.json`
|
||||||
|
|
||||||
|
Then : Run command to sticth the usable parts of narration segments into a single continuous video
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject stitch
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Reads segments from `narration.json`
|
||||||
|
- Concatenates them in order, respecting `begin`/`end` trim points
|
||||||
|
- Outputs `narration_combined.mov` in `media/videos/`
|
||||||
|
- Adds `narration_combined` entry to `videos.json` with volume settings
|
||||||
|
- Generates word-level timestamps from the narration using Whisper speech recognition.
|
||||||
|
|
||||||
|
**When to use:** After preprocessing, or adjusting trim settings, to create the main narration scaffolding.
|
||||||
|
|
||||||
|
### Stage 5: Render
|
||||||
|
|
||||||
|
Composites all elements into the final video.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject render
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Parses `manuscript.txt` for slide/video markers
|
||||||
|
- Aligns markers to transcription timestamps
|
||||||
|
- Composites background, narration, slides, and video overlays
|
||||||
|
- Outputs `final.mp4`
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject render --dry-run # Show FFmpeg command without running
|
||||||
|
gnommo -p myproject render --slides S1:S10 # Render only slides S1 through S10
|
||||||
|
gnommo -p myproject render --res low # Fast preview at 490x270
|
||||||
|
gnommo -p myproject render --res tiny # Ultrafast preview at 320x180
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shortcut: All Stages
|
||||||
|
|
||||||
|
Run all stages 2-5 and render in one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject all
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manuscript Format
|
||||||
|
|
||||||
|
The manuscript is plain text with embedded markers:
|
||||||
|
|
||||||
|
```
|
||||||
|
[S1] Welcome to this presentation.
|
||||||
|
|
||||||
|
[S2] Let me show you how this works.
|
||||||
|
|
||||||
|
[video:demo] Here's a quick demonstration.
|
||||||
|
|
||||||
|
[Zoom1] Notice this important detail.
|
||||||
|
|
||||||
|
[Reset] And that concludes our overview.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Marker types:**
|
||||||
|
- `[S1]`, `[S2]` - Slide markers (reference slides.json)
|
||||||
|
- `[video:id]` - Triggered video overlay
|
||||||
|
- `[narration:id]` - Start continuous narration video
|
||||||
|
- `[Zoom1]`, `[Reset]` - Camera presets
|
||||||
|
- `[Awoosh]` - Audio effect trigger
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Storage (GnommoCache)
|
||||||
|
|
||||||
|
For large projects, gnommo supports transparent external storage fallback.
|
||||||
|
|
||||||
|
**Setup:** Create `~/.gnommo.conf`:
|
||||||
|
```ini
|
||||||
|
[cache]
|
||||||
|
path = /Volumes/ExternalDrive/gnommo
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- Files are first looked up locally in the project directory
|
||||||
|
- If not found, gnommo checks `{cache_path}/{project_name}/...`
|
||||||
|
- The 📁 indicator shows files loaded from external storage
|
||||||
|
|
||||||
|
**Archive to external storage:**
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject archive # Sync project to cache
|
||||||
|
gnommo -p myproject archive --dry-run # Preview what would sync
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows you to move large preprocessed files to external storage while keeping the project functional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### New Project Setup
|
||||||
|
```bash
|
||||||
|
# 1. Create project structure and add media files
|
||||||
|
mkdir -p myproject/media/{slides,videos,narration,audio}
|
||||||
|
|
||||||
|
# 2. Create project.json with basic config
|
||||||
|
|
||||||
|
# 3. Import media to generate JSON definitions
|
||||||
|
gnommo -p myproject import
|
||||||
|
|
||||||
|
# 4. Edit JSON files to configure filters, trim points, etc.
|
||||||
|
|
||||||
|
# 5. Run full pipeline
|
||||||
|
gnommo -p myproject all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Re-render After Editing Manuscript
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject render
|
||||||
|
```
|
||||||
|
|
||||||
|
### Re-process After Recording New Narration
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject pre
|
||||||
|
gnommo -p myproject stitch
|
||||||
|
gnommo -p myproject transcribe
|
||||||
|
gnommo -p myproject render
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject validate # Check for errors without rendering
|
||||||
|
gnommo -p myproject description # Generate YouTube description with chapters
|
||||||
|
gnommo -p myproject transcribe --final # Transcribe final.mp4 for subtitles
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Glitch University — Server Sync
|
||||||
|
|
||||||
|
Gnommo can push project metadata and short scripts to a gnommoweb server,
|
||||||
|
and pull changes back. This keeps the platform database in sync with your
|
||||||
|
local project files without manual copy-paste.
|
||||||
|
|
||||||
|
**Setup** — add to `gnommo/.env`:
|
||||||
|
```ini
|
||||||
|
GNOMMOWEB_URL=http://localhost:3001
|
||||||
|
GNOMMOWEB_API_KEY=your_content_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Push
|
||||||
|
|
||||||
|
Registers the project on the server and syncs all defined shorts (including
|
||||||
|
their scripts). Creates a filming task for each new short.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject push # push local → server
|
||||||
|
gnommo -p myproject push --force # overwrite server even if it has newer changes
|
||||||
|
```
|
||||||
|
|
||||||
|
On the first push, gnommo creates:
|
||||||
|
- A stub video record in the platform database
|
||||||
|
- One short record per entry in `project.json["shorts"]`
|
||||||
|
- One task per new short ("Film short: …")
|
||||||
|
|
||||||
|
Re-running push is safe — existing records are updated, no duplicate tasks.
|
||||||
|
Scripts are only overwritten on the server if the local file has changed;
|
||||||
|
edits made in the staff UI are preserved.
|
||||||
|
|
||||||
|
### Pull
|
||||||
|
|
||||||
|
Fetches the current project state from the server and merges the `shorts`
|
||||||
|
array back into `project.json`. Useful after editing short titles or hooks
|
||||||
|
in the web interface.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gnommo -p myproject pull # pull server → local
|
||||||
|
gnommo -p myproject pull --force # overwrite local even if it has unsaved changes
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull preserves local `script` file paths — it won't overwrite your `.md`
|
||||||
|
script files.
|
||||||
|
|
||||||
|
### Conflict guards
|
||||||
|
|
||||||
|
Both commands check for conflicts before writing:
|
||||||
|
|
||||||
|
| Situation | Push behaviour | Pull behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| Server has changes you haven't pulled | Blocked — pull first | Proceeds (that's the point) |
|
||||||
|
| Local has changes you haven't pushed | Proceeds (that's the point) | Blocked — push first |
|
||||||
|
| `--force` flag | Overrides | Overrides |
|
||||||
|
|
||||||
|
Sync state is stored in `<project>/.gnommo_sync.json` (tracked by git,
|
||||||
|
so collaborators share the same reference point).
|
||||||
|
|
||||||
|
### Defining shorts in `project.json`
|
||||||
|
|
||||||
|
Add a `shorts` array to your project:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"shorts": [
|
||||||
|
{
|
||||||
|
"id": "short_pixelated_universe",
|
||||||
|
"title": "Is the universe pixelated?",
|
||||||
|
"hook": "What if space is made of tiny blocks?",
|
||||||
|
"script": "shorts/short_pixelated_universe.md",
|
||||||
|
"platform_targets": ["youtube"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` — unique slug within the project, used as the upsert key
|
||||||
|
- `script` — relative path to a markdown file with the full short narration
|
||||||
|
- `hook` — opening line / thumbnail caption
|
||||||
|
- `platform_targets` — list of platforms (currently `["youtube"]`)
|
||||||
|
|
||||||
|
Scripts are plain markdown with the same `[SLIDE: name]` markers and
|
||||||
|
`{word}` whisper timestamp tags used elsewhere in gnommo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- FFmpeg
|
||||||
|
- OpenAI Whisper (for transcription)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install openai-whisper
|
||||||
|
```
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"drives": {
|
||||||
|
"lacie": {
|
||||||
|
"mount_path": "/Volumes/LaCie Jens",
|
||||||
|
"backups": {
|
||||||
|
"small": {
|
||||||
|
"last_attempt": "2026-03-26T09:48:05Z",
|
||||||
|
"last_status": "success",
|
||||||
|
"last_completed": "2026-03-26T09:48:13Z"
|
||||||
|
},
|
||||||
|
"big": {
|
||||||
|
"last_attempt": "2026-02-27T12:17:30Z",
|
||||||
|
"last_status": "failed"
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"last_attempt": "2026-03-26T10:32:56Z",
|
||||||
|
"last_status": "success",
|
||||||
|
"last_completed": "2026-03-26T10:36:24Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
#
|
||||||
|
# Gnommo Backup Utility
|
||||||
|
# Syncs project files to an external drive using rsync
|
||||||
|
#
|
||||||
|
# Usage: ./backup.sh <mode> <drive> [options]
|
||||||
|
#
|
||||||
|
# Modes:
|
||||||
|
# small - Keynotes, images, metadata, code (excludes large media)
|
||||||
|
# big - Large video/audio files only (for offloading)
|
||||||
|
# all - Complete mirror of entire project
|
||||||
|
#
|
||||||
|
# Drives:
|
||||||
|
# lacie - /Volumes/LaCie Jens/gnommo
|
||||||
|
# gnommodisk - /Volumes/gnommodisk/gnommo
|
||||||
|
# status - Show backup status for all drives
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --dry-run Show what would be transferred without copying
|
||||||
|
# --delete Delete files on destination that don't exist in source
|
||||||
|
# --progress Show detailed transfer progress (default: on)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PROJECT_DIR="/Users/jenstandstad/Projects/gnommo"
|
||||||
|
BACKUP_JSON="$PROJECT_DIR/backup.json"
|
||||||
|
BIG_FILE_SIZE="100M"
|
||||||
|
|
||||||
|
# Known drives (name -> mount path)
|
||||||
|
typeset -A KNOWN_DRIVES
|
||||||
|
KNOWN_DRIVES=(
|
||||||
|
lacie "/Volumes/LaCie Jens"
|
||||||
|
gnommodisk "/Volumes/gnommodisk"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Big file extensions (video/audio that tend to be large)
|
||||||
|
BIG_EXTENSIONS=("mov" "mp4" "m4v" "avi" "mkv" "aifc" "aiff" "wav")
|
||||||
|
|
||||||
|
# Initialize backup.json if it doesn't exist
|
||||||
|
init_backup_json() {
|
||||||
|
if [[ ! -f "$BACKUP_JSON" ]]; then
|
||||||
|
cat > "$BACKUP_JSON" << 'EOF'
|
||||||
|
{
|
||||||
|
"drives": {}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update backup.json using Python (reliable JSON handling)
|
||||||
|
update_backup_json() {
|
||||||
|
local drive_name="$1"
|
||||||
|
local backup_mode="$2"
|
||||||
|
local backup_status="$3" # "started" or "completed"
|
||||||
|
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
python3 << PYTHON
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
backup_file = "$BACKUP_JSON"
|
||||||
|
drive_name = "$drive_name"
|
||||||
|
mode = "$backup_mode"
|
||||||
|
status = "$backup_status"
|
||||||
|
timestamp = "$timestamp"
|
||||||
|
|
||||||
|
# Load existing data
|
||||||
|
if os.path.exists(backup_file):
|
||||||
|
with open(backup_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
else:
|
||||||
|
data = {"drives": {}}
|
||||||
|
|
||||||
|
# Ensure drive entry exists
|
||||||
|
if drive_name not in data["drives"]:
|
||||||
|
data["drives"][drive_name] = {
|
||||||
|
"mount_path": "${KNOWN_DRIVES[$drive_name]:-$DESTINATION}",
|
||||||
|
"backups": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure mode entry exists
|
||||||
|
if mode not in data["drives"][drive_name]["backups"]:
|
||||||
|
data["drives"][drive_name]["backups"][mode] = {}
|
||||||
|
|
||||||
|
# Update based on status
|
||||||
|
backup_entry = data["drives"][drive_name]["backups"][mode]
|
||||||
|
if status == "started":
|
||||||
|
backup_entry["last_attempt"] = timestamp
|
||||||
|
backup_entry["last_status"] = "in_progress"
|
||||||
|
elif status == "completed":
|
||||||
|
backup_entry["last_completed"] = timestamp
|
||||||
|
backup_entry["last_status"] = "success"
|
||||||
|
elif status == "failed":
|
||||||
|
backup_entry["last_status"] = "failed"
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
with open(backup_file, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
PYTHON
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show backup status
|
||||||
|
show_status() {
|
||||||
|
echo "========================================"
|
||||||
|
echo "Gnommo Backup Status"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
if [[ ! -f "$BACKUP_JSON" ]]; then
|
||||||
|
echo "No backups recorded yet."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 << 'PYTHON'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
backup_file = os.environ.get('BACKUP_JSON', 'backup.json')
|
||||||
|
known_drives = {"lacie": "/Volumes/LaCie Jens", "gnommodisk": "/Volumes/gnommodisk"}
|
||||||
|
|
||||||
|
with open(backup_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for drive_name, drive_info in data.get("drives", {}).items():
|
||||||
|
mount_path = drive_info.get("mount_path", "unknown")
|
||||||
|
mounted = "CONNECTED" if os.path.exists(mount_path) else "not connected"
|
||||||
|
|
||||||
|
print(f"\n{drive_name} ({mounted})")
|
||||||
|
print(f" Path: {mount_path}")
|
||||||
|
|
||||||
|
backups = drive_info.get("backups", {})
|
||||||
|
if not backups:
|
||||||
|
print(" No backups recorded")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for mode, info in backups.items():
|
||||||
|
status = info.get("last_status", "unknown")
|
||||||
|
completed = info.get("last_completed", "never")
|
||||||
|
attempt = info.get("last_attempt", "never")
|
||||||
|
|
||||||
|
# Format the completed time nicely
|
||||||
|
if completed != "never":
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(completed.replace('Z', '+00:00'))
|
||||||
|
completed = dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
status_icon = "✓" if status == "success" else "⋯" if status == "in_progress" else "✗"
|
||||||
|
print(f" {mode}: {status_icon} {completed}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
PYTHON
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat << EOF
|
||||||
|
Gnommo Backup Utility
|
||||||
|
|
||||||
|
Usage: $(basename "$0") <mode> <drive> [options]
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
small Sync small files only: Keynotes, images, JSON, code, manuscripts
|
||||||
|
Excludes: .mov, .mp4, .aifc, and other large media files
|
||||||
|
|
||||||
|
big Sync large files only: video and audio media files
|
||||||
|
Useful for offloading to free up local space
|
||||||
|
|
||||||
|
all Full mirror of the entire gnommo project
|
||||||
|
|
||||||
|
status Show backup status for all known drives
|
||||||
|
|
||||||
|
Drives:
|
||||||
|
lacie /Volumes/LaCie Jens/gnommo
|
||||||
|
gnommodisk /Volumes/gnommodisk/gnommo
|
||||||
|
<path> Or specify a custom path
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Preview what would be transferred (no actual copying)
|
||||||
|
--delete Remove files on destination that no longer exist in source
|
||||||
|
--no-progress Disable progress display
|
||||||
|
--help Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") status
|
||||||
|
$(basename "$0") small lacie
|
||||||
|
$(basename "$0") big gnommodisk --delete
|
||||||
|
$(basename "$0") all lacie --dry-run
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
MODE=""
|
||||||
|
DRIVE=""
|
||||||
|
DESTINATION=""
|
||||||
|
DRY_RUN=""
|
||||||
|
DELETE=""
|
||||||
|
PROGRESS="--progress"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
small|big|all)
|
||||||
|
MODE="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
export BACKUP_JSON
|
||||||
|
show_status
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
lacie|gnommodisk)
|
||||||
|
DRIVE="$1"
|
||||||
|
DESTINATION="${KNOWN_DRIVES[$1]}/gnommo"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN="--dry-run"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--delete)
|
||||||
|
DELETE="--delete"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-progress)
|
||||||
|
PROGRESS=""
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z "$DESTINATION" ]]; then
|
||||||
|
DRIVE="custom"
|
||||||
|
DESTINATION="$1"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Handle status command
|
||||||
|
if [[ "$MODE" == "status" ]]; then
|
||||||
|
show_status
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate arguments
|
||||||
|
if [[ -z "$MODE" ]]; then
|
||||||
|
echo "Error: Mode is required (small, big, all, or status)"
|
||||||
|
echo ""
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$DESTINATION" ]]; then
|
||||||
|
echo "Error: Drive or destination path is required"
|
||||||
|
echo ""
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if drive is mounted (get the volume path, handling spaces)
|
||||||
|
MOUNT_PATH="${DESTINATION%/gnommo}"
|
||||||
|
if [[ ! -d "$MOUNT_PATH" ]]; then
|
||||||
|
echo "Error: Drive not mounted at: $MOUNT_PATH"
|
||||||
|
echo ""
|
||||||
|
echo "Available volumes:"
|
||||||
|
ls /Volumes/ 2>/dev/null | sed 's/^/ /'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create destination directory if needed
|
||||||
|
mkdir -p "$DESTINATION"
|
||||||
|
|
||||||
|
# Initialize backup tracking
|
||||||
|
init_backup_json
|
||||||
|
|
||||||
|
# Build rsync command
|
||||||
|
RSYNC_OPTS="-avh"
|
||||||
|
[[ -n "$PROGRESS" ]] && RSYNC_OPTS="$RSYNC_OPTS --progress"
|
||||||
|
[[ -n "$DRY_RUN" ]] && RSYNC_OPTS="$RSYNC_OPTS --dry-run"
|
||||||
|
[[ -n "$DELETE" ]] && RSYNC_OPTS="$RSYNC_OPTS --delete"
|
||||||
|
|
||||||
|
# Always exclude these
|
||||||
|
EXCLUDE_ALWAYS=(
|
||||||
|
".DS_Store"
|
||||||
|
"__pycache__"
|
||||||
|
"*.pyc"
|
||||||
|
".git"
|
||||||
|
".env"
|
||||||
|
"*.egg-info"
|
||||||
|
".venv"
|
||||||
|
"venv"
|
||||||
|
"node_modules"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build exclusion patterns for big files
|
||||||
|
build_big_excludes() {
|
||||||
|
local excludes=""
|
||||||
|
for ext in "${BIG_EXTENSIONS[@]}"; do
|
||||||
|
excludes="$excludes --exclude='*.$ext'"
|
||||||
|
done
|
||||||
|
echo "$excludes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build inclusion patterns for big files only
|
||||||
|
build_big_includes() {
|
||||||
|
local includes=""
|
||||||
|
for ext in "${BIG_EXTENSIONS[@]}"; do
|
||||||
|
includes="$includes --include='*.$ext'"
|
||||||
|
done
|
||||||
|
echo "$includes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build common exclusions
|
||||||
|
build_common_excludes() {
|
||||||
|
local excludes=""
|
||||||
|
for pattern in "${EXCLUDE_ALWAYS[@]}"; do
|
||||||
|
excludes="$excludes --exclude='$pattern'"
|
||||||
|
done
|
||||||
|
echo "$excludes"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "Gnommo Backup Utility"
|
||||||
|
echo "========================================"
|
||||||
|
echo "Mode: $MODE"
|
||||||
|
echo "Drive: $DRIVE"
|
||||||
|
echo "Source: $PROJECT_DIR"
|
||||||
|
echo "Destination: $DESTINATION"
|
||||||
|
[[ -n "$DRY_RUN" ]] && echo "DRY RUN: Yes (no files will be copied)"
|
||||||
|
[[ -n "$DELETE" ]] && echo "Delete: Yes (will remove orphaned files)"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Record backup attempt (skip for dry-run)
|
||||||
|
if [[ -z "$DRY_RUN" ]]; then
|
||||||
|
update_backup_json "$DRIVE" "$MODE" "started"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Track success
|
||||||
|
BACKUP_SUCCESS=false
|
||||||
|
|
||||||
|
run_backup() {
|
||||||
|
case "$MODE" in
|
||||||
|
small)
|
||||||
|
echo "Syncing SMALL files (excluding large media)..."
|
||||||
|
echo "Excludes: ${BIG_EXTENSIONS[*]}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
EXCLUDES=$(build_common_excludes)
|
||||||
|
BIG_EXCLUDES=$(build_big_excludes)
|
||||||
|
|
||||||
|
eval rsync $RSYNC_OPTS $EXCLUDES $BIG_EXCLUDES "'$PROJECT_DIR/'" "'$DESTINATION/'"
|
||||||
|
;;
|
||||||
|
|
||||||
|
big)
|
||||||
|
echo "Syncing BIG files only (large media)..."
|
||||||
|
echo "Includes: ${BIG_EXTENSIONS[*]}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
EXCLUDES=$(build_common_excludes)
|
||||||
|
INCLUDES="--include='*/' $(build_big_includes)"
|
||||||
|
|
||||||
|
eval rsync $RSYNC_OPTS $EXCLUDES $INCLUDES --exclude="'*'" "'$PROJECT_DIR/'" "'$DESTINATION/'"
|
||||||
|
;;
|
||||||
|
|
||||||
|
all)
|
||||||
|
echo "Syncing ALL files (complete mirror)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
EXCLUDES=$(build_common_excludes)
|
||||||
|
|
||||||
|
eval rsync $RSYNC_OPTS $EXCLUDES "'$PROJECT_DIR/'" "'$DESTINATION/'"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run backup and track result
|
||||||
|
# Exit codes: 0=success, 23=partial transfer (files changed during sync, usually OK), 24=vanished files
|
||||||
|
run_backup && BACKUP_SUCCESS=true || {
|
||||||
|
local exit_code=$?
|
||||||
|
if [[ $exit_code -eq 23 || $exit_code -eq 24 ]]; then
|
||||||
|
echo "Note: Some files changed during transfer (rsync exit $exit_code) - backup completed"
|
||||||
|
BACKUP_SUCCESS=true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
if [[ -n "$DRY_RUN" ]]; then
|
||||||
|
echo "DRY RUN complete. No files were copied."
|
||||||
|
else
|
||||||
|
if [[ "$BACKUP_SUCCESS" == true ]]; then
|
||||||
|
update_backup_json "$DRIVE" "$MODE" "completed"
|
||||||
|
echo "Backup complete!"
|
||||||
|
else
|
||||||
|
update_backup_json "$DRIVE" "$MODE" "failed"
|
||||||
|
echo "Backup FAILED!"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "========================================"
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# Partial Rendering Specification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Enable rendering of specific sections of a video (e.g., slides 1-10, then 10-20) instead of the full video. This is useful for:
|
||||||
|
- Faster iteration during development
|
||||||
|
- Re-rendering specific sections after fixes
|
||||||
|
- Parallel rendering of segments that can be concatenated later
|
||||||
|
|
||||||
|
## Scope (v1)
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Camera state tracking (cumulative state must be computed from t=0)
|
||||||
|
- Time offset adjustment for all events
|
||||||
|
- Slide range filtering
|
||||||
|
- Input video seeking
|
||||||
|
|
||||||
|
**Out of scope (v1):**
|
||||||
|
- Audio events crossing range boundaries
|
||||||
|
- Triggered video duration edge cases
|
||||||
|
- Events are assumed to begin at their marker timestamp and never "carry over"
|
||||||
|
|
||||||
|
## Current Architecture Analysis
|
||||||
|
|
||||||
|
### 1. Camera State Management
|
||||||
|
|
||||||
|
**Current behavior** (`transformer.py:250-332`):
|
||||||
|
- Camera state is **cumulative** across the transcript
|
||||||
|
- `_extract_camera_events()` walks through ALL markers sequentially
|
||||||
|
- Each marker type (Zoom/Tilt/Pan) only modifies its property while preserving others
|
||||||
|
- Example: `[Zoom2]` then `[TiltLeft]` = both zoom AND tilt active
|
||||||
|
|
||||||
|
**Problem for partial rendering**:
|
||||||
|
If we start rendering at slide 10, we need the camera state AS IT WOULD BE after processing slides 1-9.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
Separate "state computation" from "event generation":
|
||||||
|
1. Always walk through ALL transcript markers to compute cumulative state
|
||||||
|
2. Track the "initial state" at the start of the render range
|
||||||
|
3. Only emit CameraEvents for markers WITHIN the render range
|
||||||
|
4. First event in partial render must transition FROM the computed initial state
|
||||||
|
|
||||||
|
### 2. Time Signature Adjustment
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
All timing uses absolute timestamps from `transcript.csv`:
|
||||||
|
- `SlideEvent.start_time/end_time`
|
||||||
|
- `VideoEvent.start_time/end_time`
|
||||||
|
- `AudioEvent.start_time`
|
||||||
|
- `CameraEvent.time`
|
||||||
|
- FFmpeg expressions: `enable=between(t, start, end)`
|
||||||
|
- Camera animation: `if(between(t, 1.000, 1.200), ...)`
|
||||||
|
|
||||||
|
**Problem for partial rendering**:
|
||||||
|
If slide 10 starts at t=10.0s and we render from there, FFmpeg expects t=0 at the start of output.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
Apply a `time_offset` to all events after extraction:
|
||||||
|
```
|
||||||
|
new_time = original_time - time_offset
|
||||||
|
```
|
||||||
|
Where `time_offset` = start time of first slide/event in range.
|
||||||
|
|
||||||
|
### 3. Input Video Seeking
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
- Always-visible videos (talking head) start from the beginning
|
||||||
|
- FFmpeg processes entire input duration
|
||||||
|
|
||||||
|
**Problem for partial rendering**:
|
||||||
|
Need to seek into source videos to the correct position.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
Add `-ss <seek_time>` before input files for always-visible videos:
|
||||||
|
```
|
||||||
|
ffmpeg -ss 10.0 -i talking_head.mov ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed API
|
||||||
|
|
||||||
|
### Command Line Interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Render full video (current behavior)
|
||||||
|
gnommo render example/project.json output.mp4
|
||||||
|
|
||||||
|
# Render specific slide range
|
||||||
|
gnommo render example/project.json output.mp4 --slides S1:S10
|
||||||
|
gnommo render example/project.json output.mp4 --slides S10:S20
|
||||||
|
gnommo render example/project.json output.mp4 --slides S5: # S5 to end
|
||||||
|
|
||||||
|
# Render specific time range (alternative)
|
||||||
|
gnommo render example/project.json output.mp4 --time 0:60
|
||||||
|
gnommo render example/project.json output.mp4 --time 60:120
|
||||||
|
```
|
||||||
|
|
||||||
|
### Internal API
|
||||||
|
|
||||||
|
New parameters for `build_render_plan()`:
|
||||||
|
```python
|
||||||
|
def build_render_plan(
|
||||||
|
...
|
||||||
|
slide_range: Optional[tuple[str, Optional[str]]] = None, # (start_slide, end_slide)
|
||||||
|
# OR
|
||||||
|
time_range: Optional[tuple[float, Optional[float]]] = None, # (start_time, end_time)
|
||||||
|
) -> RenderPlan:
|
||||||
|
```
|
||||||
|
|
||||||
|
New field on `RenderPlan`:
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class RenderPlan:
|
||||||
|
...
|
||||||
|
time_offset: float = 0.0 # Offset to subtract from all timestamps
|
||||||
|
initial_camera_state: CameraState = field(default_factory=CameraState) # State at render start
|
||||||
|
input_seek_time: float = 0.0 # Seek position for input videos
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Phase 1: Compute Full State, Filter Events
|
||||||
|
|
||||||
|
Modify `_extract_camera_events()` to accept a time range:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _extract_camera_events(
|
||||||
|
transcript: list[TimedWord],
|
||||||
|
time_range: Optional[tuple[float, float]] = None, # (start, end)
|
||||||
|
) -> tuple[list[CameraEvent], CameraState]:
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
- List of CameraEvents within time_range
|
||||||
|
- Initial CameraState at start of time_range
|
||||||
|
"""
|
||||||
|
events: list[CameraEvent] = []
|
||||||
|
current_state = CameraState()
|
||||||
|
initial_state = CameraState()
|
||||||
|
start_time, end_time = time_range or (0.0, float('inf'))
|
||||||
|
|
||||||
|
found_start = False
|
||||||
|
|
||||||
|
for timed_word in transcript:
|
||||||
|
if not timed_word.is_marker:
|
||||||
|
continue
|
||||||
|
|
||||||
|
marker_id = timed_word.marker_id
|
||||||
|
if not marker_id or marker_id not in CAMERA_PRESETS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Always update current_state (full walk)
|
||||||
|
preset = CAMERA_PRESETS[marker_id]
|
||||||
|
new_state = _apply_preset(current_state, marker_id, preset)
|
||||||
|
|
||||||
|
# Capture state just before we enter the render range
|
||||||
|
if not found_start and timed_word.time >= start_time:
|
||||||
|
initial_state = current_state # State BEFORE this marker
|
||||||
|
found_start = True
|
||||||
|
|
||||||
|
# Only emit events within range
|
||||||
|
if start_time <= timed_word.time < end_time:
|
||||||
|
events.append(CameraEvent(
|
||||||
|
time=timed_word.time,
|
||||||
|
target_state=new_state,
|
||||||
|
duration=0.2,
|
||||||
|
easing="ease-out",
|
||||||
|
))
|
||||||
|
|
||||||
|
current_state = new_state
|
||||||
|
|
||||||
|
return events, initial_state
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Apply Time Offset
|
||||||
|
|
||||||
|
After extracting events, apply offset to all timestamps:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _apply_time_offset(plan: RenderPlan, offset: float) -> RenderPlan:
|
||||||
|
"""Shift all timestamps by offset (subtract offset from all times)."""
|
||||||
|
|
||||||
|
# Adjust slide events
|
||||||
|
for event in plan.slide_events:
|
||||||
|
event.start_time -= offset
|
||||||
|
event.end_time -= offset
|
||||||
|
|
||||||
|
# Adjust video events
|
||||||
|
for event in plan.video_events:
|
||||||
|
event.start_time -= offset
|
||||||
|
event.end_time -= offset
|
||||||
|
|
||||||
|
# Adjust audio events
|
||||||
|
for event in plan.audio_events:
|
||||||
|
event.start_time = max(0, event.start_time - offset)
|
||||||
|
|
||||||
|
# Adjust camera events
|
||||||
|
for event in plan.camera_events:
|
||||||
|
event.time -= offset
|
||||||
|
|
||||||
|
# Adjust total duration
|
||||||
|
plan.total_duration -= offset
|
||||||
|
plan.time_offset = offset
|
||||||
|
plan.input_seek_time = offset
|
||||||
|
|
||||||
|
return plan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: FFmpeg Seeking
|
||||||
|
|
||||||
|
Modify `build_ffmpeg_command()` to add seeking:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
||||||
|
cmd = ["ffmpeg", "-y"]
|
||||||
|
|
||||||
|
# Add seek for always-visible videos
|
||||||
|
for video_id, video_source, cutout in plan.narration_videos:
|
||||||
|
video_path = _resolve_video_path(videos_dir, video_source)
|
||||||
|
if plan.input_seek_time > 0:
|
||||||
|
cmd.extend(["-ss", str(plan.input_seek_time)]) # Seek BEFORE -i
|
||||||
|
cmd.extend(["-i", str(video_path)])
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Initial Camera State Handling
|
||||||
|
|
||||||
|
If `initial_camera_state` is not default, inject a "virtual" camera event at t=0:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_camera_transform(
|
||||||
|
camera_events: list[CameraEvent],
|
||||||
|
initial_state: CameraState, # NEW PARAMETER
|
||||||
|
...
|
||||||
|
) -> str:
|
||||||
|
# If initial state differs from default, prepend a virtual event
|
||||||
|
if not initial_state.is_default():
|
||||||
|
initial_event = CameraEvent(
|
||||||
|
time=0.0,
|
||||||
|
target_state=initial_state,
|
||||||
|
duration=0.0, # Instant - no transition
|
||||||
|
easing="linear",
|
||||||
|
)
|
||||||
|
camera_events = [initial_event] + camera_events
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FFmpeg Optimization
|
||||||
|
|
||||||
|
**Only emit filters for events within range.**
|
||||||
|
|
||||||
|
When rendering a partial range, the `RenderPlan` should only contain events within that range. This means:
|
||||||
|
- Fewer inputs added to the FFmpeg command (only slides/videos/audio actually used)
|
||||||
|
- Fewer overlay filters in filter_complex
|
||||||
|
- Fewer `between(t, start, end)` enable expressions to evaluate per frame
|
||||||
|
|
||||||
|
Example: Full video has 50 slides, rendering S40:S50 only:
|
||||||
|
- **Before**: 50 slide inputs, 50 overlay filters
|
||||||
|
- **After**: 10 slide inputs, 10 overlay filters
|
||||||
|
|
||||||
|
This is achieved naturally by filtering events in `build_render_plan()` before constructing the plan - the renderer already only processes events present in the plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases (v1 Simplified)
|
||||||
|
|
||||||
|
### 1. Camera state from before range
|
||||||
|
If rendering S5:S10 but there's a camera event at the S4 marker:
|
||||||
|
- Camera state from S4 must be captured as `initial_camera_state`
|
||||||
|
- Rendered output starts with that state already applied at t=0
|
||||||
|
|
||||||
|
### 2. Events filter by marker position
|
||||||
|
All events (slides, videos, audio) are filtered by whether their START marker falls within the range.
|
||||||
|
- Events beginning outside range are excluded
|
||||||
|
- No "carry over" or boundary-crossing logic needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
1. Camera state computation maintains state across full transcript
|
||||||
|
2. Time offset correctly shifts all event types
|
||||||
|
3. Initial camera state correctly captured at boundary
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
1. Render slides 1-5, then 5-10, concatenate, compare to full render
|
||||||
|
2. Camera state continuity across segment boundaries
|
||||||
|
3. Audio alignment after seeking
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
1. Visual inspection of camera state at segment boundaries
|
||||||
|
2. Audio sync verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Parallel Rendering Pipeline
|
||||||
|
```bash
|
||||||
|
# Render in parallel, then stitch
|
||||||
|
gnommo render proj.json seg1.mp4 --slides S1:S10 &
|
||||||
|
gnommo render proj.json seg2.mp4 --slides S10:S20 &
|
||||||
|
gnommo render proj.json seg3.mp4 --slides S20: &
|
||||||
|
wait
|
||||||
|
ffmpeg -f concat -i segments.txt -c copy final.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Re-rendering
|
||||||
|
Track which slides changed and only re-render affected segments.
|
||||||
|
|
||||||
|
### Preview Mode
|
||||||
|
Quick low-quality render of specific section for review.
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Virtual Camera Effects
|
||||||
|
|
||||||
|
Ideas for "stuff happening" to keep viewers engaged in edutainment videos.
|
||||||
|
These effects are triggered by markers in the manuscript, just like slides.
|
||||||
|
|
||||||
|
## Zoom Effects
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `[Zoom1]` | Zoom to 110% - subtle emphasis |
|
||||||
|
| `[Zoom2]` | Zoom to 125% - moderate emphasis |
|
||||||
|
| `[Zoom3]` | Zoom to 150% - strong emphasis |
|
||||||
|
| `[Zoom0]` | Return to 100% (default) |
|
||||||
|
| `[ZoomPunch]` | Quick zoom in + out (single beat emphasis) |
|
||||||
|
|
||||||
|
**Use case:** Rapid `[Zoom1][Zoom2][Zoom3]` for comedic/dramatic triple emphasis.
|
||||||
|
|
||||||
|
## Tilt/Rotation Effects
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `[TiltLeft]` | Rotate -15 degrees |
|
||||||
|
| `[TiltRight]` | Rotate +15 degrees |
|
||||||
|
| `[NoTilt]` | Return to 0 degrees |
|
||||||
|
| `[TiltShake]` | Quick left-right shake (confusion/emphasis) |
|
||||||
|
|
||||||
|
**Use case:** Tilt when saying something "off" or wrong, return to flat for correction.
|
||||||
|
|
||||||
|
## Pan/Position Effects
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `[PanLeft]` | Shift frame left (subject moves right) |
|
||||||
|
| `[PanRight]` | Shift frame right (subject moves left) |
|
||||||
|
| `[PanUp]` | Shift frame up |
|
||||||
|
| `[PanDown]` | Shift frame down |
|
||||||
|
| `[PanCenter]` | Return to center |
|
||||||
|
|
||||||
|
**Use case:** Pan to make room for a slide appearing on one side.
|
||||||
|
|
||||||
|
## Shake/Movement Effects
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `[Shake]` | Brief screen shake (impact, surprise) |
|
||||||
|
| `[ShakeHard]` | Intense shake (explosion, error) |
|
||||||
|
| `[Wobble]` | Gentle continuous wobble |
|
||||||
|
| `[NoWobble]` | Stop wobble |
|
||||||
|
|
||||||
|
**Use case:** Shake on "WRONG!" or when something crashes/fails.
|
||||||
|
|
||||||
|
## Speed/Rhythm Effects
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `[Beat]` | Single visual pulse (scale bump) |
|
||||||
|
| `[BeatStart]` | Start pulsing to rhythm |
|
||||||
|
| `[BeatStop]` | Stop pulsing |
|
||||||
|
|
||||||
|
**Use case:** Rhythmic emphasis during lists or key points.
|
||||||
|
|
||||||
|
## Transition Effects
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `[Flash]` | Quick white flash |
|
||||||
|
| `[Blackout]` | Brief black frame |
|
||||||
|
| `[Glitch]` | Digital glitch effect |
|
||||||
|
|
||||||
|
**Use case:** Transition between topics or for "record scratch" moments.
|
||||||
|
|
||||||
|
## Picture-in-Picture Variations
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `[PipGrow]` | Enlarge talking head cutout |
|
||||||
|
| `[PipShrink]` | Shrink talking head cutout |
|
||||||
|
| `[PipHide]` | Temporarily hide talking head |
|
||||||
|
| `[PipShow]` | Restore talking head |
|
||||||
|
| `[PipMove:corner]` | Move pip to different corner |
|
||||||
|
|
||||||
|
**Use case:** Shrink self when showing important diagram, grow when making personal point.
|
||||||
|
|
||||||
|
## Combination Presets
|
||||||
|
|
||||||
|
| Marker | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `[Emphasis]` | Zoom2 + slight tilt (general emphasis) |
|
||||||
|
| `[Surprise]` | Quick zoom + shake |
|
||||||
|
| `[Sarcasm]` | Slow zoom + tilt |
|
||||||
|
| `[Reset]` | Return all effects to default |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture: The Camera Abstraction
|
||||||
|
|
||||||
|
### The Core Insight
|
||||||
|
|
||||||
|
All visual elements (slides, cutouts, talking head, background) exist in a **scene**.
|
||||||
|
The **camera** views the scene. When the camera zooms, tilts, or pans - everything
|
||||||
|
moves together, just like a real camera filming a physical set.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ SCENE │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Background Layer │ │
|
||||||
|
│ │ ┌─────────────┐ │ │
|
||||||
|
│ │ │ Talking Head│ ┌──────────────────┐ │ │
|
||||||
|
│ │ │ (cutout) │ │ Slide │ │ │
|
||||||
|
│ │ └─────────────┘ │ (from .png) │ │ │
|
||||||
|
│ │ └──────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ CAMERA │
|
||||||
|
│ zoom: 1.25 │
|
||||||
|
│ tilt: -15° │
|
||||||
|
│ pan: 0, 0 │
|
||||||
|
└─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Final Output │
|
||||||
|
│ (1920x1080) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Matters
|
||||||
|
|
||||||
|
**Keynote slides are designed for a specific frame.** If you create a slide with
|
||||||
|
an arrow pointing at where the talking head cutout will be, that spatial
|
||||||
|
relationship must be preserved when the camera zooms or tilts.
|
||||||
|
|
||||||
|
If we zoomed only the background and not the slides, the arrow would point to
|
||||||
|
the wrong place. The camera abstraction ensures everything transforms together.
|
||||||
|
|
||||||
|
### Camera Properties
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class CameraState:
|
||||||
|
zoom: float = 1.0 # 1.0 = 100%, 1.25 = 125%
|
||||||
|
rotation: float = 0.0 # degrees, positive = clockwise
|
||||||
|
pan_x: float = 0.0 # -1.0 to 1.0, percentage of frame
|
||||||
|
pan_y: float = 0.0 # -1.0 to 1.0, percentage of frame
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CameraKeyframe:
|
||||||
|
time: float # timestamp in seconds
|
||||||
|
state: CameraState
|
||||||
|
easing: str = "linear" # linear, ease-in, ease-out, ease-in-out
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rendering Pipeline (Updated)
|
||||||
|
|
||||||
|
```
|
||||||
|
Current Pipeline:
|
||||||
|
Parse → Validate → Transform → Render
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
build_filter_complex()
|
||||||
|
│
|
||||||
|
[bg] → overlays → [vout]
|
||||||
|
|
||||||
|
New Pipeline:
|
||||||
|
Parse → Validate → Transform → Render
|
||||||
|
│
|
||||||
|
Extract camera
|
||||||
|
keyframes from
|
||||||
|
markers
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
build_filter_complex()
|
||||||
|
│
|
||||||
|
[bg] → overlays → [scene]
|
||||||
|
│
|
||||||
|
apply_camera_transform()
|
||||||
|
│
|
||||||
|
[scene] → zoom/rotate/pan → [vout]
|
||||||
|
```
|
||||||
|
|
||||||
|
### FFmpeg Implementation
|
||||||
|
|
||||||
|
The camera transform is a **final filter stage** applied to the composed scene:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Compose scene (existing code)
|
||||||
|
[0:v]scale=1920:1080[bg];
|
||||||
|
[bg][slide1]overlay=...[s1];
|
||||||
|
[s1][talkinghead]overlay=...[scene];
|
||||||
|
|
||||||
|
# Camera transform (new)
|
||||||
|
[scene]scale=iw*{zoom}:ih*{zoom},
|
||||||
|
rotate={rotation}*PI/180:fillcolor=black,
|
||||||
|
crop=1920:1080:(iw-1920)/2:(ih-1080)/2[vout]
|
||||||
|
```
|
||||||
|
|
||||||
|
For smooth animated zoom (using expressions):
|
||||||
|
```
|
||||||
|
[scene]zoompan=z='if(between(t,5,8), 1+0.25*(t-5)/3, 1)':
|
||||||
|
x='iw/2-(iw/zoom/2)':
|
||||||
|
y='ih/2-(ih/zoom/2)':
|
||||||
|
d=1:s=1920x1080:fps=30[vout]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Camera Events in Timeline
|
||||||
|
|
||||||
|
New model for camera changes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class CameraEvent:
|
||||||
|
time: float
|
||||||
|
target_state: CameraState
|
||||||
|
duration: float = 0.0 # 0 = instant snap
|
||||||
|
easing: str = "ease-out"
|
||||||
|
```
|
||||||
|
|
||||||
|
Markers map to camera events:
|
||||||
|
- `[Zoom2]` → `CameraEvent(time=t, target_state=CameraState(zoom=1.25), duration=0.2)`
|
||||||
|
- `[TiltLeft]` → `CameraEvent(time=t, target_state=CameraState(rotation=-15), duration=0.3)`
|
||||||
|
- `[Reset]` → `CameraEvent(time=t, target_state=CameraState(), duration=0.2)`
|
||||||
|
|
||||||
|
### Considerations
|
||||||
|
|
||||||
|
1. **Overscan**: When zoomed in, we're cropping. The scene must be rendered
|
||||||
|
larger than output (e.g., 2x) to have room for zoom without quality loss.
|
||||||
|
|
||||||
|
2. **Rotation center**: Rotate around frame center, not corner.
|
||||||
|
|
||||||
|
3. **State accumulation**: `[Zoom2]` then `[TiltLeft]` means zoom AND tilt
|
||||||
|
are both active. `[Reset]` clears all.
|
||||||
|
|
||||||
|
4. **Interaction with cutouts**: Cutout positions are in scene-space, so they
|
||||||
|
transform naturally with the camera. No special handling needed.
|
||||||
|
|
||||||
|
5. **Slides stay synced**: Keynote exports are positioned for the base frame.
|
||||||
|
Camera zoom/tilt transforms them identically to everything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Camera Data Model ✓
|
||||||
|
- [x] Add `CameraState` and `CameraEvent` to models.py
|
||||||
|
- [x] Add camera effect markers to transformer.py
|
||||||
|
- [x] Generate camera keyframes from markers
|
||||||
|
|
||||||
|
### Phase 2: Render Pipeline ✓
|
||||||
|
- [x] Modify renderer to compose to `[scene]` instead of `[vout]`
|
||||||
|
- [x] Add camera transform stage after composition
|
||||||
|
- [ ] Handle overscan (render larger, crop to output) - deferred, upsampling OK for now
|
||||||
|
|
||||||
|
### Phase 3: Smooth Animation (partial)
|
||||||
|
- [x] Support animated transitions between keyframes (linear interpolation)
|
||||||
|
- [ ] Implement easing functions as FFmpeg expressions (ease-in, ease-out)
|
||||||
|
- [ ] Test with rapid zoom sequences
|
||||||
|
|
||||||
|
### Phase 4: Effect Presets ✓
|
||||||
|
- [x] Define presets (Zoom0/1/2/3, TiltLeft/Right/NoTilt, Pan*, Reset)
|
||||||
|
- [x] Presets defined in `CAMERA_PRESETS` dict in models.py
|
||||||
|
- [ ] Support custom parameterized markers `[Zoom:1.35]` - future enhancement
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"reference": "Gnommo Documentation - https://github.com/example/gnommo",
|
||||||
|
"context": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reference": "FFmpeg Documentation - https://ffmpeg.org/documentation.html",
|
||||||
|
"context": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
+37
-3
@@ -1,5 +1,39 @@
|
|||||||
Welcome to GnommoEditor, a code-first video editing system. [S1]
|
[S1]
|
||||||
|
This is the first slide. It appears immediately.
|
||||||
|
|
||||||
In this example, we demonstrate how slides appear at specific timestamps based on markers in the transcript. [S2]
|
[S2]
|
||||||
|
However, this is the second slide. It should appear 1 second prior to when I say “however”
|
||||||
|
|
||||||
And that's the end of our demo.
|
[S3]
|
||||||
|
[video:KnightRotating]
|
||||||
|
This is me talking alongside a video. The video is constrained within the red square. Notice how the video stops immediately when we make the transition to the next slide.
|
||||||
|
|
||||||
|
[S4]
|
||||||
|
I will continue to talk without pause, but in the finished recording - there will be a pause before the narration continues. Now a video will play that pauses the narration
|
||||||
|
|
||||||
|
[S5]
|
||||||
|
[video:gnommologo]
|
||||||
|
|
||||||
|
Notice how my voice continues after the video finished
|
||||||
|
|
||||||
|
[S6]
|
||||||
|
|
||||||
|
[S7]
|
||||||
|
This is the first slide. It appears immediately.
|
||||||
|
|
||||||
|
[S8]
|
||||||
|
However, this is the second slide. It should appear 1 second prior to when I say “however”
|
||||||
|
|
||||||
|
[S9]
|
||||||
|
[video:KnightRotating]
|
||||||
|
This is me talking alongside a video. The video is constrained within the red square. Notice how the video stops immediately when we make the transition to the next slide.
|
||||||
|
|
||||||
|
[S10]
|
||||||
|
I will continue to talk without pause, but in the finished recording - there will be a pause before the narration continues. Now a video will play that pauses the narration
|
||||||
|
|
||||||
|
[S11]
|
||||||
|
[video:gnommologo]
|
||||||
|
|
||||||
|
Notice how my voice continues after the video finished
|
||||||
|
|
||||||
|
[S12]
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"talking_head_S1": {
|
||||||
|
"source_file": "talking_head_S1.mov",
|
||||||
|
"output_file": "talking_head_S1_processed.mov",
|
||||||
|
"cutout": "talkinghead",
|
||||||
|
"always_visible": true,
|
||||||
|
"filter": "talkinghead"
|
||||||
|
},
|
||||||
|
"talking_head_S3": {
|
||||||
|
"source_file": "talking_head_S3.mov",
|
||||||
|
"output_file": "talking_head_S3_processed.mov",
|
||||||
|
"cutout": "talkinghead",
|
||||||
|
"always_visible": true,
|
||||||
|
"filter": "talkinghead"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"S1": {
|
||||||
|
"image": "example.001.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S2": {
|
||||||
|
"image": "example.002.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S3": {
|
||||||
|
"image": "example.003.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S4": {
|
||||||
|
"image": "example.004.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S5": {
|
||||||
|
"image": "example.005.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S6": {
|
||||||
|
"image": "example.006.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S7": {
|
||||||
|
"image": "example.007.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S8": {
|
||||||
|
"image": "example.008.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S9": {
|
||||||
|
"image": "example.009.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S10": {
|
||||||
|
"image": "example.010.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S11": {
|
||||||
|
"image": "example.011.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
},
|
||||||
|
"S12": {
|
||||||
|
"image": "example.012.png",
|
||||||
|
"type": "fullscreen"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
file '/Users/jenstandstad/Projects/gnommo/example/media/videos/intermediate/talking_head_batch0.mov'
|
||||||
|
file '/Users/jenstandstad/Projects/gnommo/example/media/videos/intermediate/segments/segment_0002.mov'
|
||||||
@@ -0,0 +1,992 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 10.739999999999997,
|
||||||
|
"end": 11.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 11.44,
|
||||||
|
"end": 11.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 11.64,
|
||||||
|
"end": 11.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 11.82,
|
||||||
|
"end": 12.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 12.04,
|
||||||
|
"end": 12.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 12.92,
|
||||||
|
"end": 13.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appears",
|
||||||
|
"start": 13.34,
|
||||||
|
"end": 13.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediate.",
|
||||||
|
"start": 13.7,
|
||||||
|
"end": 14.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "However,",
|
||||||
|
"start": 15.36,
|
||||||
|
"end": 16.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "this",
|
||||||
|
"start": 16.38,
|
||||||
|
"end": 16.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 16.48,
|
||||||
|
"end": 16.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 16.62,
|
||||||
|
"end": 16.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 16.8,
|
||||||
|
"end": 17.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 17.08,
|
||||||
|
"end": 17.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 17.78,
|
||||||
|
"end": 18.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "should",
|
||||||
|
"start": 18.02,
|
||||||
|
"end": 18.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appear",
|
||||||
|
"start": 18.24,
|
||||||
|
"end": 18.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "one",
|
||||||
|
"start": 18.56,
|
||||||
|
"end": 19.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 19.02,
|
||||||
|
"end": 19.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prior",
|
||||||
|
"start": 19.5,
|
||||||
|
"end": 19.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 19.92,
|
||||||
|
"end": 20.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 20.16,
|
||||||
|
"end": 20.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "word",
|
||||||
|
"start": 20.26,
|
||||||
|
"end": 20.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 20.54,
|
||||||
|
"end": 21.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 21.24,
|
||||||
|
"end": 21.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "say",
|
||||||
|
"start": 21.32,
|
||||||
|
"end": 21.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "whoever",
|
||||||
|
"start": 21.5,
|
||||||
|
"end": 21.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 21.86,
|
||||||
|
"end": 22.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "time.",
|
||||||
|
"start": 22.44,
|
||||||
|
"end": 22.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 24.3,
|
||||||
|
"end": 25.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 25.0,
|
||||||
|
"end": 25.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "me",
|
||||||
|
"start": 25.14,
|
||||||
|
"end": 25.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "taking,",
|
||||||
|
"start": 25.38,
|
||||||
|
"end": 25.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talking",
|
||||||
|
"start": 26.14,
|
||||||
|
"end": 27.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "alongside",
|
||||||
|
"start": 27.18,
|
||||||
|
"end": 27.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 27.66,
|
||||||
|
"end": 27.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video.",
|
||||||
|
"start": 27.92,
|
||||||
|
"end": 28.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "The",
|
||||||
|
"start": 28.68,
|
||||||
|
"end": 28.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 28.96,
|
||||||
|
"end": 29.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 29.2,
|
||||||
|
"end": 29.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "constrained",
|
||||||
|
"start": 29.4,
|
||||||
|
"end": 29.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "within",
|
||||||
|
"start": 29.82,
|
||||||
|
"end": 30.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 30.18,
|
||||||
|
"end": 30.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "red",
|
||||||
|
"start": 30.36,
|
||||||
|
"end": 30.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "square.",
|
||||||
|
"start": 30.52,
|
||||||
|
"end": 30.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 31.3,
|
||||||
|
"end": 31.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 31.48,
|
||||||
|
"end": 31.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 31.78,
|
||||||
|
"end": 31.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 31.96,
|
||||||
|
"end": 32.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stops",
|
||||||
|
"start": 32.16,
|
||||||
|
"end": 32.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediately",
|
||||||
|
"start": 32.48,
|
||||||
|
"end": 32.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 32.98,
|
||||||
|
"end": 33.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "we",
|
||||||
|
"start": 33.4,
|
||||||
|
"end": 33.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "make",
|
||||||
|
"start": 33.58,
|
||||||
|
"end": 33.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 33.76,
|
||||||
|
"end": 34.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "transition",
|
||||||
|
"start": 34.0,
|
||||||
|
"end": 34.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 34.42,
|
||||||
|
"end": 34.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 34.72,
|
||||||
|
"end": 34.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "next",
|
||||||
|
"start": 34.84,
|
||||||
|
"end": 35.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 35.06,
|
||||||
|
"end": 35.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 37.2,
|
||||||
|
"end": 37.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 37.76,
|
||||||
|
"end": 37.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continue",
|
||||||
|
"start": 37.82,
|
||||||
|
"end": 38.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 38.12,
|
||||||
|
"end": 38.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talk",
|
||||||
|
"start": 38.34,
|
||||||
|
"end": 38.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "without",
|
||||||
|
"start": 38.58,
|
||||||
|
"end": 38.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause,",
|
||||||
|
"start": 38.92,
|
||||||
|
"end": 39.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "but",
|
||||||
|
"start": 39.5,
|
||||||
|
"end": 39.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "in",
|
||||||
|
"start": 39.6,
|
||||||
|
"end": 39.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 39.72,
|
||||||
|
"end": 39.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished",
|
||||||
|
"start": 39.8,
|
||||||
|
"end": 40.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recording",
|
||||||
|
"start": 40.0,
|
||||||
|
"end": 40.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "there",
|
||||||
|
"start": 40.48,
|
||||||
|
"end": 41.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 41.22,
|
||||||
|
"end": 41.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "be",
|
||||||
|
"start": 41.38,
|
||||||
|
"end": 41.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 41.58,
|
||||||
|
"end": 41.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause",
|
||||||
|
"start": 41.68,
|
||||||
|
"end": 41.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "before",
|
||||||
|
"start": 41.96,
|
||||||
|
"end": 42.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 42.32,
|
||||||
|
"end": 42.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration",
|
||||||
|
"start": 42.52,
|
||||||
|
"end": 43.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues.",
|
||||||
|
"start": 43.06,
|
||||||
|
"end": 43.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Now",
|
||||||
|
"start": 44.44,
|
||||||
|
"end": 44.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 44.56,
|
||||||
|
"end": 44.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 44.7,
|
||||||
|
"end": 44.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 44.94,
|
||||||
|
"end": 45.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "play",
|
||||||
|
"start": 45.12,
|
||||||
|
"end": 45.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "that",
|
||||||
|
"start": 45.4,
|
||||||
|
"end": 45.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pauses",
|
||||||
|
"start": 45.8,
|
||||||
|
"end": 46.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 46.52,
|
||||||
|
"end": 46.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration.",
|
||||||
|
"start": 46.8,
|
||||||
|
"end": 47.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 48.66,
|
||||||
|
"end": 49.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 49.22,
|
||||||
|
"end": 49.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "my",
|
||||||
|
"start": 49.44,
|
||||||
|
"end": 49.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "voice",
|
||||||
|
"start": 49.6,
|
||||||
|
"end": 49.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues",
|
||||||
|
"start": 49.84,
|
||||||
|
"end": 50.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "after",
|
||||||
|
"start": 50.38,
|
||||||
|
"end": 50.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 50.88,
|
||||||
|
"end": 51.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 51.04,
|
||||||
|
"end": 51.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished.",
|
||||||
|
"start": 51.28,
|
||||||
|
"end": 51.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 65.46000000000001,
|
||||||
|
"end": 66.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 66.14,
|
||||||
|
"end": 66.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 66.34,
|
||||||
|
"end": 66.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 66.52,
|
||||||
|
"end": 66.74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 66.74,
|
||||||
|
"end": 67.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 67.68,
|
||||||
|
"end": 68.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appears",
|
||||||
|
"start": 68.02,
|
||||||
|
"end": 68.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediate.",
|
||||||
|
"start": 68.38,
|
||||||
|
"end": 68.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "However,",
|
||||||
|
"start": 70.28,
|
||||||
|
"end": 70.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "this",
|
||||||
|
"start": 71.1,
|
||||||
|
"end": 71.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 71.18,
|
||||||
|
"end": 71.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 71.32,
|
||||||
|
"end": 71.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 71.48,
|
||||||
|
"end": 71.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 71.78,
|
||||||
|
"end": 72.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 72.4,
|
||||||
|
"end": 72.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "should",
|
||||||
|
"start": 72.7,
|
||||||
|
"end": 72.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appear",
|
||||||
|
"start": 72.94,
|
||||||
|
"end": 73.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "one",
|
||||||
|
"start": 73.26,
|
||||||
|
"end": 73.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 73.72,
|
||||||
|
"end": 74.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prior",
|
||||||
|
"start": 74.2,
|
||||||
|
"end": 74.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 74.62,
|
||||||
|
"end": 74.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 74.86,
|
||||||
|
"end": 74.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "word",
|
||||||
|
"start": 74.98,
|
||||||
|
"end": 75.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 75.24,
|
||||||
|
"end": 75.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 75.94,
|
||||||
|
"end": 76.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "say",
|
||||||
|
"start": 76.02,
|
||||||
|
"end": 76.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "whoever",
|
||||||
|
"start": 76.18,
|
||||||
|
"end": 76.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 76.56,
|
||||||
|
"end": 77.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "time.",
|
||||||
|
"start": 77.14,
|
||||||
|
"end": 77.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 79.36,
|
||||||
|
"end": 79.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 79.7,
|
||||||
|
"end": 79.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "me",
|
||||||
|
"start": 79.86,
|
||||||
|
"end": 80.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "taking,",
|
||||||
|
"start": 80.08,
|
||||||
|
"end": 80.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talking",
|
||||||
|
"start": 80.92,
|
||||||
|
"end": 81.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "alongside",
|
||||||
|
"start": 81.88,
|
||||||
|
"end": 82.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 82.36,
|
||||||
|
"end": 82.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video.",
|
||||||
|
"start": 82.62,
|
||||||
|
"end": 82.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "The",
|
||||||
|
"start": 83.48,
|
||||||
|
"end": 83.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 83.66,
|
||||||
|
"end": 83.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 83.92,
|
||||||
|
"end": 84.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "constrained",
|
||||||
|
"start": 84.1,
|
||||||
|
"end": 84.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "within",
|
||||||
|
"start": 84.54,
|
||||||
|
"end": 84.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 84.88,
|
||||||
|
"end": 85.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "red",
|
||||||
|
"start": 85.06,
|
||||||
|
"end": 85.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "square.",
|
||||||
|
"start": 85.22,
|
||||||
|
"end": 85.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 85.62,
|
||||||
|
"end": 86.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 86.18,
|
||||||
|
"end": 86.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 86.48,
|
||||||
|
"end": 86.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 86.66,
|
||||||
|
"end": 86.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stops",
|
||||||
|
"start": 86.86,
|
||||||
|
"end": 87.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediately",
|
||||||
|
"start": 87.2,
|
||||||
|
"end": 87.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 87.68,
|
||||||
|
"end": 88.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "we",
|
||||||
|
"start": 88.1,
|
||||||
|
"end": 88.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "make",
|
||||||
|
"start": 88.28,
|
||||||
|
"end": 88.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 88.46,
|
||||||
|
"end": 88.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "transition",
|
||||||
|
"start": 88.7,
|
||||||
|
"end": 89.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 89.12,
|
||||||
|
"end": 89.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 89.42,
|
||||||
|
"end": 89.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "next",
|
||||||
|
"start": 89.54,
|
||||||
|
"end": 89.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 89.76,
|
||||||
|
"end": 90.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 91.94,
|
||||||
|
"end": 92.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 92.46,
|
||||||
|
"end": 92.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continue",
|
||||||
|
"start": 92.52,
|
||||||
|
"end": 92.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 92.82,
|
||||||
|
"end": 93.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talk",
|
||||||
|
"start": 93.04,
|
||||||
|
"end": 93.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "without",
|
||||||
|
"start": 93.28,
|
||||||
|
"end": 93.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause,",
|
||||||
|
"start": 93.62,
|
||||||
|
"end": 93.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "but",
|
||||||
|
"start": 94.2,
|
||||||
|
"end": 94.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "in",
|
||||||
|
"start": 94.3,
|
||||||
|
"end": 94.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 94.42,
|
||||||
|
"end": 94.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished",
|
||||||
|
"start": 94.48,
|
||||||
|
"end": 94.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recording",
|
||||||
|
"start": 94.7,
|
||||||
|
"end": 95.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "there",
|
||||||
|
"start": 95.18,
|
||||||
|
"end": 95.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 95.92,
|
||||||
|
"end": 96.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "be",
|
||||||
|
"start": 96.08,
|
||||||
|
"end": 96.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 96.28,
|
||||||
|
"end": 96.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause",
|
||||||
|
"start": 96.38,
|
||||||
|
"end": 96.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "before",
|
||||||
|
"start": 96.64,
|
||||||
|
"end": 97.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 97.02,
|
||||||
|
"end": 97.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration",
|
||||||
|
"start": 97.22,
|
||||||
|
"end": 97.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues.",
|
||||||
|
"start": 97.76,
|
||||||
|
"end": 98.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Now",
|
||||||
|
"start": 99.06,
|
||||||
|
"end": 99.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 99.26,
|
||||||
|
"end": 99.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 99.4,
|
||||||
|
"end": 99.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 99.64,
|
||||||
|
"end": 99.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "play",
|
||||||
|
"start": 99.8,
|
||||||
|
"end": 100.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "that",
|
||||||
|
"start": 100.1,
|
||||||
|
"end": 100.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pauses",
|
||||||
|
"start": 100.5,
|
||||||
|
"end": 101.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 101.24,
|
||||||
|
"end": 101.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration.",
|
||||||
|
"start": 101.5,
|
||||||
|
"end": 101.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 103.18,
|
||||||
|
"end": 103.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 103.92,
|
||||||
|
"end": 104.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "my",
|
||||||
|
"start": 104.14,
|
||||||
|
"end": 104.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "voice",
|
||||||
|
"start": 104.32,
|
||||||
|
"end": 104.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues",
|
||||||
|
"start": 104.58,
|
||||||
|
"end": 105.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "after",
|
||||||
|
"start": 105.1,
|
||||||
|
"end": 105.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 105.58,
|
||||||
|
"end": 105.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 105.76,
|
||||||
|
"end": 105.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished.",
|
||||||
|
"start": 105.98,
|
||||||
|
"end": 106.48
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,992 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 10.739999999999997,
|
||||||
|
"end": 11.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 11.44,
|
||||||
|
"end": 11.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 11.64,
|
||||||
|
"end": 11.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 11.82,
|
||||||
|
"end": 12.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 12.04,
|
||||||
|
"end": 12.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 12.92,
|
||||||
|
"end": 13.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appears",
|
||||||
|
"start": 13.34,
|
||||||
|
"end": 13.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediate.",
|
||||||
|
"start": 13.7,
|
||||||
|
"end": 14.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "However,",
|
||||||
|
"start": 15.36,
|
||||||
|
"end": 16.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "this",
|
||||||
|
"start": 16.38,
|
||||||
|
"end": 16.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 16.48,
|
||||||
|
"end": 16.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 16.62,
|
||||||
|
"end": 16.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 16.8,
|
||||||
|
"end": 17.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 17.08,
|
||||||
|
"end": 17.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 17.78,
|
||||||
|
"end": 18.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "should",
|
||||||
|
"start": 18.02,
|
||||||
|
"end": 18.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appear",
|
||||||
|
"start": 18.24,
|
||||||
|
"end": 18.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "one",
|
||||||
|
"start": 18.56,
|
||||||
|
"end": 19.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 19.02,
|
||||||
|
"end": 19.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prior",
|
||||||
|
"start": 19.5,
|
||||||
|
"end": 19.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 19.92,
|
||||||
|
"end": 20.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 20.16,
|
||||||
|
"end": 20.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "word",
|
||||||
|
"start": 20.26,
|
||||||
|
"end": 20.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 20.54,
|
||||||
|
"end": 21.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 21.24,
|
||||||
|
"end": 21.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "say",
|
||||||
|
"start": 21.32,
|
||||||
|
"end": 21.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "whoever",
|
||||||
|
"start": 21.5,
|
||||||
|
"end": 21.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 21.86,
|
||||||
|
"end": 22.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "time.",
|
||||||
|
"start": 22.44,
|
||||||
|
"end": 22.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 24.3,
|
||||||
|
"end": 25.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 25.0,
|
||||||
|
"end": 25.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "me",
|
||||||
|
"start": 25.14,
|
||||||
|
"end": 25.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "taking,",
|
||||||
|
"start": 25.38,
|
||||||
|
"end": 25.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talking",
|
||||||
|
"start": 26.14,
|
||||||
|
"end": 27.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "alongside",
|
||||||
|
"start": 27.18,
|
||||||
|
"end": 27.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 27.66,
|
||||||
|
"end": 27.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video.",
|
||||||
|
"start": 27.92,
|
||||||
|
"end": 28.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "The",
|
||||||
|
"start": 28.68,
|
||||||
|
"end": 28.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 28.96,
|
||||||
|
"end": 29.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 29.2,
|
||||||
|
"end": 29.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "constrained",
|
||||||
|
"start": 29.4,
|
||||||
|
"end": 29.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "within",
|
||||||
|
"start": 29.82,
|
||||||
|
"end": 30.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 30.18,
|
||||||
|
"end": 30.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "red",
|
||||||
|
"start": 30.36,
|
||||||
|
"end": 30.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "square.",
|
||||||
|
"start": 30.52,
|
||||||
|
"end": 30.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 31.3,
|
||||||
|
"end": 31.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 31.48,
|
||||||
|
"end": 31.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 31.78,
|
||||||
|
"end": 31.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 31.96,
|
||||||
|
"end": 32.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stops",
|
||||||
|
"start": 32.16,
|
||||||
|
"end": 32.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediately",
|
||||||
|
"start": 32.48,
|
||||||
|
"end": 32.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 32.98,
|
||||||
|
"end": 33.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "we",
|
||||||
|
"start": 33.4,
|
||||||
|
"end": 33.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "make",
|
||||||
|
"start": 33.58,
|
||||||
|
"end": 33.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 33.76,
|
||||||
|
"end": 34.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "transition",
|
||||||
|
"start": 34.0,
|
||||||
|
"end": 34.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 34.42,
|
||||||
|
"end": 34.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 34.72,
|
||||||
|
"end": 34.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "next",
|
||||||
|
"start": 34.84,
|
||||||
|
"end": 35.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 35.06,
|
||||||
|
"end": 35.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 37.2,
|
||||||
|
"end": 37.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 37.76,
|
||||||
|
"end": 37.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continue",
|
||||||
|
"start": 37.82,
|
||||||
|
"end": 38.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 38.12,
|
||||||
|
"end": 38.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talk",
|
||||||
|
"start": 38.34,
|
||||||
|
"end": 38.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "without",
|
||||||
|
"start": 38.58,
|
||||||
|
"end": 38.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause,",
|
||||||
|
"start": 38.92,
|
||||||
|
"end": 39.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "but",
|
||||||
|
"start": 39.5,
|
||||||
|
"end": 39.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "in",
|
||||||
|
"start": 39.6,
|
||||||
|
"end": 39.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 39.72,
|
||||||
|
"end": 39.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished",
|
||||||
|
"start": 39.8,
|
||||||
|
"end": 40.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recording",
|
||||||
|
"start": 40.0,
|
||||||
|
"end": 40.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "there",
|
||||||
|
"start": 40.48,
|
||||||
|
"end": 41.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 41.22,
|
||||||
|
"end": 41.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "be",
|
||||||
|
"start": 41.38,
|
||||||
|
"end": 41.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 41.58,
|
||||||
|
"end": 41.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause",
|
||||||
|
"start": 41.68,
|
||||||
|
"end": 41.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "before",
|
||||||
|
"start": 41.96,
|
||||||
|
"end": 42.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 42.32,
|
||||||
|
"end": 42.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration",
|
||||||
|
"start": 42.52,
|
||||||
|
"end": 43.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues.",
|
||||||
|
"start": 43.06,
|
||||||
|
"end": 43.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Now",
|
||||||
|
"start": 44.44,
|
||||||
|
"end": 44.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 44.56,
|
||||||
|
"end": 44.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 44.7,
|
||||||
|
"end": 44.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 44.94,
|
||||||
|
"end": 45.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "play",
|
||||||
|
"start": 45.12,
|
||||||
|
"end": 45.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "that",
|
||||||
|
"start": 45.4,
|
||||||
|
"end": 45.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pauses",
|
||||||
|
"start": 45.8,
|
||||||
|
"end": 46.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 46.52,
|
||||||
|
"end": 46.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration.",
|
||||||
|
"start": 46.8,
|
||||||
|
"end": 47.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 48.66,
|
||||||
|
"end": 49.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 49.22,
|
||||||
|
"end": 49.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "my",
|
||||||
|
"start": 49.44,
|
||||||
|
"end": 49.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "voice",
|
||||||
|
"start": 49.6,
|
||||||
|
"end": 49.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues",
|
||||||
|
"start": 49.84,
|
||||||
|
"end": 50.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "after",
|
||||||
|
"start": 50.38,
|
||||||
|
"end": 50.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 50.88,
|
||||||
|
"end": 51.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 51.04,
|
||||||
|
"end": 51.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished.",
|
||||||
|
"start": 51.28,
|
||||||
|
"end": 51.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 65.46000000000001,
|
||||||
|
"end": 66.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 66.14,
|
||||||
|
"end": 66.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 66.34,
|
||||||
|
"end": 66.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 66.52,
|
||||||
|
"end": 66.74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 66.74,
|
||||||
|
"end": 67.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 67.68,
|
||||||
|
"end": 68.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appears",
|
||||||
|
"start": 68.02,
|
||||||
|
"end": 68.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediate.",
|
||||||
|
"start": 68.38,
|
||||||
|
"end": 68.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "However,",
|
||||||
|
"start": 70.28,
|
||||||
|
"end": 70.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "this",
|
||||||
|
"start": 71.1,
|
||||||
|
"end": 71.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 71.18,
|
||||||
|
"end": 71.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 71.32,
|
||||||
|
"end": 71.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 71.48,
|
||||||
|
"end": 71.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 71.78,
|
||||||
|
"end": 72.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 72.4,
|
||||||
|
"end": 72.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "should",
|
||||||
|
"start": 72.7,
|
||||||
|
"end": 72.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appear",
|
||||||
|
"start": 72.94,
|
||||||
|
"end": 73.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "one",
|
||||||
|
"start": 73.26,
|
||||||
|
"end": 73.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 73.72,
|
||||||
|
"end": 74.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prior",
|
||||||
|
"start": 74.2,
|
||||||
|
"end": 74.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 74.62,
|
||||||
|
"end": 74.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 74.86,
|
||||||
|
"end": 74.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "word",
|
||||||
|
"start": 74.98,
|
||||||
|
"end": 75.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 75.24,
|
||||||
|
"end": 75.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 75.94,
|
||||||
|
"end": 76.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "say",
|
||||||
|
"start": 76.02,
|
||||||
|
"end": 76.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "whoever",
|
||||||
|
"start": 76.18,
|
||||||
|
"end": 76.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 76.56,
|
||||||
|
"end": 77.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "time.",
|
||||||
|
"start": 77.14,
|
||||||
|
"end": 77.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 79.36,
|
||||||
|
"end": 79.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 79.7,
|
||||||
|
"end": 79.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "me",
|
||||||
|
"start": 79.86,
|
||||||
|
"end": 80.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "taking,",
|
||||||
|
"start": 80.08,
|
||||||
|
"end": 80.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talking",
|
||||||
|
"start": 80.92,
|
||||||
|
"end": 81.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "alongside",
|
||||||
|
"start": 81.88,
|
||||||
|
"end": 82.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 82.36,
|
||||||
|
"end": 82.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video.",
|
||||||
|
"start": 82.62,
|
||||||
|
"end": 82.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "The",
|
||||||
|
"start": 83.48,
|
||||||
|
"end": 83.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 83.66,
|
||||||
|
"end": 83.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 83.92,
|
||||||
|
"end": 84.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "constrained",
|
||||||
|
"start": 84.1,
|
||||||
|
"end": 84.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "within",
|
||||||
|
"start": 84.54,
|
||||||
|
"end": 84.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 84.88,
|
||||||
|
"end": 85.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "red",
|
||||||
|
"start": 85.06,
|
||||||
|
"end": 85.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "square.",
|
||||||
|
"start": 85.22,
|
||||||
|
"end": 85.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 85.62,
|
||||||
|
"end": 86.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 86.18,
|
||||||
|
"end": 86.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 86.48,
|
||||||
|
"end": 86.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 86.66,
|
||||||
|
"end": 86.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stops",
|
||||||
|
"start": 86.86,
|
||||||
|
"end": 87.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediately",
|
||||||
|
"start": 87.2,
|
||||||
|
"end": 87.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 87.68,
|
||||||
|
"end": 88.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "we",
|
||||||
|
"start": 88.1,
|
||||||
|
"end": 88.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "make",
|
||||||
|
"start": 88.28,
|
||||||
|
"end": 88.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 88.46,
|
||||||
|
"end": 88.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "transition",
|
||||||
|
"start": 88.7,
|
||||||
|
"end": 89.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 89.12,
|
||||||
|
"end": 89.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 89.42,
|
||||||
|
"end": 89.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "next",
|
||||||
|
"start": 89.54,
|
||||||
|
"end": 89.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 89.76,
|
||||||
|
"end": 90.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 91.94,
|
||||||
|
"end": 92.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 92.46,
|
||||||
|
"end": 92.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continue",
|
||||||
|
"start": 92.52,
|
||||||
|
"end": 92.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 92.82,
|
||||||
|
"end": 93.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talk",
|
||||||
|
"start": 93.04,
|
||||||
|
"end": 93.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "without",
|
||||||
|
"start": 93.28,
|
||||||
|
"end": 93.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause,",
|
||||||
|
"start": 93.62,
|
||||||
|
"end": 93.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "but",
|
||||||
|
"start": 94.2,
|
||||||
|
"end": 94.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "in",
|
||||||
|
"start": 94.3,
|
||||||
|
"end": 94.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 94.42,
|
||||||
|
"end": 94.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished",
|
||||||
|
"start": 94.48,
|
||||||
|
"end": 94.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recording",
|
||||||
|
"start": 94.7,
|
||||||
|
"end": 95.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "there",
|
||||||
|
"start": 95.18,
|
||||||
|
"end": 95.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 95.92,
|
||||||
|
"end": 96.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "be",
|
||||||
|
"start": 96.08,
|
||||||
|
"end": 96.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 96.28,
|
||||||
|
"end": 96.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause",
|
||||||
|
"start": 96.38,
|
||||||
|
"end": 96.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "before",
|
||||||
|
"start": 96.64,
|
||||||
|
"end": 97.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 97.02,
|
||||||
|
"end": 97.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration",
|
||||||
|
"start": 97.22,
|
||||||
|
"end": 97.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues.",
|
||||||
|
"start": 97.76,
|
||||||
|
"end": 98.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Now",
|
||||||
|
"start": 99.06,
|
||||||
|
"end": 99.26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 99.26,
|
||||||
|
"end": 99.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 99.4,
|
||||||
|
"end": 99.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 99.64,
|
||||||
|
"end": 99.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "play",
|
||||||
|
"start": 99.8,
|
||||||
|
"end": 100.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "that",
|
||||||
|
"start": 100.1,
|
||||||
|
"end": 100.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pauses",
|
||||||
|
"start": 100.5,
|
||||||
|
"end": 101.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 101.24,
|
||||||
|
"end": 101.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration.",
|
||||||
|
"start": 101.5,
|
||||||
|
"end": 101.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 103.18,
|
||||||
|
"end": 103.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 103.92,
|
||||||
|
"end": 104.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "my",
|
||||||
|
"start": 104.14,
|
||||||
|
"end": 104.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "voice",
|
||||||
|
"start": 104.32,
|
||||||
|
"end": 104.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues",
|
||||||
|
"start": 104.58,
|
||||||
|
"end": 105.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "after",
|
||||||
|
"start": 105.1,
|
||||||
|
"end": 105.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 105.58,
|
||||||
|
"end": 105.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 105.76,
|
||||||
|
"end": 105.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished.",
|
||||||
|
"start": 105.98,
|
||||||
|
"end": 106.48
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 10.72,
|
||||||
|
"end": 11.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 11.4,
|
||||||
|
"end": 11.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 11.6,
|
||||||
|
"end": 11.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 11.78,
|
||||||
|
"end": 11.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 11.98,
|
||||||
|
"end": 12.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 13.02,
|
||||||
|
"end": 13.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appears",
|
||||||
|
"start": 13.3,
|
||||||
|
"end": 13.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediately.",
|
||||||
|
"start": 13.66,
|
||||||
|
"end": 14.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "However,",
|
||||||
|
"start": 15.34,
|
||||||
|
"end": 16.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "this",
|
||||||
|
"start": 16.34,
|
||||||
|
"end": 16.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 16.46,
|
||||||
|
"end": 16.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 16.58,
|
||||||
|
"end": 16.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 16.76,
|
||||||
|
"end": 17.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 17.04,
|
||||||
|
"end": 17.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "It",
|
||||||
|
"start": 17.74,
|
||||||
|
"end": 17.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "should",
|
||||||
|
"start": 17.96,
|
||||||
|
"end": 18.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "appear",
|
||||||
|
"start": 18.2,
|
||||||
|
"end": 18.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "one",
|
||||||
|
"start": 18.54,
|
||||||
|
"end": 18.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "second",
|
||||||
|
"start": 18.98,
|
||||||
|
"end": 19.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "prior",
|
||||||
|
"start": 19.46,
|
||||||
|
"end": 19.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 19.88,
|
||||||
|
"end": 20.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 20.1,
|
||||||
|
"end": 20.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "word",
|
||||||
|
"start": 20.22,
|
||||||
|
"end": 20.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 20.52,
|
||||||
|
"end": 21.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "say",
|
||||||
|
"start": 21.14,
|
||||||
|
"end": 21.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "whoever",
|
||||||
|
"start": 21.42,
|
||||||
|
"end": 21.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 21.8,
|
||||||
|
"end": 22.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "first",
|
||||||
|
"start": 22.16,
|
||||||
|
"end": 22.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "time.",
|
||||||
|
"start": 22.4,
|
||||||
|
"end": 22.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "This",
|
||||||
|
"start": 24.28,
|
||||||
|
"end": 24.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 24.96,
|
||||||
|
"end": 25.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "me",
|
||||||
|
"start": 25.12,
|
||||||
|
"end": 25.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "taking,",
|
||||||
|
"start": 25.36,
|
||||||
|
"end": 25.74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talking",
|
||||||
|
"start": 26.12,
|
||||||
|
"end": 27.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "alongside",
|
||||||
|
"start": 27.12,
|
||||||
|
"end": 27.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 27.64,
|
||||||
|
"end": 27.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video.",
|
||||||
|
"start": 27.88,
|
||||||
|
"end": 28.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "The",
|
||||||
|
"start": 28.16,
|
||||||
|
"end": 28.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 28.92,
|
||||||
|
"end": 29.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "is",
|
||||||
|
"start": 29.18,
|
||||||
|
"end": 29.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "constrained",
|
||||||
|
"start": 29.36,
|
||||||
|
"end": 29.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "within",
|
||||||
|
"start": 29.76,
|
||||||
|
"end": 30.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 30.14,
|
||||||
|
"end": 30.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "red",
|
||||||
|
"start": 30.32,
|
||||||
|
"end": 30.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "square.",
|
||||||
|
"start": 30.48,
|
||||||
|
"end": 30.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 31.26,
|
||||||
|
"end": 31.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 31.44,
|
||||||
|
"end": 31.74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 31.74,
|
||||||
|
"end": 31.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 31.92,
|
||||||
|
"end": 32.14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "stops",
|
||||||
|
"start": 32.14,
|
||||||
|
"end": 32.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "immediately",
|
||||||
|
"start": 32.44,
|
||||||
|
"end": 32.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "when",
|
||||||
|
"start": 32.94,
|
||||||
|
"end": 33.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "we",
|
||||||
|
"start": 33.36,
|
||||||
|
"end": 33.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "make",
|
||||||
|
"start": 33.54,
|
||||||
|
"end": 33.74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 33.74,
|
||||||
|
"end": 33.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "transition",
|
||||||
|
"start": 33.94,
|
||||||
|
"end": 34.38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 34.38,
|
||||||
|
"end": 34.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 34.68,
|
||||||
|
"end": 34.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "next",
|
||||||
|
"start": 34.8,
|
||||||
|
"end": 35.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "slide.",
|
||||||
|
"start": 35.02,
|
||||||
|
"end": 35.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "I",
|
||||||
|
"start": 37.18,
|
||||||
|
"end": 37.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 37.72,
|
||||||
|
"end": 37.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continue",
|
||||||
|
"start": 37.78,
|
||||||
|
"end": 38.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "to",
|
||||||
|
"start": 38.08,
|
||||||
|
"end": 38.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "talk",
|
||||||
|
"start": 38.32,
|
||||||
|
"end": 38.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "without",
|
||||||
|
"start": 38.56,
|
||||||
|
"end": 38.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause,",
|
||||||
|
"start": 38.88,
|
||||||
|
"end": 39.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "but",
|
||||||
|
"start": 39.46,
|
||||||
|
"end": 39.56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "in",
|
||||||
|
"start": 39.56,
|
||||||
|
"end": 39.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 39.68,
|
||||||
|
"end": 39.74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished",
|
||||||
|
"start": 39.74,
|
||||||
|
"end": 39.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "recording",
|
||||||
|
"start": 39.98,
|
||||||
|
"end": 40.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "there",
|
||||||
|
"start": 40.46,
|
||||||
|
"end": 41.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 41.18,
|
||||||
|
"end": 41.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "be",
|
||||||
|
"start": 41.36,
|
||||||
|
"end": 41.54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 41.54,
|
||||||
|
"end": 41.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pause",
|
||||||
|
"start": 41.64,
|
||||||
|
"end": 41.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "before",
|
||||||
|
"start": 41.92,
|
||||||
|
"end": 42.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 42.28,
|
||||||
|
"end": 42.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration",
|
||||||
|
"start": 42.5,
|
||||||
|
"end": 43.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues.",
|
||||||
|
"start": 43.0,
|
||||||
|
"end": 43.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Now",
|
||||||
|
"start": 44.38,
|
||||||
|
"end": 44.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "a",
|
||||||
|
"start": 44.52,
|
||||||
|
"end": 44.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 44.68,
|
||||||
|
"end": 44.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "will",
|
||||||
|
"start": 44.9,
|
||||||
|
"end": 45.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "play",
|
||||||
|
"start": 45.08,
|
||||||
|
"end": 45.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "that",
|
||||||
|
"start": 45.36,
|
||||||
|
"end": 45.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "pauses",
|
||||||
|
"start": 45.76,
|
||||||
|
"end": 46.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 46.52,
|
||||||
|
"end": 46.76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "narration.",
|
||||||
|
"start": 46.76,
|
||||||
|
"end": 47.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Notice",
|
||||||
|
"start": 48.64,
|
||||||
|
"end": 49.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "how",
|
||||||
|
"start": 49.18,
|
||||||
|
"end": 49.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "my",
|
||||||
|
"start": 49.42,
|
||||||
|
"end": 49.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "voice",
|
||||||
|
"start": 49.58,
|
||||||
|
"end": 49.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "continues",
|
||||||
|
"start": 49.8,
|
||||||
|
"end": 50.36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "after",
|
||||||
|
"start": 50.36,
|
||||||
|
"end": 50.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "the",
|
||||||
|
"start": 50.84,
|
||||||
|
"end": 51.02
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "video",
|
||||||
|
"start": 51.02,
|
||||||
|
"end": 51.24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "finished.",
|
||||||
|
"start": 51.24,
|
||||||
|
"end": 51.76
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"talking_head_S1": {
|
||||||
|
"source_file": "talking_head_S1.mov",
|
||||||
|
"output_file": "talking_head_S1_processed.mov",
|
||||||
|
"cutout": "talkinghead",
|
||||||
|
"always_visible": true,
|
||||||
|
"filter": "talkinghead"
|
||||||
|
},
|
||||||
|
"talking_head_S3": {
|
||||||
|
"source_file": "talking_head_S3.mov",
|
||||||
|
"output_file": "talking_head_S3_processed.mov",
|
||||||
|
"cutout": "talkinghead",
|
||||||
|
"always_visible": true,
|
||||||
|
"filter": "talkinghead"
|
||||||
|
},
|
||||||
|
"KnightRotating": {
|
||||||
|
"description": "Knight model rotating in place",
|
||||||
|
"source_file": "KnightRotating.mp4",
|
||||||
|
"output_file": "KnightRotating.mp4",
|
||||||
|
"cutout": "square",
|
||||||
|
"filter": [],
|
||||||
|
"is_shared": true
|
||||||
|
},
|
||||||
|
"gnommologo": {
|
||||||
|
"source_file": "Logo.mov",
|
||||||
|
"is_shared": true,
|
||||||
|
"cutout": "fullscreen",
|
||||||
|
"pause_narration": 17,
|
||||||
|
"take": 25,
|
||||||
|
"skip": 0
|
||||||
|
},
|
||||||
|
"Zoomin_MontageZoom": {
|
||||||
|
"description": "Montage zoom",
|
||||||
|
"source_file": "MontageZoom.mp4",
|
||||||
|
"output_file": "MontageZoom.mp4",
|
||||||
|
"pause_narration": 5,
|
||||||
|
"cutout": "square",
|
||||||
|
"is_shared": true,
|
||||||
|
"filter": []
|
||||||
|
},
|
||||||
|
"narration_combined": {
|
||||||
|
"source_file": "narration_combined.mov",
|
||||||
|
"output_file": "narration_combined.mov",
|
||||||
|
"cutout": "square",
|
||||||
|
"filter": []
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
-7
@@ -1,11 +1,93 @@
|
|||||||
{
|
{
|
||||||
|
"id": "VideoExample",
|
||||||
|
"name": "Example",
|
||||||
|
"description": "In this video, I demonstrate the Gnommo video editing pipeline - a code-first approach to creating presenter-mode videos from Keynote presentations.",
|
||||||
|
"footer": "Subscribe for more tutorials!\nTwitter: @example",
|
||||||
"resolution": [1920, 1080],
|
"resolution": [1920, 1080],
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
"talkinghead": {
|
"gnommo_scratch": null,
|
||||||
"x": 50,
|
"defaultSlideType": "fullscreen",
|
||||||
"y": 600,
|
"keynote_file": "media/example.key",
|
||||||
"targetheight": 400
|
"transcript": "media/videos/talking_head.transcript.json",
|
||||||
},
|
"narration": "media/narration/narration.json",
|
||||||
"defaultSlideType": "square",
|
"background": "shared_assets/solarpunk.png",
|
||||||
"background_video": ""
|
"videos": "media/videos/videos.json",
|
||||||
|
"slides": "media/slides/Example/slides.json",
|
||||||
|
"audio": "media/audio/audio.json",
|
||||||
|
"default_filters": {
|
||||||
|
"talkinghead": [
|
||||||
|
{
|
||||||
|
"type": "audio_normalize",
|
||||||
|
"eq_bands": [
|
||||||
|
{"freq": 47, "gain": -15, "type": "lowshelf"},
|
||||||
|
{"freq": 107, "gain": -1.3, "q": 1.2},
|
||||||
|
{"freq": 597, "gain": -5.2, "q": 2},
|
||||||
|
{"freq": 11811, "gain": 2.8, "q": 1},
|
||||||
|
{"freq": 24000, "gain": 3.9, "type": "highshelf"}
|
||||||
|
],
|
||||||
|
"highpass": 0,
|
||||||
|
"room_eq": false,
|
||||||
|
"dereverb_model": "shared_assets/models/std.rnnn",
|
||||||
|
"dereverb_mix": 0.8,
|
||||||
|
"denoise": true,
|
||||||
|
"noise_floor": -25,
|
||||||
|
"gate": true,
|
||||||
|
"gate_threshold": -35,
|
||||||
|
"gate_range": -20,
|
||||||
|
"compress": true,
|
||||||
|
"threshold": -20,
|
||||||
|
"ratio": 3,
|
||||||
|
"attack": 12,
|
||||||
|
"release": 100,
|
||||||
|
"makeup": 2,
|
||||||
|
"normalize": true,
|
||||||
|
"target_lufs": -16,
|
||||||
|
"target_lra": 11,
|
||||||
|
"target_tp": -1.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "color_grade",
|
||||||
|
"saturation": 1.15,
|
||||||
|
"contrast": 1.05,
|
||||||
|
"bm": -0.10,
|
||||||
|
"rm": 0.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gnommokey",
|
||||||
|
"screen_color": [81, 137, 65],
|
||||||
|
"screen_gain": 175,
|
||||||
|
"screen_balance": 58,
|
||||||
|
"despill_bias": [217, 240, 255],
|
||||||
|
"despill_strength": 5.0,
|
||||||
|
"edge_erode": 1.0,
|
||||||
|
"clip_black": 0,
|
||||||
|
"clip_white": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "mask",
|
||||||
|
"left": 0.05,
|
||||||
|
"right": 0.1,
|
||||||
|
"top": 0.1,
|
||||||
|
"bottom": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cutouts": {
|
||||||
|
"talkinghead": {
|
||||||
|
"x": "-10%",
|
||||||
|
"y": "40%",
|
||||||
|
"height": "60%"
|
||||||
|
},
|
||||||
|
"square": {
|
||||||
|
"x": "45%",
|
||||||
|
"y": "3%",
|
||||||
|
"width": "53%",
|
||||||
|
"height": "94%"
|
||||||
|
},
|
||||||
|
"fullscreen": {
|
||||||
|
"x": "0%",
|
||||||
|
"y": "0%",
|
||||||
|
"height": "100%"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"S1": {
|
|
||||||
"image": "S1.png",
|
|
||||||
"type": "square"
|
|
||||||
},
|
|
||||||
"S2": {
|
|
||||||
"image": "S2.png",
|
|
||||||
"type": "square"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
t,word
|
|
||||||
0.00,Hello
|
|
||||||
0.30,world
|
|
||||||
0.60,[S1]
|
|
||||||
1.50,Second
|
|
||||||
1.80,slide
|
|
||||||
2.00,[S2]
|
|
||||||
2.50,End
|
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"talking_head": {
|
|
||||||
"file": "media/talking_head.mp4",
|
|
||||||
"preprocess": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# GnommoEditor - Code-first video editing pipeline
|
# GnommoEditor - Code-first video editing pipeline
|
||||||
|
# This is a thin wrapper that activates the venv and runs the Python CLI.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage: gnommo -p <project> [action] [options]
|
||||||
# gnommo.sh -p <project> Render project
|
# Run with -h for full help.
|
||||||
# gnommo.sh -p <project> import Generate slides.json from image files
|
|
||||||
# gnommo.sh -p <project> validate Validate only
|
|
||||||
# gnommo.sh -p <project> preprocess Apply video preprocessing filters
|
|
||||||
# gnommo.sh -p <project> transcribe Transcribe video
|
|
||||||
# gnommo.sh -p <project> align Align markers to transcript
|
|
||||||
# gnommo.sh -p <project> all Full pipeline: transcribe → align → render
|
|
||||||
#
|
#
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
VENV_PYTHON="$SCRIPT_DIR/venv/bin/python"
|
VENV_PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||||
|
|
||||||
# Check for venv
|
# Check for venv
|
||||||
if [[ ! -f "$VENV_PYTHON" ]]; then
|
if [[ ! -f "$VENV_PYTHON" ]]; then
|
||||||
echo "Error: Virtual environment not found at $SCRIPT_DIR/venv"
|
echo "Error: Virtual environment not found at $SCRIPT_DIR/venv"
|
||||||
echo "Create it with: python -m venv venv && ./venv/bin/pip install openai-whisper"
|
echo "Create it with: python -m venv venv && ./venv/bin/pip install -e . openai-whisper"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Parse arguments
|
# Pass all arguments directly to the Python CLI
|
||||||
PROJECT=""
|
exec "$VENV_PYTHON" -m gnommo "$@"
|
||||||
COMMAND="render"
|
|
||||||
VERBOSE=""
|
|
||||||
FORCE=""
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: gnommo.sh -p <project> [command] [options]"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " render Render video (default)"
|
|
||||||
echo " import Generate slides.json from image files"
|
|
||||||
echo " validate Validate project only"
|
|
||||||
echo " preprocess Apply video preprocessing filters (chroma key, etc.)"
|
|
||||||
echo " transcribe Transcribe video audio"
|
|
||||||
echo " align Align manuscript to transcript"
|
|
||||||
echo " all Full pipeline: transcribe → align → render"
|
|
||||||
echo ""
|
|
||||||
echo "Options:"
|
|
||||||
echo " -p <dir> Project directory (required)"
|
|
||||||
echo " -v Verbose output"
|
|
||||||
echo " -f Force overwrite existing files"
|
|
||||||
echo " -h Show this help"
|
|
||||||
echo ""
|
|
||||||
echo "Examples:"
|
|
||||||
echo " gnommo.sh -p video1 # Render video1 project"
|
|
||||||
echo " gnommo.sh -p video1 import # Generate slides.json"
|
|
||||||
echo " gnommo.sh -p video1 import -f # Force overwrite slides.json"
|
|
||||||
echo " gnommo.sh -p video1 validate # Validate only"
|
|
||||||
echo " gnommo.sh -p video1 all # Full pipeline"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
-p|--project)
|
|
||||||
PROJECT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-v|--verbose)
|
|
||||||
VERBOSE="-v"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-f|--force)
|
|
||||||
FORCE="-f"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
import|validate|render|preprocess|transcribe|align|all)
|
|
||||||
COMMAND="$1"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown option: $1"
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Validate project argument
|
|
||||||
if [[ -z "$PROJECT" ]]; then
|
|
||||||
echo "Error: Project directory required (-p <project>)"
|
|
||||||
echo ""
|
|
||||||
usage
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -d "$PROJECT" ]]; then
|
|
||||||
echo "Error: Project directory not found: $PROJECT"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "$PROJECT/project.json" ]]; then
|
|
||||||
echo "Error: project.json not found in $PROJECT"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run commands using new CLI interface
|
|
||||||
run_gnommo() {
|
|
||||||
"$VENV_PYTHON" -m gnommo -p "$PROJECT" -a "$1" $VERBOSE
|
|
||||||
}
|
|
||||||
|
|
||||||
run_gnommo_import() {
|
|
||||||
"$VENV_PYTHON" -m gnommo -p "$PROJECT" -a validate -i $FORCE $VERBOSE
|
|
||||||
}
|
|
||||||
|
|
||||||
case $COMMAND in
|
|
||||||
import)
|
|
||||||
echo "=== Importing assets for $PROJECT ==="
|
|
||||||
run_gnommo_import
|
|
||||||
;;
|
|
||||||
|
|
||||||
validate)
|
|
||||||
echo "=== Validating $PROJECT ==="
|
|
||||||
run_gnommo validate
|
|
||||||
;;
|
|
||||||
|
|
||||||
transcribe)
|
|
||||||
echo "=== Transcribing $PROJECT ==="
|
|
||||||
run_gnommo transcribe
|
|
||||||
;;
|
|
||||||
|
|
||||||
align)
|
|
||||||
echo "=== Aligning $PROJECT ==="
|
|
||||||
run_gnommo align
|
|
||||||
;;
|
|
||||||
|
|
||||||
render)
|
|
||||||
echo "=== Rendering $PROJECT ==="
|
|
||||||
run_gnommo render
|
|
||||||
;;
|
|
||||||
|
|
||||||
preprocess)
|
|
||||||
echo "=== Preprocessing $PROJECT ==="
|
|
||||||
run_gnommo preprocess
|
|
||||||
;;
|
|
||||||
|
|
||||||
all)
|
|
||||||
echo "=== Full Pipeline: $PROJECT ==="
|
|
||||||
run_gnommo all
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
|
||||||
echo "Unknown command: $COMMAND"
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
"""Alignment stage: match manuscript markers to transcript timestamps."""
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .errors import GnommoError
|
|
||||||
from .transcriber import TranscribedWord
|
|
||||||
|
|
||||||
|
|
||||||
class AlignmentError(GnommoError):
|
|
||||||
"""Error during alignment."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MarkerAlignment:
|
|
||||||
"""A marker with its aligned timestamp."""
|
|
||||||
marker_id: str
|
|
||||||
timestamp: float
|
|
||||||
matched_phrase: str
|
|
||||||
confidence: float # 0-1, how confident the match is
|
|
||||||
|
|
||||||
|
|
||||||
def extract_marker_contexts(manuscript_text: str) -> list[tuple[str, str]]:
|
|
||||||
"""
|
|
||||||
Extract markers and the text immediately following them.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of (marker_id, following_text) tuples
|
|
||||||
"""
|
|
||||||
# Split by markers, keeping the markers
|
|
||||||
parts = re.split(r"\[([A-Za-z0-9_]+)\]", manuscript_text)
|
|
||||||
|
|
||||||
# parts will be: [text_before, marker1, text_after1, marker2, text_after2, ...]
|
|
||||||
contexts = []
|
|
||||||
|
|
||||||
for i in range(1, len(parts), 2):
|
|
||||||
marker_id = parts[i]
|
|
||||||
if i + 1 < len(parts):
|
|
||||||
following_text = parts[i + 1].strip()
|
|
||||||
# Get first sentence or first N words
|
|
||||||
following_text = _get_first_phrase(following_text)
|
|
||||||
contexts.append((marker_id, following_text))
|
|
||||||
|
|
||||||
return contexts
|
|
||||||
|
|
||||||
|
|
||||||
def _get_first_phrase(text: str, max_words: int = 10) -> str:
|
|
||||||
"""Extract first phrase (up to first sentence end or max_words)."""
|
|
||||||
# Clean up the text
|
|
||||||
text = text.replace("\n", " ").strip()
|
|
||||||
|
|
||||||
# Find first sentence boundary
|
|
||||||
match = re.search(r"[.!?]", text)
|
|
||||||
if match and match.start() < 200:
|
|
||||||
text = text[: match.start()]
|
|
||||||
|
|
||||||
# Limit to max_words
|
|
||||||
words = text.split()[:max_words]
|
|
||||||
return " ".join(words)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_text(text: str) -> str:
|
|
||||||
"""Normalize text for matching (lowercase, remove punctuation)."""
|
|
||||||
text = text.lower()
|
|
||||||
text = re.sub(r"[^\w\s]", "", text)
|
|
||||||
text = re.sub(r"\s+", " ", text)
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def find_phrase_in_transcript(
|
|
||||||
phrase: str,
|
|
||||||
transcript: list[TranscribedWord],
|
|
||||||
start_from: int = 0,
|
|
||||||
) -> tuple[int, float]:
|
|
||||||
"""
|
|
||||||
Find a phrase in the transcript and return the word index and timestamp.
|
|
||||||
|
|
||||||
Uses sliding window matching with normalization.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (word_index, timestamp) or (-1, 0.0) if not found
|
|
||||||
"""
|
|
||||||
phrase_normalized = normalize_text(phrase)
|
|
||||||
phrase_words = phrase_normalized.split()
|
|
||||||
|
|
||||||
if not phrase_words:
|
|
||||||
return -1, 0.0
|
|
||||||
|
|
||||||
# Try to find increasingly shorter prefixes
|
|
||||||
for length in range(len(phrase_words), 2, -1):
|
|
||||||
target = " ".join(phrase_words[:length])
|
|
||||||
|
|
||||||
# Sliding window through transcript
|
|
||||||
for i in range(start_from, len(transcript) - length + 1):
|
|
||||||
window_words = [normalize_text(transcript[j].word) for j in range(i, i + length)]
|
|
||||||
window_text = " ".join(window_words)
|
|
||||||
|
|
||||||
if target in window_text or window_text in target:
|
|
||||||
return i, transcript[i].start
|
|
||||||
|
|
||||||
# Fallback: try to find just the first few words
|
|
||||||
if len(phrase_words) >= 2:
|
|
||||||
target = " ".join(phrase_words[:3])
|
|
||||||
for i in range(start_from, len(transcript) - 2):
|
|
||||||
window_words = [normalize_text(transcript[j].word) for j in range(i, min(i + 5, len(transcript)))]
|
|
||||||
window_text = " ".join(window_words)
|
|
||||||
if phrase_words[0] in window_text and phrase_words[1] in window_text:
|
|
||||||
return i, transcript[i].start
|
|
||||||
|
|
||||||
return -1, 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def align_markers(
|
|
||||||
manuscript_text: str,
|
|
||||||
transcript: list[TranscribedWord],
|
|
||||||
offset_seconds: float = -1.0,
|
|
||||||
) -> list[MarkerAlignment]:
|
|
||||||
"""
|
|
||||||
Align manuscript markers to transcript timestamps.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
manuscript_text: Full manuscript text with [S1], [S2] etc.
|
|
||||||
transcript: Word-level transcript with timestamps
|
|
||||||
offset_seconds: Offset to apply to found timestamps (default -1.0)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of MarkerAlignment with timestamps
|
|
||||||
"""
|
|
||||||
contexts = extract_marker_contexts(manuscript_text)
|
|
||||||
alignments: list[MarkerAlignment] = []
|
|
||||||
|
|
||||||
last_index = 0
|
|
||||||
|
|
||||||
for marker_id, following_text in contexts:
|
|
||||||
idx, timestamp = find_phrase_in_transcript(
|
|
||||||
following_text, transcript, start_from=last_index
|
|
||||||
)
|
|
||||||
|
|
||||||
if idx >= 0:
|
|
||||||
# Apply offset (e.g., -1 second before the word)
|
|
||||||
adjusted_time = max(0.0, timestamp + offset_seconds)
|
|
||||||
alignments.append(MarkerAlignment(
|
|
||||||
marker_id=marker_id,
|
|
||||||
timestamp=adjusted_time,
|
|
||||||
matched_phrase=following_text[:50],
|
|
||||||
confidence=1.0,
|
|
||||||
))
|
|
||||||
last_index = idx
|
|
||||||
else:
|
|
||||||
# Could not find match - report but continue
|
|
||||||
alignments.append(MarkerAlignment(
|
|
||||||
marker_id=marker_id,
|
|
||||||
timestamp=-1.0, # Indicates not found
|
|
||||||
matched_phrase=following_text[:50],
|
|
||||||
confidence=0.0,
|
|
||||||
))
|
|
||||||
|
|
||||||
return alignments
|
|
||||||
|
|
||||||
|
|
||||||
def save_aligned_transcript(
|
|
||||||
alignments: list[MarkerAlignment],
|
|
||||||
transcript: list[TranscribedWord],
|
|
||||||
output_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Save aligned transcript as CSV compatible with gnommo's transcript.csv format.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
t,word
|
|
||||||
0.00,Hello
|
|
||||||
1.50,[S1]
|
|
||||||
1.51,This
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
# Build list of (timestamp, word) including markers
|
|
||||||
entries: list[tuple[float, str]] = []
|
|
||||||
|
|
||||||
# Add all words from transcript
|
|
||||||
for word in transcript:
|
|
||||||
entries.append((word.start, word.word))
|
|
||||||
|
|
||||||
# Add markers at their aligned positions
|
|
||||||
for alignment in alignments:
|
|
||||||
if alignment.timestamp >= 0:
|
|
||||||
entries.append((alignment.timestamp, f"[{alignment.marker_id}]"))
|
|
||||||
|
|
||||||
# Sort by timestamp
|
|
||||||
entries.sort(key=lambda x: x[0])
|
|
||||||
|
|
||||||
# Write CSV
|
|
||||||
with open(output_path, "w", encoding="utf-8", newline="") as f:
|
|
||||||
writer = csv.writer(f)
|
|
||||||
writer.writerow(["t", "word"])
|
|
||||||
for timestamp, word in entries:
|
|
||||||
writer.writerow([f"{timestamp:.2f}", word])
|
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
"""GnommoCache - External storage extension for large media files.
|
||||||
|
|
||||||
|
Provides transparent fallback to external storage when files are not found locally.
|
||||||
|
Configure via ~/.gnommo.conf:
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
path = /Volumes/GnommoDisk/gnommo
|
||||||
|
|
||||||
|
Files are looked up first locally, then in the cache at:
|
||||||
|
{cache_path}/{project_name}/{relative_path}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
_cache_config: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_cache_config() -> Optional[Path]:
|
||||||
|
"""Load gnommo.conf and return cache path if configured.
|
||||||
|
|
||||||
|
Configuration file location: ~/.gnommo.conf
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the cache root directory, or None if not configured.
|
||||||
|
"""
|
||||||
|
global _cache_config
|
||||||
|
if _cache_config is not None:
|
||||||
|
return _cache_config.get("path")
|
||||||
|
|
||||||
|
config_path = Path.home() / ".gnommo.conf"
|
||||||
|
if not config_path.exists():
|
||||||
|
_cache_config = {}
|
||||||
|
return None
|
||||||
|
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(config_path)
|
||||||
|
|
||||||
|
if config.has_option("cache", "path"):
|
||||||
|
cache_path = Path(config.get("cache", "path"))
|
||||||
|
_cache_config = {"path": cache_path}
|
||||||
|
return cache_path
|
||||||
|
|
||||||
|
_cache_config = {}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_with_cache(
|
||||||
|
local_path: Path,
|
||||||
|
project_path: Path,
|
||||||
|
) -> Tuple[Path, bool]:
|
||||||
|
"""
|
||||||
|
Resolve a file path with cache fallback (read-only).
|
||||||
|
|
||||||
|
Checks the local path first. If not found and cache is configured,
|
||||||
|
checks the cache directory which mirrors the project structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_path: The expected local path to the file
|
||||||
|
project_path: The project root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (resolved_path, is_cached) where is_cached=True if
|
||||||
|
the file was found in the external cache instead of locally.
|
||||||
|
"""
|
||||||
|
# Check local path first
|
||||||
|
if local_path.exists():
|
||||||
|
return local_path, False
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
cache_base = load_cache_config()
|
||||||
|
if cache_base is None:
|
||||||
|
return local_path, False # No cache configured
|
||||||
|
|
||||||
|
# Build cache path: {cache_base}/{project_name}/{relative_path}
|
||||||
|
try:
|
||||||
|
relative = local_path.relative_to(project_path)
|
||||||
|
cache_path = cache_base / project_path.name / relative
|
||||||
|
if cache_path.exists():
|
||||||
|
return cache_path, True
|
||||||
|
except ValueError:
|
||||||
|
pass # local_path is not relative to project_path
|
||||||
|
|
||||||
|
return local_path, False
|
||||||
|
|
||||||
|
|
||||||
|
def is_cache_configured() -> bool:
|
||||||
|
"""Check if cache is configured (for status messages)."""
|
||||||
|
return load_cache_config() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache_info() -> Optional[str]:
|
||||||
|
"""Get a human-readable cache configuration string."""
|
||||||
|
cache_path = load_cache_config()
|
||||||
|
if cache_path is None:
|
||||||
|
return None
|
||||||
|
if cache_path.exists():
|
||||||
|
return f"{cache_path} (connected)"
|
||||||
|
return f"{cache_path} (not connected)"
|
||||||
+3231
-165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
|||||||
|
"""Description generator: Create YouTube description with chapters, citations, and attributions."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Attribution,
|
||||||
|
Citation,
|
||||||
|
ProjectConfig,
|
||||||
|
SlideDefinition,
|
||||||
|
VideoSource,
|
||||||
|
)
|
||||||
|
from .transcriber import TranscribedWord
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChapterMarker:
|
||||||
|
"""A chapter marker with timestamp and title."""
|
||||||
|
|
||||||
|
slide_id: str
|
||||||
|
timestamp: float
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
def _format_timestamp(seconds: float) -> str:
|
||||||
|
"""Format seconds as M:SS or H:MM:SS for YouTube chapters."""
|
||||||
|
if seconds < 0:
|
||||||
|
return "0:00"
|
||||||
|
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||||
|
else:
|
||||||
|
return f"{minutes}:{secs:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_chapter_title(
|
||||||
|
manuscript_text: str, slide_id: str, slides: dict[str, SlideDefinition]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Extract a chapter title for a slide.
|
||||||
|
|
||||||
|
Tries to find meaningful title from:
|
||||||
|
1. First sentence/line after the slide marker
|
||||||
|
2. Falls back to slide ID if nothing useful found
|
||||||
|
"""
|
||||||
|
# Find the marker and text after it
|
||||||
|
pattern = rf"\[{re.escape(slide_id)}\]\s*(.+?)(?=\[S\d+\]|\[video:|\[narration:|\Z)"
|
||||||
|
match = re.search(pattern, manuscript_text, re.DOTALL)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
text = match.group(1).strip()
|
||||||
|
# Remove any other markers from the text
|
||||||
|
text = re.sub(r"\[[^\]]+\]", "", text).strip()
|
||||||
|
|
||||||
|
if text:
|
||||||
|
# Take first line or first sentence
|
||||||
|
first_line = text.split("\n")[0].strip()
|
||||||
|
# Truncate if too long
|
||||||
|
if len(first_line) > 50:
|
||||||
|
# Try to break at word boundary
|
||||||
|
truncated = first_line[:47]
|
||||||
|
last_space = truncated.rfind(" ")
|
||||||
|
if last_space > 30:
|
||||||
|
truncated = truncated[:last_space]
|
||||||
|
first_line = truncated + "..."
|
||||||
|
|
||||||
|
if first_line:
|
||||||
|
return first_line
|
||||||
|
|
||||||
|
# Fallback to slide number
|
||||||
|
slide_num = slide_id[1:] if slide_id.startswith("S") else slide_id
|
||||||
|
return f"Section {slide_num}"
|
||||||
|
|
||||||
|
|
||||||
|
def _align_citation_to_transcription(
|
||||||
|
citation: Citation,
|
||||||
|
transcription: list[TranscribedWord],
|
||||||
|
manuscript_text: str,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Align a citation to the transcription to find its timestamp.
|
||||||
|
|
||||||
|
Uses the context text following the citation to find the approximate
|
||||||
|
position in the audio.
|
||||||
|
|
||||||
|
Returns timestamp in seconds, or -1 if not found.
|
||||||
|
"""
|
||||||
|
if not transcription or not citation.context:
|
||||||
|
return -1.0
|
||||||
|
|
||||||
|
# Get more context from the manuscript for better matching
|
||||||
|
# Find the citation in the manuscript and get surrounding text
|
||||||
|
pattern = rf"\[cite:{re.escape(citation.reference)}\]\s*(.{{0,200}})"
|
||||||
|
match = re.search(pattern, manuscript_text, re.DOTALL)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return -1.0
|
||||||
|
|
||||||
|
context_text = match.group(1).strip()
|
||||||
|
# Clean up: remove markers, normalize whitespace
|
||||||
|
context_text = re.sub(r"\[[^\]]+\]", "", context_text)
|
||||||
|
context_text = " ".join(context_text.split())
|
||||||
|
|
||||||
|
if not context_text:
|
||||||
|
return -1.0
|
||||||
|
|
||||||
|
# Normalize for matching
|
||||||
|
context_words = context_text.lower().split()[:10] # Use up to 10 words
|
||||||
|
if not context_words:
|
||||||
|
return -1.0
|
||||||
|
|
||||||
|
# Build normalized transcription
|
||||||
|
trans_words = [(w.word.lower(), w.start) for w in transcription]
|
||||||
|
|
||||||
|
# Simple sliding window match
|
||||||
|
best_match_score = 0
|
||||||
|
best_match_time = -1.0
|
||||||
|
|
||||||
|
for i in range(len(trans_words) - len(context_words) + 1):
|
||||||
|
matches = 0
|
||||||
|
for j, ctx_word in enumerate(context_words):
|
||||||
|
trans_word = trans_words[i + j][0]
|
||||||
|
# Allow partial matches for longer words
|
||||||
|
if ctx_word == trans_word:
|
||||||
|
matches += 1
|
||||||
|
elif len(ctx_word) >= 4 and (
|
||||||
|
ctx_word in trans_word or trans_word in ctx_word
|
||||||
|
):
|
||||||
|
matches += 0.5
|
||||||
|
|
||||||
|
score = matches / len(context_words)
|
||||||
|
if score > best_match_score and score >= 0.5:
|
||||||
|
best_match_score = score
|
||||||
|
best_match_time = trans_words[i][1]
|
||||||
|
|
||||||
|
return best_match_time
|
||||||
|
|
||||||
|
|
||||||
|
def generate_chapters(
|
||||||
|
manuscript_text: str,
|
||||||
|
slides: dict[str, SlideDefinition],
|
||||||
|
marker_timings: list, # List of MarkerTiming from transformer
|
||||||
|
min_chapter_duration: float = 30.0,
|
||||||
|
) -> list[ChapterMarker]:
|
||||||
|
"""
|
||||||
|
Generate chapter markers from slide timings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manuscript_text: The manuscript content
|
||||||
|
slides: Slide definitions
|
||||||
|
marker_timings: Aligned marker timings from the transformer
|
||||||
|
min_chapter_duration: Minimum seconds between chapters (merges short ones)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ChapterMarker objects
|
||||||
|
"""
|
||||||
|
chapters = []
|
||||||
|
|
||||||
|
# Build timing lookup
|
||||||
|
timing_lookup = {
|
||||||
|
t.marker_id: t.timestamp for t in marker_timings if t.timestamp >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process slides in order
|
||||||
|
slide_ids = sorted(
|
||||||
|
[s for s in slides.keys() if s.startswith("S")],
|
||||||
|
key=lambda x: int(x[1:]) if x[1:].isdigit() else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for slide_id in slide_ids:
|
||||||
|
if slide_id not in timing_lookup:
|
||||||
|
continue
|
||||||
|
timestamp = timing_lookup[slide_id]
|
||||||
|
title = _extract_chapter_title(manuscript_text, slide_id, slides)
|
||||||
|
if chapters and (timestamp - chapters[-1].timestamp) < min_chapter_duration:
|
||||||
|
continue # Skip this chapter, previous one covers it
|
||||||
|
|
||||||
|
chapters.append(
|
||||||
|
ChapterMarker(
|
||||||
|
slide_id=slide_id,
|
||||||
|
timestamp=timestamp,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure first chapter starts at 0:00
|
||||||
|
if chapters and chapters[0].timestamp > 0:
|
||||||
|
chapters[0] = ChapterMarker(
|
||||||
|
slide_id=chapters[0].slide_id,
|
||||||
|
timestamp=0.0,
|
||||||
|
title=chapters[0].title,
|
||||||
|
)
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
def collect_attributions(
|
||||||
|
videos: dict[str, VideoSource],
|
||||||
|
video_events: list = None,
|
||||||
|
) -> list[tuple[str, Attribution]]:
|
||||||
|
"""
|
||||||
|
Collect all video attributions.
|
||||||
|
|
||||||
|
Returns list of (video_id, Attribution) tuples for videos that have attribution.
|
||||||
|
Only includes videos that are actually used in the project (via video_events)
|
||||||
|
or videos from shared assets that have attribution.
|
||||||
|
"""
|
||||||
|
attributions = []
|
||||||
|
|
||||||
|
# Get set of used video IDs from events
|
||||||
|
used_video_ids = set()
|
||||||
|
if video_events:
|
||||||
|
for event in video_events:
|
||||||
|
used_video_ids.add(event.video_id)
|
||||||
|
|
||||||
|
for video_id, video_source in videos.items():
|
||||||
|
if video_source.attribution:
|
||||||
|
# Include if used in video or if it's a shared asset
|
||||||
|
if video_id in used_video_ids or video_source.is_shared:
|
||||||
|
attributions.append((video_id, video_source.attribution))
|
||||||
|
|
||||||
|
return attributions
|
||||||
|
|
||||||
|
|
||||||
|
def generate_description(
|
||||||
|
config: ProjectConfig,
|
||||||
|
manuscript_text: str,
|
||||||
|
slides: dict[str, SlideDefinition],
|
||||||
|
videos: dict[str, VideoSource],
|
||||||
|
marker_timings: list,
|
||||||
|
transcription: list[TranscribedWord] = None,
|
||||||
|
video_events: list = None,
|
||||||
|
citations: list[Citation] = None,
|
||||||
|
include_chapters: bool = True,
|
||||||
|
include_citations: bool = True,
|
||||||
|
include_attributions: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate complete YouTube description.
|
||||||
|
|
||||||
|
Combines:
|
||||||
|
- Video description from project.json
|
||||||
|
- Chapter markers (optional)
|
||||||
|
- Citations from manuscript (optional)
|
||||||
|
- Stock footage attributions (optional)
|
||||||
|
- Footer from project.json
|
||||||
|
|
||||||
|
Returns formatted description text.
|
||||||
|
"""
|
||||||
|
sections = []
|
||||||
|
|
||||||
|
# 1. Video description
|
||||||
|
if config.description:
|
||||||
|
sections.append(config.description.strip())
|
||||||
|
|
||||||
|
# 2. Chapters
|
||||||
|
if include_chapters:
|
||||||
|
chapters = generate_chapters(manuscript_text, slides, marker_timings)
|
||||||
|
if chapters:
|
||||||
|
chapter_lines = ["CHAPTERS", ""]
|
||||||
|
for ch in chapters:
|
||||||
|
chapter_lines.append(f"{_format_timestamp(ch.timestamp)} {ch.title}")
|
||||||
|
sections.append("\n".join(chapter_lines))
|
||||||
|
|
||||||
|
# 3. Citations/References
|
||||||
|
if include_citations:
|
||||||
|
citations = citations or []
|
||||||
|
if citations and transcription:
|
||||||
|
# Align citations to get timestamps
|
||||||
|
for citation in citations:
|
||||||
|
citation.timestamp = _align_citation_to_transcription(
|
||||||
|
citation, transcription, manuscript_text
|
||||||
|
)
|
||||||
|
|
||||||
|
if citations:
|
||||||
|
ref_lines = ["REFERENCES", ""]
|
||||||
|
for citation in citations:
|
||||||
|
if citation.timestamp >= 0:
|
||||||
|
ref_lines.append(
|
||||||
|
f"{_format_timestamp(citation.timestamp)} - {citation.reference}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ref_lines.append(f"- {citation.reference}")
|
||||||
|
sections.append("\n".join(ref_lines))
|
||||||
|
|
||||||
|
# 4. Stock footage attributions
|
||||||
|
if include_attributions:
|
||||||
|
attributions = collect_attributions(videos, video_events)
|
||||||
|
if attributions:
|
||||||
|
attr_lines = ["STOCK FOOTAGE", ""]
|
||||||
|
for video_id, attr in attributions:
|
||||||
|
# Format: "Description by Creator via Source: URL"
|
||||||
|
line = f"{video_id.replace('_', ' ').title()} by {attr.creator} via {attr.source.title()}"
|
||||||
|
if attr.url:
|
||||||
|
line += f": {attr.url}"
|
||||||
|
attr_lines.append(line)
|
||||||
|
sections.append("\n".join(attr_lines))
|
||||||
|
|
||||||
|
# 5. Footer
|
||||||
|
if config.footer:
|
||||||
|
sections.append(config.footer.strip())
|
||||||
|
|
||||||
|
# Join sections with double newlines
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def write_description_file(
|
||||||
|
output_path: Path,
|
||||||
|
config: ProjectConfig,
|
||||||
|
manuscript_text: str,
|
||||||
|
slides: dict[str, SlideDefinition],
|
||||||
|
videos: dict[str, VideoSource],
|
||||||
|
marker_timings: list,
|
||||||
|
transcription: list[TranscribedWord] = None,
|
||||||
|
video_events: list = None,
|
||||||
|
citations: list[Citation] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate and write YouTube description to file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_path: Path to write description (e.g., out/description_youtube.txt)
|
||||||
|
config: Project configuration
|
||||||
|
manuscript_text: Manuscript content
|
||||||
|
slides: Slide definitions
|
||||||
|
videos: Video definitions
|
||||||
|
marker_timings: Aligned marker timings
|
||||||
|
transcription: Word-level transcription (optional, for citation timestamps)
|
||||||
|
video_events: Video events from render plan (optional, for attribution filtering)
|
||||||
|
citations: Pre-extracted citations (optional, loaded from citations.json)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The generated description text
|
||||||
|
"""
|
||||||
|
description = generate_description(
|
||||||
|
config=config,
|
||||||
|
manuscript_text=manuscript_text,
|
||||||
|
slides=slides,
|
||||||
|
videos=videos,
|
||||||
|
marker_timings=marker_timings,
|
||||||
|
transcription=transcription,
|
||||||
|
video_events=video_events,
|
||||||
|
citations=citations,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write description
|
||||||
|
output_path.write_text(description, encoding="utf-8")
|
||||||
|
|
||||||
|
return description
|
||||||
+15
-3
@@ -7,12 +7,14 @@ from typing import Optional
|
|||||||
|
|
||||||
class GnommoError(Exception):
|
class GnommoError(Exception):
|
||||||
"""Base exception for all GnommoEditor errors."""
|
"""Base exception for all GnommoEditor errors."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ValidationIssue:
|
class ValidationIssue:
|
||||||
"""A single validation issue with location context."""
|
"""A single validation issue with location context."""
|
||||||
|
|
||||||
message: str
|
message: str
|
||||||
file: Optional[Path] = None
|
file: Optional[Path] = None
|
||||||
line: Optional[int] = None
|
line: Optional[int] = None
|
||||||
@@ -30,7 +32,9 @@ class ValidationIssue:
|
|||||||
class ParseError(GnommoError):
|
class ParseError(GnommoError):
|
||||||
"""Error during parsing of input files."""
|
"""Error during parsing of input files."""
|
||||||
|
|
||||||
def __init__(self, message: str, file: Optional[Path] = None, line: Optional[int] = None):
|
def __init__(
|
||||||
|
self, message: str, file: Optional[Path] = None, line: Optional[int] = None
|
||||||
|
):
|
||||||
self.issue = ValidationIssue(message, file, line)
|
self.issue = ValidationIssue(message, file, line)
|
||||||
super().__init__(str(self.issue))
|
super().__init__(str(self.issue))
|
||||||
|
|
||||||
@@ -48,7 +52,9 @@ class ValidationError(GnommoError):
|
|||||||
class RenderError(GnommoError):
|
class RenderError(GnommoError):
|
||||||
"""Error during rendering stage."""
|
"""Error during rendering stage."""
|
||||||
|
|
||||||
def __init__(self, message: str, command: Optional[str] = None, stderr: Optional[str] = None):
|
def __init__(
|
||||||
|
self, message: str, command: Optional[str] = None, stderr: Optional[str] = None
|
||||||
|
):
|
||||||
self.command = command
|
self.command = command
|
||||||
self.stderr = stderr
|
self.stderr = stderr
|
||||||
full_message = message
|
full_message = message
|
||||||
@@ -62,7 +68,13 @@ class RenderError(GnommoError):
|
|||||||
class PreprocessError(GnommoError):
|
class PreprocessError(GnommoError):
|
||||||
"""Error during preprocessing stage."""
|
"""Error during preprocessing stage."""
|
||||||
|
|
||||||
def __init__(self, message: str, filter_type: Optional[str] = None, command: Optional[str] = None, stderr: Optional[str] = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
filter_type: Optional[str] = None,
|
||||||
|
command: Optional[str] = None,
|
||||||
|
stderr: Optional[str] = None,
|
||||||
|
):
|
||||||
self.filter_type = filter_type
|
self.filter_type = filter_type
|
||||||
self.command = command
|
self.command = command
|
||||||
self.stderr = stderr
|
self.stderr = stderr
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
ObjC.import('stdlib');
|
||||||
|
ObjC.import('Foundation');
|
||||||
|
|
||||||
|
function toAbsolutePath(p) {
|
||||||
|
// Expand ~ and make absolute relative to current working directory
|
||||||
|
var s = $(String(p)).stringByExpandingTildeInPath;
|
||||||
|
if (!s.isAbsolutePath) {
|
||||||
|
var cwd = $.NSFileManager.defaultManager.currentDirectoryPath;
|
||||||
|
s = cwd.stringByAppendingPathComponent(s);
|
||||||
|
}
|
||||||
|
return s.stringByStandardizingPath.js;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExists(p) {
|
||||||
|
return $.NSFileManager.defaultManager.fileExistsAtPath($(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotes(slide) {
|
||||||
|
try { return slide.presenterNotes(); } catch (e) {}
|
||||||
|
try { return slide.speakerNotes(); } catch (e) {}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(argv) {
|
||||||
|
if (!argv || argv.length < 1) throw new Error("Usage: script.js <file.key> [slides_output_dir]");
|
||||||
|
var abs = toAbsolutePath(argv[0]);
|
||||||
|
var slidesDir = argv.length >= 2 ? toAbsolutePath(argv[1]) : null;
|
||||||
|
|
||||||
|
if (!fileExists(abs)) {
|
||||||
|
throw new Error("File not found: " + abs);
|
||||||
|
}
|
||||||
|
|
||||||
|
var Keynote = Application('Keynote');
|
||||||
|
Keynote.activate();
|
||||||
|
|
||||||
|
// Keynote is happiest when given a Path() made from an absolute POSIX path
|
||||||
|
var doc = Keynote.open(Path(abs));
|
||||||
|
|
||||||
|
// Export slides as PNG if output directory is provided
|
||||||
|
if (slidesDir) {
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
var fm = $.NSFileManager.defaultManager;
|
||||||
|
if (!fm.fileExistsAtPath($(slidesDir))) {
|
||||||
|
fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(
|
||||||
|
$(slidesDir), true, $(), $()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export using AppleScript (more reliable than JXA for Keynote export)
|
||||||
|
var app = Application.currentApplication();
|
||||||
|
app.includeStandardAdditions = true;
|
||||||
|
|
||||||
|
// Build osascript command with proper escaping
|
||||||
|
// Using multiple -e flags to avoid quoting issues
|
||||||
|
var cmd = '/usr/bin/osascript' +
|
||||||
|
' -e \'tell application "Keynote"\'' +
|
||||||
|
' -e \'export front document to POSIX file "' + slidesDir + '" as slide images with properties {image format:PNG}\'' +
|
||||||
|
' -e \'end tell\'';
|
||||||
|
|
||||||
|
app.doShellScript(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
var slides = doc.slides();
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < slides.length; i++) {
|
||||||
|
out.push({
|
||||||
|
slide_index: i + 1,
|
||||||
|
notes: String(getNotes(slides[i]) || "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.close({ saving: 'no' });
|
||||||
|
return JSON.stringify(out, null, 2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Extract presenter notes from a Keynote .key file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python extract_keynote_notes.py path/to/deck.key --out notes.json
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- A .key file is a package (zip). The presenter notes live in an XML-ish file
|
||||||
|
typically called index.apxl inside the package.
|
||||||
|
- This script tries to be robust across minor format changes by searching for
|
||||||
|
likely note fields.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gnommo.parser import _read_json
|
||||||
|
|
||||||
|
|
||||||
|
def write_manuscript(data: Path, out_path: Path):
|
||||||
|
data = _read_json(data.read_text(encoding="utf-8"))
|
||||||
|
lines = []
|
||||||
|
i = 0
|
||||||
|
for item in data:
|
||||||
|
print(f"Writing notes for slide {i} to file")
|
||||||
|
idx = item.get("slide_index")
|
||||||
|
notes = (item.get("notes") or "").rstrip()
|
||||||
|
|
||||||
|
lines.append(f"[S{idx}]")
|
||||||
|
lines.append(notes)
|
||||||
|
lines.append("") # blank line between slides
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
out_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
||||||
|
print(f"Wrote {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
keynote_file = Path("video1/video1.key").expanduser().resolve()
|
||||||
|
if not keynote_file.exists():
|
||||||
|
raise FileNotFoundError(f"Keynote file not found: {keynote_file}")
|
||||||
|
|
||||||
|
script_file = Path("gnommo/extract_keynote_notes.js").expanduser().resolve()
|
||||||
|
if not script_file.exists():
|
||||||
|
raise FileNotFoundError(f"Extractor script not found: {script_file}")
|
||||||
|
|
||||||
|
presenter_notes_json_file = Path("video1/manuscript.json").expanduser().resolve()
|
||||||
|
|
||||||
|
# Run JXA extractor
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"osascript",
|
||||||
|
"-l",
|
||||||
|
"JavaScript",
|
||||||
|
str(script_file),
|
||||||
|
str(keynote_file),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Failed to extract presenter notes:\n"
|
||||||
|
f"STDERR:\n{proc.stderr}\n"
|
||||||
|
f"STDOUT:\n{proc.stdout}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write JSON output
|
||||||
|
presenter_notes_json_file.write_text(proc.stdout, encoding="utf-8")
|
||||||
|
|
||||||
|
if not presenter_notes_json_file.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Failed to extract presenter notes to {presenter_notes_json_file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert JSON → manuscript.txt
|
||||||
|
write_manuscript(
|
||||||
|
presenter_notes_json_file, out_path=keynote_file.parent / "manuscript.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Hand off a finished video to the gnommoweb server.
|
||||||
|
|
||||||
|
Works for any gnommo project type: parent videos and shorts alike.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
gnommo handoff -p video1
|
||||||
|
gnommo handoff -p short_pixelated_universe
|
||||||
|
gnommo handoff -p video1 --file /path/to/render.mp4
|
||||||
|
|
||||||
|
Reads project.json for the 'output_video' field (path relative to the
|
||||||
|
project directory). Override with --file.
|
||||||
|
|
||||||
|
On success:
|
||||||
|
- Uploads the video to MinIO via POST /api/projects/:handle/handoff
|
||||||
|
- For shorts: server auto-advances status to 'processed'
|
||||||
|
- Bumps video_version on every upload
|
||||||
|
- Updates .gnommo_sync.json with new video_version
|
||||||
|
|
||||||
|
Configuration (from .env or environment):
|
||||||
|
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
|
||||||
|
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"Error: 'requests' package is required. Run: pip install requests",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
SYNC_FILE_LOCAL = ".gnommo_sync.json"
|
||||||
|
SYNC_FILE_PROD = ".gnommo_sync.prod.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_file(prod: bool) -> str:
|
||||||
|
return SYNC_FILE_PROD if prod else SYNC_FILE_LOCAL
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file():
|
||||||
|
env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
with open(env_path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_sync(project_path: Path, prod: bool = False) -> dict:
|
||||||
|
sync_file = project_path / _sync_file(prod)
|
||||||
|
if sync_file.exists():
|
||||||
|
with open(sync_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sync(project_path: Path, data: dict, prod: bool = False):
|
||||||
|
with open(project_path / _sync_file(prod), "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_handoff(
|
||||||
|
project_path: Path,
|
||||||
|
verbose: bool = False,
|
||||||
|
file_override: str | None = None,
|
||||||
|
prod: bool = False,
|
||||||
|
res: str = "full",
|
||||||
|
) -> int:
|
||||||
|
_load_env_file()
|
||||||
|
|
||||||
|
if prod:
|
||||||
|
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||||
|
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||||
|
if not api_url:
|
||||||
|
print("Error: GNOMMOWEB_PROD_URL is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not api_key:
|
||||||
|
print("Error: GNOMMOWEB_PROD_API_KEY is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||||
|
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||||
|
if not api_url:
|
||||||
|
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not api_key:
|
||||||
|
print("Error: GNOMMOWEB_API_KEY is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
target = "production" if prod else "local"
|
||||||
|
print(f" → {target}: {api_url}")
|
||||||
|
|
||||||
|
project_file = project_path / "project.json"
|
||||||
|
if not project_file.exists():
|
||||||
|
print(f"Error: {project_file} not found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(project_file) as f:
|
||||||
|
project = json.load(f)
|
||||||
|
|
||||||
|
project_id = project.get("id")
|
||||||
|
if not project_id:
|
||||||
|
print("Error: project.json must have an 'id' field.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ── Resolve video file ─────────────────────────────────────────────────────
|
||||||
|
if file_override:
|
||||||
|
video_path = Path(file_override)
|
||||||
|
else:
|
||||||
|
output_filename = (
|
||||||
|
project.get("output") or Path(project.get("output_video", "")).name
|
||||||
|
)
|
||||||
|
if not output_filename:
|
||||||
|
print(
|
||||||
|
"Error: no 'output' field in project.json and no --file provided.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
if res != "full":
|
||||||
|
video_path = project_path / "out" / res / output_filename
|
||||||
|
else:
|
||||||
|
video_path = project_path / "out" / output_filename
|
||||||
|
|
||||||
|
if not video_path.exists():
|
||||||
|
print(f"Error: video file not found: {video_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
file_size_mb = video_path.stat().st_size / (1024 * 1024)
|
||||||
|
if verbose:
|
||||||
|
print(f"Handing off {project_id} → {api_url}")
|
||||||
|
print(f" File: {video_path} ({file_size_mb:.1f} MB)")
|
||||||
|
|
||||||
|
# ── Upload ─────────────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
with open(video_path, "rb") as vf:
|
||||||
|
r = requests.post(
|
||||||
|
f"{api_url}/api/projects/{project_id}/handoff",
|
||||||
|
files={"video": (video_path.name, vf, _mime_type(video_path))},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
timeout=None, # large files may take a while
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f"✗ Could not connect to {api_url}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not r.ok:
|
||||||
|
try:
|
||||||
|
body = r.json()
|
||||||
|
except Exception:
|
||||||
|
body = r.text[:500]
|
||||||
|
print(f"✗ Server returned {r.status_code}: {body}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
result = r.json()
|
||||||
|
video_version = result.get("video_version", "?")
|
||||||
|
video_url = result.get("video_url", "")
|
||||||
|
|
||||||
|
# ── Write sync state ───────────────────────────────────────────────────────
|
||||||
|
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||||
|
existing_sync = _read_sync(project_path, prod)
|
||||||
|
_write_sync(
|
||||||
|
project_path,
|
||||||
|
{
|
||||||
|
**existing_sync,
|
||||||
|
"last_handoff_at": now_iso,
|
||||||
|
"video_version": video_version,
|
||||||
|
"server_updated_at": result.get("asset", {}).get(
|
||||||
|
"updated_at", existing_sync.get("server_updated_at")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
prod,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"✓ {project_id} → v{video_version} [processed]")
|
||||||
|
if video_url:
|
||||||
|
print(f" {video_url}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _mime_type(path: Path) -> str:
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
return {
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".mov": "video/quicktime",
|
||||||
|
".webm": "video/webm",
|
||||||
|
".mkv": "video/x-matroska",
|
||||||
|
}.get(ext, "application/octet-stream")
|
||||||
+446
-37
@@ -2,35 +2,78 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TalkingHeadConfig:
|
class CutoutDefinition:
|
||||||
"""Configuration for talking head video positioning."""
|
"""Definition of a named zone for placing video content.
|
||||||
x: int
|
|
||||||
y: int
|
All positioning values support both pixels (int) and percentages (str like "50%").
|
||||||
target_height: int # in pixels, or -1 for percentage-based
|
Percentage values are stored as floats (0.0-1.0) with pixel value set to -1.
|
||||||
target_height_percent: float = 0.0 # percentage (0.0-1.0) if target_height is -1
|
|
||||||
file: Optional[str] = None # Path to video or metadata JSON file
|
Videos placed in cutouts are cropped to fit the cutout dimensions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
x: int # in pixels, or -1 for percentage-based
|
||||||
|
y: int # in pixels, or -1 for percentage-based
|
||||||
|
height: int # in pixels, or -1 for percentage-based
|
||||||
|
width: int = (
|
||||||
|
-1
|
||||||
|
) # in pixels, or -1 for percentage-based (defaults to height for square)
|
||||||
|
x_percent: float = 0.0 # percentage (0.0-1.0) if x is -1
|
||||||
|
y_percent: float = 0.0 # percentage (0.0-1.0) if y is -1
|
||||||
|
height_percent: float = 0.0 # percentage (0.0-1.0) if height is -1
|
||||||
|
width_percent: float = 0.0 # percentage (0.0-1.0) if width is -1
|
||||||
|
|
||||||
|
|
||||||
|
# Backwards compatibility alias
|
||||||
|
TalkingHeadConfig = CutoutDefinition
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProjectConfig:
|
class ProjectConfig:
|
||||||
"""Global project configuration from project.json."""
|
"""Global project configuration from project.json."""
|
||||||
|
|
||||||
resolution: tuple[int, int]
|
resolution: tuple[int, int]
|
||||||
fps: int
|
fps: int
|
||||||
talking_head: TalkingHeadConfig
|
|
||||||
default_slide_type: str
|
default_slide_type: str
|
||||||
|
cutouts: dict[str, CutoutDefinition] = field(
|
||||||
|
default_factory=dict
|
||||||
|
) # Named zones for video placement
|
||||||
|
default_filters: dict[str, list[dict]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
) # Named filter presets that can be referenced in videos.json
|
||||||
background: str = "" # Background image or video path (in shared_assets/)
|
background: 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
|
||||||
|
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[
|
||||||
|
Union[str, list]
|
||||||
|
] = None # ID(s) of main video(s) - array for multi-segment narration
|
||||||
|
gnommo_scratch: Optional[
|
||||||
|
str
|
||||||
|
] = None # directory for intermediate files (e.g., external SSD)
|
||||||
|
default_begin: float = 0.0 # Trim this many seconds from the start of each segment (if no explicit begin/skip)
|
||||||
|
default_end_trim: float = 0.0 # Trim this many seconds from the end of each segment (if no explicit end/take)
|
||||||
|
# Outro sequence - plays after narration ends (not marker-triggered)
|
||||||
|
outro: list[str] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # List of video IDs to play in sequence after narration
|
||||||
|
# YouTube description fields
|
||||||
|
description: str = "" # Video description text for YouTube
|
||||||
|
footer: str = "" # Footer text (social links, subscribe CTA, etc.)
|
||||||
|
output_video: str = (
|
||||||
|
"" # Output filename (e.g. "DISC_INT3.mp4"); placed in out/ or out/<res>/
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SlideDefinition:
|
class SlideDefinition:
|
||||||
"""Definition of a single slide from slides.json."""
|
"""Definition of a single slide from slides.json."""
|
||||||
|
|
||||||
image: str
|
image: str
|
||||||
type: str # "fullscreen" | "square"
|
type: str # "fullscreen" | "square"
|
||||||
|
|
||||||
@@ -38,25 +81,226 @@ class SlideDefinition:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ChromaKeyConfig:
|
class ChromaKeyConfig:
|
||||||
"""Configuration for chroma key (green screen) filter."""
|
"""Configuration for chroma key (green screen) filter."""
|
||||||
|
|
||||||
color: tuple[int, int, int] = (0, 255, 0) # RGB color to key out
|
color: tuple[int, int, int] = (0, 255, 0) # RGB color to key out
|
||||||
similarity: float = 0.15 # Color similarity threshold (0.0-1.0)
|
similarity: float = (
|
||||||
blend: float = 0.1 # Edge blend/feathering (0.0-1.0)
|
0.4 # Color similarity threshold (0.0-1.0), higher = more aggressive
|
||||||
spill: float = 0.0 # Spill suppression amount (0.0-1.0)
|
)
|
||||||
|
blend: float = 0.08 # Edge blend/feathering (0.0-1.0), lower = tighter edges
|
||||||
|
spill: float = 0.1 # Spill suppression amount (0.0-1.0)
|
||||||
|
edge_erode: int = 0 # Pixels to erode from alpha edge (0-5), removes green fringe
|
||||||
|
# Color protection - restore opacity for colors that shouldn't be keyed
|
||||||
|
protect_color: tuple[int, int, int] = None # RGB color to protect from keying
|
||||||
|
protect_tolerance: float = (
|
||||||
|
0.15 # How much variation from protect_color to allow (0-1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GnommoKeyConfig:
|
||||||
|
"""Configuration for gnommokey filter - Keylight-style color-difference keyer.
|
||||||
|
|
||||||
|
Uses YCbCr color-difference keying (like Keylight/Ultimatte) instead of
|
||||||
|
simple Euclidean distance. This handles lighting variation much better
|
||||||
|
than basic chromakey.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Screen color (the green/blue screen color to key out)
|
||||||
|
screen_color: tuple[int, int, int] = (0, 177, 64) # RGB of the screen
|
||||||
|
|
||||||
|
# Key extraction strength (default 100, higher = more aggressive)
|
||||||
|
# Values 80-150 are typical. Maps to Keylight's Screen Gain.
|
||||||
|
screen_gain: float = 100.0
|
||||||
|
|
||||||
|
# Balance between chrominance and luminance in key calculation (0-100)
|
||||||
|
# 0 = pure color-difference, 100 = luminance weighted
|
||||||
|
# Maps to Keylight's Screen Balance.
|
||||||
|
screen_balance: float = 50.0
|
||||||
|
|
||||||
|
# Alpha/matte adjustments
|
||||||
|
clip_black: float = 0.0 # Crush blacks (0-100). Higher = more transparent areas
|
||||||
|
clip_white: float = 100.0 # Crush whites (0-100). Lower = more opaque areas
|
||||||
|
|
||||||
|
# Despill: color to shift green spill toward (RGB)
|
||||||
|
# Typical values: skin tone [217, 200, 180] or neutral [200, 200, 200]
|
||||||
|
despill_bias: tuple[int, int, int] = None
|
||||||
|
|
||||||
|
# How aggressively to apply despill (0-1)
|
||||||
|
despill_strength: float = 0.5
|
||||||
|
|
||||||
|
# Alpha bias: influences edge treatment (RGB)
|
||||||
|
# Can help with edge color contamination
|
||||||
|
alpha_bias: tuple[int, int, int] = None
|
||||||
|
|
||||||
|
# Edge refinement
|
||||||
|
edge_erode: int = 0 # Pixels to erode from alpha edge (0-5)
|
||||||
|
edge_soften: float = 0.0 # Blur the alpha edge (0-5 pixels)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ColorGradeConfig:
|
||||||
|
"""Configuration for color grading filter.
|
||||||
|
|
||||||
|
Applies color balance, contrast curves, and saturation adjustments
|
||||||
|
while preserving the alpha channel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Color balance (range: -1.0 to 1.0, 0 = no change)
|
||||||
|
# Midtones
|
||||||
|
rm: float = 0.0 # Red midtones adjustment
|
||||||
|
gm: float = 0.0 # Green midtones adjustment
|
||||||
|
bm: float = 0.0 # Blue midtones adjustment
|
||||||
|
# Highlights
|
||||||
|
rh: float = 0.0 # Red highlights adjustment
|
||||||
|
gh: float = 0.0 # Green highlights adjustment
|
||||||
|
bh: float = 0.0 # Blue highlights adjustment
|
||||||
|
# Shadows
|
||||||
|
rs: float = 0.0 # Red shadows adjustment
|
||||||
|
gs: float = 0.0 # Green shadows adjustment
|
||||||
|
bs: float = 0.0 # Blue shadows adjustment
|
||||||
|
|
||||||
|
# Curves preset (none, lighter, darker, increase_contrast, medium_contrast, etc.)
|
||||||
|
curves_preset: str = "none"
|
||||||
|
|
||||||
|
# EQ adjustments
|
||||||
|
contrast: float = 1.0 # Contrast multiplier (0.0-2.0, 1.0 = no change)
|
||||||
|
brightness: float = 0.0 # Brightness adjustment (-1.0 to 1.0, 0 = no change)
|
||||||
|
saturation: float = 1.0 # Saturation multiplier (0.0-3.0, 1.0 = no change)
|
||||||
|
|
||||||
|
# Custom curves for lift/gamma/gain control
|
||||||
|
# Format: "0/0 0.5/0.56 1/1" means (input/output) control points
|
||||||
|
curves_r: str = "" # Red channel curve
|
||||||
|
curves_g: str = "" # Green channel curve
|
||||||
|
curves_b: str = "" # Blue channel curve
|
||||||
|
curves_master: str = "" # Master (luminance) curve
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EQBand:
|
||||||
|
"""A single parametric EQ band."""
|
||||||
|
|
||||||
|
freq: float # Center frequency in Hz
|
||||||
|
gain: float # Gain in dB (negative = cut, positive = boost)
|
||||||
|
q: float = 1.0 # Q factor (bandwidth), higher = narrower
|
||||||
|
type: str = "peak" # "peak", "lowshelf", or "highshelf"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioNormalizeConfig:
|
||||||
|
"""Configuration for audio normalization filter.
|
||||||
|
|
||||||
|
Applies noise reduction, compression, and loudness normalization
|
||||||
|
to improve audio quality and consistency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Parametric EQ bands (applied before other processing)
|
||||||
|
eq_bands: list[EQBand] = field(default_factory=list)
|
||||||
|
|
||||||
|
# High-pass filter (remove room rumble)
|
||||||
|
highpass: float = (
|
||||||
|
0.0 # High-pass frequency in Hz (0 = disabled, try 80-120 for voice)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Low-pass filter (remove harsh highs)
|
||||||
|
lowpass: float = (
|
||||||
|
0.0 # Low-pass frequency in Hz (0 = disabled, try 12000-16000 if needed)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Room resonance EQ cut (reduce muddy room buildup)
|
||||||
|
room_eq: bool = False # Enable room resonance cut
|
||||||
|
room_eq_freq: float = 300.0 # Center frequency for room cut (Hz, typically 200-400)
|
||||||
|
room_eq_gain: float = -4.0 # Gain in dB (negative = cut)
|
||||||
|
room_eq_width: float = 1.5 # Q/bandwidth (higher = narrower cut)
|
||||||
|
|
||||||
|
# Noise gate (reduce reverb tails during pauses)
|
||||||
|
gate: bool = False # Enable noise gate
|
||||||
|
gate_threshold: float = -35.0 # Threshold in dB (signal below this gets attenuated)
|
||||||
|
gate_range: float = -20.0 # Attenuation amount in dB when gate is closed
|
||||||
|
gate_attack: float = 10.0 # Attack time in ms
|
||||||
|
gate_release: float = 150.0 # Release time in ms
|
||||||
|
|
||||||
|
# Neural de-reverb (arnndn filter - very effective but needs model file)
|
||||||
|
dereverb_model: str = "" # Path to RNNoise model file (empty = disabled)
|
||||||
|
dereverb_mix: float = (
|
||||||
|
0.8 # Mix ratio 0.0-1.0 (1.0 = full effect, 0.8 = preserve some natural room)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Noise reduction (afftdn filter)
|
||||||
|
denoise: bool = True # Enable noise reduction
|
||||||
|
noise_floor: float = (
|
||||||
|
-25.0
|
||||||
|
) # Noise floor in dB (default -25, lower = more aggressive)
|
||||||
|
|
||||||
|
# Compression (acompressor filter)
|
||||||
|
compress: bool = True # Enable dynamic range compression
|
||||||
|
threshold: float = -20.0 # Compression threshold in dB
|
||||||
|
ratio: float = 4.0 # Compression ratio (4:1 default)
|
||||||
|
attack: float = 5.0 # Attack time in ms
|
||||||
|
release: float = 50.0 # Release time in ms
|
||||||
|
makeup: float = 2.0 # Makeup gain in dB
|
||||||
|
|
||||||
|
# Loudness normalization (loudnorm filter - EBU R128)
|
||||||
|
normalize: bool = True # Enable loudness normalization
|
||||||
|
target_lufs: float = (
|
||||||
|
-16.0
|
||||||
|
) # Target integrated loudness (YouTube recommends -14 to -16)
|
||||||
|
target_lra: float = 11.0 # Target loudness range
|
||||||
|
target_tp: float = -1.5 # Target true peak in dB
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FilterConfig:
|
class FilterConfig:
|
||||||
"""Base configuration for a preprocessing filter."""
|
"""Base configuration for a preprocessing filter."""
|
||||||
|
|
||||||
type: str
|
type: str
|
||||||
# Type-specific config stored in subclasses or as dict
|
# Type-specific config stored in subclasses or as dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Attribution:
|
||||||
|
"""Attribution information for stock footage (e.g., Pexels)."""
|
||||||
|
|
||||||
|
source: str # Source platform (e.g., "pexels", "pixabay", "unsplash")
|
||||||
|
creator: str # Creator/photographer name
|
||||||
|
url: Optional[str] = None # URL to the original content
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VideoSource:
|
class VideoSource:
|
||||||
"""Video source definition from videos.json."""
|
"""Video source definition from videos.json."""
|
||||||
file: str
|
|
||||||
preprocess: list[dict] = field(default_factory=list) # List of filter config dicts
|
source_file: str # Source video filename (relative to videos.json location or shared_assets/)
|
||||||
output_file: Optional[str] = None # Path to preprocessed output (if any)
|
filter: list[dict] = field(default_factory=list) # List of filter config dicts
|
||||||
|
output_file: Optional[
|
||||||
|
str
|
||||||
|
] = None # Path to preprocessed output (relative to videos.json)
|
||||||
|
take: Optional[
|
||||||
|
float
|
||||||
|
] = None # Max duration to play (seconds). Default: until next slide or end of clip
|
||||||
|
skip: float = 0.0 # Skip this many seconds at start of video (seek point)
|
||||||
|
zoom: float = (
|
||||||
|
1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge)
|
||||||
|
)
|
||||||
|
cutout: Optional[
|
||||||
|
str
|
||||||
|
] = None # Name of cutout to place video in (from project.json cutouts)
|
||||||
|
always_visible: bool = False # If True, video is always shown (like talking head)
|
||||||
|
is_shared: bool = False # If True, source_file is relative to shared_assets/
|
||||||
|
pause_narration: float = (
|
||||||
|
0.0 # Seconds to pause narration during this video (0 = no pause)
|
||||||
|
)
|
||||||
|
attribution: Optional[Attribution] = None # Attribution for stock footage
|
||||||
|
use_audio_channels: str = (
|
||||||
|
"both" # Audio channel selection: "both", "left", or "right"
|
||||||
|
)
|
||||||
|
defer_loudnorm: bool = (
|
||||||
|
False # If True, skip loudnorm during preprocessing (apply after concatenation)
|
||||||
|
)
|
||||||
|
volume: float = 1.0 # Volume multiplier (1.0=full, >1.0=boost, <1.0=reduce)
|
||||||
|
layer: str = "above" # "above" = renders on top of slides; "below" = behind slides
|
||||||
|
duration: Optional[float] = None # Pre-probed file duration in seconds (set by import)
|
||||||
|
has_audio: Optional[bool] = None # Pre-detected audio presence (set by import)
|
||||||
|
end_on: Optional[str] = None # When video event ends: "next_slide" | "end" | "take" (None = marker-type default)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -67,50 +311,215 @@ class VideoMetadata:
|
|||||||
This allows defining preprocessing steps separately from videos.json,
|
This allows defining preprocessing steps separately from videos.json,
|
||||||
enabling per-video preprocessing configuration.
|
enabling per-video preprocessing configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
source_file: str # Original source video file
|
source_file: str # Original source video file
|
||||||
preprocess: list[dict] = field(default_factory=list) # Preprocessing filters
|
preprocess: list[dict] = field(default_factory=list) # Preprocessing filters
|
||||||
output: Optional[dict] = None # Output config {"file": "...", "colorspace": "...", "alpha": "..."}
|
output: Optional[
|
||||||
|
dict
|
||||||
|
] = None # Output config {"file": "...", "colorspace": "...", "alpha": "..."}
|
||||||
@dataclass
|
|
||||||
class TimedWord:
|
|
||||||
"""A word or marker with its timestamp from transcript.csv."""
|
|
||||||
time: float
|
|
||||||
word: str
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_marker(self) -> bool:
|
|
||||||
"""Check if this is a slide marker like [S1]."""
|
|
||||||
return self.word.startswith("[") and self.word.endswith("]")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def marker_id(self) -> Optional[str]:
|
|
||||||
"""Extract marker ID (e.g., 'S1' from '[S1]')."""
|
|
||||||
if self.is_marker:
|
|
||||||
return self.word[1:-1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SlideEvent:
|
class SlideEvent:
|
||||||
"""A resolved slide event with timing information."""
|
"""A resolved slide event with timing information."""
|
||||||
|
|
||||||
slide_id: str
|
slide_id: str
|
||||||
start_time: float
|
start_time: float
|
||||||
end_time: float
|
end_time: float
|
||||||
slide_def: SlideDefinition
|
slide_def: SlideDefinition
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioDefinition:
|
||||||
|
"""Definition of an audio clip from audio.json."""
|
||||||
|
|
||||||
|
file: str # Audio filename (relative to audio.json location)
|
||||||
|
volume: float = 1.0 # Volume multiplier (0.0-1.0)
|
||||||
|
loop: bool = False # If True, loop for entire duration from trigger point
|
||||||
|
overlap: Optional[float] = None # Crossfade overlap in seconds when looping
|
||||||
|
ignore_pauses: bool = (
|
||||||
|
False # If True, audio continues playing during narration pauses
|
||||||
|
)
|
||||||
|
duration: Optional[float] = None # Pre-probed duration in seconds (set by import)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Citation:
|
||||||
|
"""A citation extracted from manuscript.txt [cite:...] markers."""
|
||||||
|
|
||||||
|
reference: str # The literal reference text after cite:
|
||||||
|
marker_id: str # The full marker (e.g., "cite:Smith et al...")
|
||||||
|
timestamp: float = -1.0 # Aligned timestamp (-1 if not aligned)
|
||||||
|
context: str = "" # Text following the citation for alignment
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioEvent:
|
||||||
|
"""A resolved audio event with timing information."""
|
||||||
|
|
||||||
|
audio_id: str
|
||||||
|
start_time: float # When to start playing (marker time - offset)
|
||||||
|
audio_def: AudioDefinition
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoEvent:
|
||||||
|
"""A resolved video event with timing information."""
|
||||||
|
|
||||||
|
video_id: str
|
||||||
|
start_time: float
|
||||||
|
end_time: float
|
||||||
|
video_source: "VideoSource"
|
||||||
|
cutout: "CutoutDefinition"
|
||||||
|
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
|
||||||
|
layer: str = "above" # "above" = on top of slides; "below" = behind slides
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CameraState:
|
||||||
|
"""State of the virtual camera at a point in time.
|
||||||
|
|
||||||
|
The camera transforms the entire composed scene (background, slides, cutouts).
|
||||||
|
This ensures all elements stay spatially synchronized when zooming/tilting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
zoom: float = 1.0 # 1.0 = 100%, 1.25 = 125%, etc.
|
||||||
|
rotation: float = 0.0 # degrees, positive = clockwise
|
||||||
|
pan_x: float = 0.0 # -1.0 to 1.0, percentage of frame width
|
||||||
|
pan_y: float = 0.0 # -1.0 to 1.0, percentage of frame height
|
||||||
|
focal_x: float = 0.5 # 0.0 to 1.0, zoom focal point X (0.5 = center)
|
||||||
|
focal_y: float = 0.5 # 0.0 to 1.0, zoom focal point Y (0.5 = center)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Clamp values to reasonable ranges
|
||||||
|
self.zoom = max(0.5, min(3.0, self.zoom))
|
||||||
|
self.rotation = max(-45.0, min(45.0, self.rotation))
|
||||||
|
self.pan_x = max(-1.0, min(1.0, self.pan_x))
|
||||||
|
self.pan_y = max(-1.0, min(1.0, self.pan_y))
|
||||||
|
self.focal_x = max(0.0, min(1.0, self.focal_x))
|
||||||
|
self.focal_y = max(0.0, min(1.0, self.focal_y))
|
||||||
|
|
||||||
|
def is_default(self) -> bool:
|
||||||
|
"""Check if this is the default camera state (no transform)."""
|
||||||
|
return (
|
||||||
|
self.zoom == 1.0
|
||||||
|
and self.rotation == 0.0
|
||||||
|
and self.pan_x == 0.0
|
||||||
|
and self.pan_y == 0.0
|
||||||
|
and self.focal_x == 0.5
|
||||||
|
and self.focal_y == 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CameraEvent:
|
||||||
|
"""A camera state change at a specific time.
|
||||||
|
|
||||||
|
Camera events can be instant (duration=0) or animated (duration>0).
|
||||||
|
When animated, the camera smoothly transitions from its current state
|
||||||
|
to the target state over the specified duration using the easing function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
time: float # timestamp in seconds
|
||||||
|
target_state: CameraState
|
||||||
|
duration: float = 0.2 # transition duration (0 = instant snap)
|
||||||
|
easing: str = "ease-out" # linear, ease-in, ease-out, ease-in-out
|
||||||
|
|
||||||
|
|
||||||
|
# Camera effect presets - map marker names to camera states
|
||||||
|
# Effect strengths are intentionally subtle for professional look
|
||||||
|
CAMERA_PRESETS: dict[str, CameraState] = {
|
||||||
|
# Zoom levels (halved for subtlety)
|
||||||
|
"Zoom0": CameraState(zoom=1.0),
|
||||||
|
"Zoom1": CameraState(zoom=1.05),
|
||||||
|
"Zoom2": CameraState(zoom=1.125),
|
||||||
|
"Zoom3": CameraState(zoom=1.25),
|
||||||
|
# Tilt/rotation (halved)
|
||||||
|
"TiltLeft": CameraState(rotation=-7.5),
|
||||||
|
"TiltRight": CameraState(rotation=7.5),
|
||||||
|
"NoTilt": CameraState(), # Full reset to default state
|
||||||
|
# Pan (halved)
|
||||||
|
"PanLeft": CameraState(pan_x=-0.1),
|
||||||
|
"PanRight": CameraState(pan_x=0.1),
|
||||||
|
"PanUp": CameraState(pan_y=-0.075),
|
||||||
|
"PanDown": CameraState(pan_y=0.075),
|
||||||
|
"PanCenter": CameraState(pan_x=0.0, pan_y=0.0),
|
||||||
|
# Reset all
|
||||||
|
"Reset": CameraState(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NarrationPause:
|
||||||
|
"""A pause in the narration timeline for an interstitial video."""
|
||||||
|
|
||||||
|
output_time: float # When the pause starts in the OUTPUT timeline
|
||||||
|
narration_time: float # Where we are in the NARRATION source when pause starts
|
||||||
|
duration: float # How long the pause lasts
|
||||||
|
video_id: str # The video that plays during the pause
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OutroEvent:
|
||||||
|
"""A video that plays as part of the outro sequence (after narration ends)."""
|
||||||
|
|
||||||
|
video_id: str
|
||||||
|
start_time: float # When this outro video starts (in output timeline)
|
||||||
|
end_time: float # When this outro video ends
|
||||||
|
video_source: "VideoSource"
|
||||||
|
cutout: Optional["CutoutDefinition"] = None # None = fullscreen
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RenderPlan:
|
class RenderPlan:
|
||||||
"""Complete plan for rendering the final video."""
|
"""Complete plan for rendering the final video."""
|
||||||
|
|
||||||
project_path: Path
|
project_path: Path
|
||||||
config: ProjectConfig
|
config: ProjectConfig
|
||||||
talking_head: VideoSource
|
|
||||||
slide_events: list[SlideEvent]
|
slide_events: list[SlideEvent]
|
||||||
total_duration: float
|
total_duration: float
|
||||||
slides: dict[str, SlideDefinition]
|
slides: dict[str, SlideDefinition]
|
||||||
|
videos: dict[str, VideoSource] = field(default_factory=dict)
|
||||||
|
video_events: list[VideoEvent] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # Triggered video overlays
|
||||||
|
narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # (video_id, source, cutout)
|
||||||
slides_dir: Path = None # directory containing slide images
|
slides_dir: Path = None # directory containing slide images
|
||||||
talking_head_path: Path = None # Resolved path to actual video file
|
videos_dir: Path = None # directory containing videos.json and video files
|
||||||
|
audio_events: list[AudioEvent] = field(default_factory=list)
|
||||||
|
audio: dict[str, AudioDefinition] = field(default_factory=dict)
|
||||||
|
audio_dir: Path = None # directory containing audio.json and audio files
|
||||||
|
camera_events: list[CameraEvent] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # Virtual camera keyframes
|
||||||
|
# Partial rendering support
|
||||||
|
time_offset: float = (
|
||||||
|
0.0 # Offset subtracted from all timestamps (for partial render)
|
||||||
|
)
|
||||||
|
initial_camera_state: "CameraState" = (
|
||||||
|
None # Camera state at render start (for partial render)
|
||||||
|
)
|
||||||
|
input_seek_time: float = 0.0 # Seek position for input videos (for partial render)
|
||||||
|
# Shared assets support
|
||||||
|
shared_assets_dir: Path = None # Directory containing shared assets (pexels, etc.)
|
||||||
|
# Narration pause support
|
||||||
|
narration_pauses: list[NarrationPause] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # Gaps in narration for interstitial videos
|
||||||
|
# Outro sequence (plays after narration ends)
|
||||||
|
outro_events: list["OutroEvent"] = field(
|
||||||
|
default_factory=list
|
||||||
|
) # Videos that play after narration ends
|
||||||
|
narration_end_time: float = 0.0 # When narration ends (before outro starts)
|
||||||
|
# GnommoCache support
|
||||||
|
cached_files: set = field(
|
||||||
|
default_factory=set
|
||||||
|
) # Video IDs loaded from external cache (show 📁 indicator)
|
||||||
|
output_path: Optional[
|
||||||
|
Path
|
||||||
|
] = None # Final output file path (set after plan is built)
|
||||||
|
|
||||||
|
|
||||||
# Slide layout configurations (hardcoded for POC)
|
# Slide layout configurations (hardcoded for POC)
|
||||||
|
|||||||
+463
-75
@@ -1,28 +1,42 @@
|
|||||||
"""Extract stage: parse all input files."""
|
"""Extract stage: parse all input files."""
|
||||||
|
|
||||||
import csv
|
|
||||||
import json
|
import json
|
||||||
import re
|
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,
|
||||||
|
AudioDefinition,
|
||||||
|
Citation,
|
||||||
|
CutoutDefinition,
|
||||||
ProjectConfig,
|
ProjectConfig,
|
||||||
SlideDefinition,
|
SlideDefinition,
|
||||||
TalkingHeadConfig,
|
|
||||||
TimedWord,
|
|
||||||
VideoMetadata,
|
VideoMetadata,
|
||||||
VideoSource,
|
VideoSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int, str]]]:
|
def _read_json(path: Path) -> Any:
|
||||||
|
"""Read and parse a JSON file, treating an empty file as {}."""
|
||||||
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
|
return json.loads(text) if text else {}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_manuscript(
|
||||||
|
project_path: Path,
|
||||||
|
) -> tuple[str, list[str], list[tuple[int, str]], list[Citation]]:
|
||||||
"""
|
"""
|
||||||
Parse manuscript.txt and extract text content and slide markers.
|
Parse manuscript.txt and extract text content and slide markers.
|
||||||
|
|
||||||
|
Strips [cite:...] and [marker:...] markers from the returned text so they
|
||||||
|
never pollute alignment contexts. Citations are extracted and returned
|
||||||
|
separately. Marker cues are personal recording notes and are simply discarded.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (full text, list of marker IDs found, list of malformed markers as (line_num, text))
|
Tuple of (full text, list of marker IDs found, list of malformed markers, list of citations)
|
||||||
"""
|
"""
|
||||||
manuscript_path = project_path / "manuscript.txt"
|
manuscript_path = project_path / "manuscript.txt"
|
||||||
|
|
||||||
@@ -31,8 +45,19 @@ def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int
|
|||||||
|
|
||||||
text = manuscript_path.read_text(encoding="utf-8")
|
text = manuscript_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Extract all valid slide markers like [S1], [S2], etc.
|
# Extract citations before stripping them
|
||||||
markers = re.findall(r"\[([A-Za-z0-9_]+)\]", text)
|
citations = parse_citations(text)
|
||||||
|
|
||||||
|
# Strip [cite:...] markers from text so they don't pollute alignment
|
||||||
|
text = re.sub(r"\[cite:[^\]]+\]", "", text)
|
||||||
|
|
||||||
|
# Strip [marker:...] and [cue:...] markers (personal recording cues, ignored by pipeline)
|
||||||
|
text = re.sub(r"\[marker:[^\]]+\]", "", text)
|
||||||
|
text = re.sub(r"\[cue:[^\]]+\]", "", text)
|
||||||
|
|
||||||
|
# Extract all valid markers like [S1], [video:demo], [Zoom2], etc.
|
||||||
|
# Include . in pattern to catch markers with file extensions (so validator can warn about them)
|
||||||
|
markers = re.findall(r"\[([A-Za-z0-9_:.]+)\]", text)
|
||||||
|
|
||||||
# Find malformed markers (missing brackets, extra spaces, etc.)
|
# Find malformed markers (missing brackets, extra spaces, etc.)
|
||||||
malformed: list[tuple[int, str]] = []
|
malformed: list[tuple[int, str]] = []
|
||||||
@@ -56,48 +81,72 @@ def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int
|
|||||||
for match in spaced:
|
for match in spaced:
|
||||||
malformed.append((line_num, match))
|
malformed.append((line_num, match))
|
||||||
|
|
||||||
return text, markers, malformed
|
return text, markers, malformed, citations
|
||||||
|
|
||||||
|
|
||||||
def parse_transcript(project_path: Path) -> list[TimedWord]:
|
def parse_citations(manuscript_text: str) -> list[Citation]:
|
||||||
"""
|
"""
|
||||||
Parse transcript.csv into a list of timed words.
|
Extract all [cite:...] markers from manuscript text.
|
||||||
|
|
||||||
Expected format:
|
The text after 'cite:' is the literal reference that should appear
|
||||||
t,word
|
in the video description.
|
||||||
0.00,This
|
|
||||||
0.42,is
|
Returns:
|
||||||
...
|
List of Citation objects with reference text and context for alignment.
|
||||||
"""
|
"""
|
||||||
transcript_path = project_path / "transcript.csv"
|
citations = []
|
||||||
|
|
||||||
if not transcript_path.exists():
|
# Match [cite:...] markers - content can include any characters except ]
|
||||||
raise ParseError("transcript.csv not found", transcript_path)
|
# Use a more permissive pattern that handles multi-word citations
|
||||||
|
pattern = r"\[cite:([^\]]+)\]"
|
||||||
|
|
||||||
timed_words = []
|
for match in re.finditer(pattern, manuscript_text):
|
||||||
|
reference = match.group(1).strip()
|
||||||
|
marker_id = f"cite:{reference}"
|
||||||
|
|
||||||
with open(transcript_path, "r", encoding="utf-8") as f:
|
# Extract context: text following the citation (for alignment)
|
||||||
reader = csv.DictReader(f)
|
# Get up to 100 chars after the marker, stopping at next marker or newline
|
||||||
|
end_pos = match.end()
|
||||||
|
context_text = manuscript_text[end_pos : end_pos + 150]
|
||||||
|
|
||||||
if reader.fieldnames is None or "t" not in reader.fieldnames or "word" not in reader.fieldnames:
|
# Clean up context: take text until next marker or double newline
|
||||||
raise ParseError(
|
context_match = re.match(r"([^\[]*?)(?:\[|\n\n|$)", context_text)
|
||||||
"transcript.csv must have columns: t, word",
|
context = context_match.group(1).strip() if context_match else ""
|
||||||
transcript_path
|
|
||||||
|
# Truncate context to ~50 chars for display
|
||||||
|
if len(context) > 50:
|
||||||
|
context = context[:47] + "..."
|
||||||
|
|
||||||
|
citations.append(
|
||||||
|
Citation(
|
||||||
|
reference=reference,
|
||||||
|
marker_id=marker_id,
|
||||||
|
context=context,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for line_num, row in enumerate(reader, start=2): # start=2 because line 1 is header
|
return citations
|
||||||
try:
|
|
||||||
time = float(row["t"])
|
|
||||||
word = row["word"].strip()
|
|
||||||
timed_words.append(TimedWord(time=time, word=word))
|
|
||||||
except (ValueError, KeyError) as e:
|
|
||||||
raise ParseError(
|
|
||||||
f"Invalid row: {e}",
|
|
||||||
transcript_path,
|
|
||||||
line_num
|
|
||||||
)
|
|
||||||
|
|
||||||
return timed_words
|
|
||||||
|
def save_citations(citations: list[Citation], path: Path) -> None:
|
||||||
|
"""Save citations to a JSON file."""
|
||||||
|
data = [{"reference": c.reference, "context": c.context} for c in citations]
|
||||||
|
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def load_citations(path: Path) -> list[Citation]:
|
||||||
|
"""Load citations from a JSON file."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
data = _read_json(path)
|
||||||
|
return [
|
||||||
|
Citation(
|
||||||
|
reference=item["reference"],
|
||||||
|
marker_id=f"cite:{item['reference']}",
|
||||||
|
context=item.get("context", ""),
|
||||||
|
)
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def parse_project_config(project_path: Path) -> ProjectConfig:
|
def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||||
@@ -108,35 +157,87 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
|
|||||||
raise ParseError("project.json not found", config_path)
|
raise ParseError("project.json not found", config_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
data = _read_json(config_path)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise ParseError(f"Invalid JSON: {e}", config_path)
|
raise ParseError(f"Invalid JSON: {e}", config_path)
|
||||||
|
|
||||||
# Parse talking head config
|
# Built-in cutouts — used by vft/vfb/vst/vsb marker shorthand.
|
||||||
th_data = data.get("talkinghead", {})
|
# Projects can override these by defining cutouts with the same names.
|
||||||
th_height, th_height_pct = _parse_dimension(th_data.get("targetheight", 200))
|
cutouts: dict[str, CutoutDefinition] = {
|
||||||
talking_head = TalkingHeadConfig(
|
# 100 % × 100 % at origin — for fullscreen video (vf* markers)
|
||||||
x=th_data.get("x", 100),
|
"fullscreen": CutoutDefinition(
|
||||||
y=th_data.get("y", 100),
|
x=-1,
|
||||||
target_height=th_height,
|
y=-1,
|
||||||
target_height_percent=th_height_pct,
|
height=-1,
|
||||||
file=th_data.get("file"),
|
width=-1,
|
||||||
)
|
x_percent=0.0,
|
||||||
|
y_percent=0.0,
|
||||||
|
height_percent=1.0,
|
||||||
|
width_percent=1.0,
|
||||||
|
),
|
||||||
|
# 50 % height, square aspect, centred — for square video (vs* markers)
|
||||||
|
"square": CutoutDefinition(
|
||||||
|
x=-1,
|
||||||
|
y=-1,
|
||||||
|
height=-1,
|
||||||
|
width=-1,
|
||||||
|
x_percent=0.25,
|
||||||
|
y_percent=0.25,
|
||||||
|
height_percent=0.5,
|
||||||
|
width_percent=0.0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse cutouts (named zones for video placement) — project definitions
|
||||||
|
# override the built-ins above.
|
||||||
|
cutouts_data = data.get("cutouts", {})
|
||||||
|
for cutout_name, cutout_data in cutouts_data.items():
|
||||||
|
x, x_pct = _parse_dimension(cutout_data.get("x", 0))
|
||||||
|
y, y_pct = _parse_dimension(cutout_data.get("y", 0))
|
||||||
|
height, height_pct = _parse_dimension(cutout_data.get("height", 200))
|
||||||
|
# Width defaults to same as height (square) if not specified
|
||||||
|
width, width_pct = _parse_dimension(
|
||||||
|
cutout_data.get("width", cutout_data.get("height", 200))
|
||||||
|
)
|
||||||
|
cutouts[cutout_name] = CutoutDefinition(
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
height=height,
|
||||||
|
width=width,
|
||||||
|
x_percent=x_pct,
|
||||||
|
y_percent=y_pct,
|
||||||
|
height_percent=height_pct,
|
||||||
|
width_percent=width_pct,
|
||||||
|
)
|
||||||
|
|
||||||
# Parse resolution
|
# Parse resolution
|
||||||
resolution = data.get("resolution", [1920, 1080])
|
resolution = data.get("resolution", [1920, 1080])
|
||||||
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),
|
||||||
talking_head=talking_head,
|
|
||||||
default_slide_type=data.get("defaultSlideType", "square"),
|
default_slide_type=data.get("defaultSlideType", "square"),
|
||||||
|
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"),
|
||||||
|
videos_path=data.get("videos", "videos.json"),
|
||||||
|
audio_path=data.get("audio", "audio.json"),
|
||||||
audio_source=data.get("audio_source"),
|
audio_source=data.get("audio_source"),
|
||||||
|
main_video=data.get("main_video"),
|
||||||
|
gnommo_scratch=data.get("gnommo_scratch"),
|
||||||
|
default_begin=float(data.get("default_begin", 0.0)),
|
||||||
|
default_end_trim=float(data.get("default_end_trim", 0.0)),
|
||||||
|
outro=data.get("outro", []),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
footer=data.get("footer", ""),
|
||||||
|
output_video=data.get("output_video", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -157,18 +258,24 @@ def _parse_dimension(value: Any) -> tuple[int, float]:
|
|||||||
return 200, 0.0 # default
|
return 200, 0.0 # default
|
||||||
|
|
||||||
|
|
||||||
def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str, SlideDefinition]:
|
def parse_slides(
|
||||||
|
project_path: Path, config: ProjectConfig = None
|
||||||
|
) -> dict[str, SlideDefinition]:
|
||||||
"""Parse slides.json into slide definitions."""
|
"""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 = _read_json(slides_path)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise ParseError(f"Invalid JSON: {e}", slides_path)
|
raise ParseError(f"Invalid JSON: {e}", slides_path)
|
||||||
|
|
||||||
@@ -176,8 +283,7 @@ def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str,
|
|||||||
for slide_id, slide_data in data.items():
|
for slide_id, slide_data in data.items():
|
||||||
if "image" not in slide_data:
|
if "image" not in slide_data:
|
||||||
raise ParseError(
|
raise ParseError(
|
||||||
f"Slide '{slide_id}' missing required field 'image'",
|
f"Slide '{slide_id}' missing required field 'image'", slides_path
|
||||||
slides_path
|
|
||||||
)
|
)
|
||||||
slides[slide_id] = SlideDefinition(
|
slides[slide_id] = SlideDefinition(
|
||||||
image=slide_data["image"],
|
image=slide_data["image"],
|
||||||
@@ -187,32 +293,307 @@ def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str,
|
|||||||
return slides
|
return slides
|
||||||
|
|
||||||
|
|
||||||
def parse_videos(project_path: Path) -> dict[str, VideoSource]:
|
def parse_audio(
|
||||||
"""Parse videos.json into video source definitions."""
|
project_path: Path, config: Optional[ProjectConfig] = None
|
||||||
videos_path = project_path / "videos.json"
|
) -> tuple[dict[str, AudioDefinition], Path]:
|
||||||
|
"""
|
||||||
|
Parse audio.json into audio definitions.
|
||||||
|
|
||||||
if not videos_path.exists():
|
Returns:
|
||||||
raise ParseError("videos.json not found", videos_path)
|
Tuple of (audio dict, audio_dir) where audio_dir is the directory
|
||||||
|
containing audio.json (for resolving relative file paths).
|
||||||
|
"""
|
||||||
|
if config and config.audio_path:
|
||||||
|
local_audio_path = project_path / config.audio_path
|
||||||
|
else:
|
||||||
|
local_audio_path = project_path / "audio.json"
|
||||||
|
|
||||||
|
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||||
|
audio_dir = local_audio_path.parent
|
||||||
|
|
||||||
|
# Try cache fallback for reading JSON
|
||||||
|
audio_path, _ = resolve_with_cache(local_audio_path, project_path)
|
||||||
|
|
||||||
|
# Audio is optional - return empty dict if not found
|
||||||
|
if not audio_path.exists():
|
||||||
|
return {}, audio_dir
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(videos_path.read_text(encoding="utf-8"))
|
data = _read_json(audio_path)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ParseError(f"Invalid JSON: {e}", audio_path)
|
||||||
|
|
||||||
|
audio = {}
|
||||||
|
for audio_id, audio_data in data.items():
|
||||||
|
if "file" not in audio_data:
|
||||||
|
raise ParseError(
|
||||||
|
f"Audio '{audio_id}' missing required field 'file'", audio_path
|
||||||
|
)
|
||||||
|
# Parse overlap if specified (timestamp string like "10s")
|
||||||
|
overlap = None
|
||||||
|
if "overlap" in audio_data and audio_data["overlap"]:
|
||||||
|
overlap = parse_timestamp(audio_data["overlap"])
|
||||||
|
|
||||||
|
raw_duration = audio_data.get("duration")
|
||||||
|
audio[audio_id] = AudioDefinition(
|
||||||
|
file=audio_data["file"],
|
||||||
|
volume=float(audio_data.get("volume", 1.0)),
|
||||||
|
loop=bool(audio_data.get("loop", False)),
|
||||||
|
overlap=overlap,
|
||||||
|
ignore_pauses=bool(audio_data.get("ignore_pauses", False)),
|
||||||
|
duration=float(raw_duration) if raw_duration is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return audio, audio_dir
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(value: str) -> float:
|
||||||
|
"""
|
||||||
|
Parse a timestamp string into seconds.
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
- "3.5s" or "3.5" → 3.5 seconds
|
||||||
|
- "2:54" → 2 minutes 54 seconds (174.0)
|
||||||
|
- "1:23:45" → 1 hour 23 minutes 45 seconds
|
||||||
|
- "2:54.5" → 2 minutes 54.5 seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Time in seconds as a float.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
# Remove trailing 's' if present (e.g., "3.5s")
|
||||||
|
if value.endswith("s"):
|
||||||
|
value = value[:-1]
|
||||||
|
|
||||||
|
# Check for colon-separated format (MM:SS or HH:MM:SS)
|
||||||
|
if ":" in value:
|
||||||
|
parts = value.split(":")
|
||||||
|
if len(parts) == 2:
|
||||||
|
# MM:SS format
|
||||||
|
minutes, seconds = parts
|
||||||
|
return float(minutes) * 60 + float(seconds)
|
||||||
|
elif len(parts) == 3:
|
||||||
|
# HH:MM:SS format
|
||||||
|
hours, minutes, seconds = parts
|
||||||
|
return float(hours) * 3600 + float(minutes) * 60 + float(seconds)
|
||||||
|
else:
|
||||||
|
raise ParseError(f"Invalid timestamp format: {value}", None)
|
||||||
|
|
||||||
|
# Plain number (seconds)
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_videos(
|
||||||
|
project_path: Path, config: Optional[ProjectConfig] = None
|
||||||
|
) -> tuple[dict[str, VideoSource], Path]:
|
||||||
|
"""
|
||||||
|
Parse videos.json into video source definitions.
|
||||||
|
|
||||||
|
Filter can be specified as:
|
||||||
|
- A list of filter configs (inline definition)
|
||||||
|
- A string referencing a named preset in config.default_filters
|
||||||
|
|
||||||
|
Trim points can be specified as:
|
||||||
|
- skip/take: raw values in seconds (traditional)
|
||||||
|
- begin/end: timestamp strings like "3.5s", "2:54", "1:23:45" (user-friendly)
|
||||||
|
These are converted to skip/take internally.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (videos dict, videos_dir) where videos_dir is the directory
|
||||||
|
containing videos.json (for resolving relative file paths).
|
||||||
|
"""
|
||||||
|
if config and config.videos_path:
|
||||||
|
local_videos_path = project_path / config.videos_path
|
||||||
|
else:
|
||||||
|
local_videos_path = project_path / "videos.json"
|
||||||
|
|
||||||
|
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||||
|
videos_dir = local_videos_path.parent
|
||||||
|
|
||||||
|
# Try cache fallback for reading JSON
|
||||||
|
videos_path, _ = resolve_with_cache(local_videos_path, project_path)
|
||||||
|
if not videos_path.exists():
|
||||||
|
raise ParseError(
|
||||||
|
f"videos.json not found: {local_videos_path}", local_videos_path
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _read_json(videos_path)
|
||||||
except json.JSONDecodeError as e:
|
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 "file" not in video_data:
|
if "source_file" not in video_data:
|
||||||
raise ParseError(
|
raise ParseError(
|
||||||
f"Video '{video_id}' missing required field 'file'",
|
f"Video '{video_id}' missing required field 'source_file'", videos_path
|
||||||
videos_path
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parse attribution if present
|
||||||
|
attribution = None
|
||||||
|
if "attribution" in video_data:
|
||||||
|
attr_data = video_data["attribution"]
|
||||||
|
attribution = Attribution(
|
||||||
|
source=attr_data.get("source", "unknown"),
|
||||||
|
creator=attr_data.get("creator", "Unknown"),
|
||||||
|
url=attr_data.get("url"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve filter - can be a list or a string reference to default_filters
|
||||||
|
filter_value = video_data.get("filter", [])
|
||||||
|
if isinstance(filter_value, str):
|
||||||
|
# It's a reference to a named filter preset
|
||||||
|
if filter_value not in default_filters:
|
||||||
|
raise ParseError(
|
||||||
|
f"Video '{video_id}' references unknown filter preset '{filter_value}'. "
|
||||||
|
f"Available presets: {list(default_filters.keys())}",
|
||||||
|
videos_path,
|
||||||
|
)
|
||||||
|
filter_list = default_filters[filter_value]
|
||||||
|
else:
|
||||||
|
# It's an inline filter definition
|
||||||
|
filter_list = filter_value
|
||||||
|
|
||||||
|
# Handle skip/take - can use begin/end as user-friendly alternatives
|
||||||
|
skip = video_data.get("skip", 0.0)
|
||||||
|
take = video_data.get("take")
|
||||||
|
|
||||||
|
# Convert begin/end to skip/take if provided
|
||||||
|
if "begin" in video_data and video_data["begin"]:
|
||||||
|
skip = parse_timestamp(video_data["begin"])
|
||||||
|
if "end" in video_data and video_data["end"]:
|
||||||
|
end_time = parse_timestamp(video_data["end"])
|
||||||
|
# take = end - begin (duration from begin to end)
|
||||||
|
take = end_time - skip
|
||||||
|
|
||||||
|
raw_duration = video_data.get("duration")
|
||||||
|
raw_has_audio = video_data.get("has_audio")
|
||||||
videos[video_id] = VideoSource(
|
videos[video_id] = VideoSource(
|
||||||
file=video_data["file"],
|
source_file=video_data["source_file"],
|
||||||
preprocess=video_data.get("preprocess", []),
|
filter=filter_list,
|
||||||
output_file=video_data.get("output_file"),
|
output_file=video_data.get("output_file"),
|
||||||
|
take=take,
|
||||||
|
skip=skip,
|
||||||
|
zoom=video_data.get("zoom", 1.0),
|
||||||
|
cutout=video_data.get("cutout"),
|
||||||
|
always_visible=video_data.get("always_visible", False),
|
||||||
|
is_shared=video_data.get("is_shared", False),
|
||||||
|
pause_narration=float(video_data.get("pause_narration", 0)),
|
||||||
|
attribution=attribution,
|
||||||
|
use_audio_channels=video_data.get("use_audio_channels", "both"),
|
||||||
|
defer_loudnorm=video_data.get("defer_loudnorm", False),
|
||||||
|
volume=float(video_data.get("volume", 1.0)),
|
||||||
|
layer=video_data.get("layer", "above"),
|
||||||
|
duration=float(raw_duration) if raw_duration is not None else None,
|
||||||
|
has_audio=bool(raw_has_audio) if raw_has_audio is not None else None,
|
||||||
|
end_on=video_data.get("end_on"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return videos
|
return videos, videos_dir
|
||||||
|
|
||||||
|
|
||||||
|
def parse_narration(
|
||||||
|
project_path: Path, config: Optional[ProjectConfig] = None
|
||||||
|
) -> tuple[dict[str, VideoSource], Path]:
|
||||||
|
"""
|
||||||
|
Parse narration.json into narration segment definitions.
|
||||||
|
|
||||||
|
Narration segments are stored in media/narration/ and are processed
|
||||||
|
separately from videos. Each segment can have filters, begin/end trim
|
||||||
|
points, and other properties similar to videos.
|
||||||
|
|
||||||
|
Filter can be specified as:
|
||||||
|
- A list of filter configs (inline definition)
|
||||||
|
- A string referencing a named preset in config.default_filters
|
||||||
|
|
||||||
|
Trim points can be specified as:
|
||||||
|
- skip/take: raw values in seconds (traditional)
|
||||||
|
- begin/end: timestamp strings like "3.5s", "2:54", "1:23:45" (user-friendly)
|
||||||
|
These are converted to skip/take internally.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (narration dict, narration_dir) where narration_dir is the directory
|
||||||
|
containing narration.json (for resolving relative file paths).
|
||||||
|
"""
|
||||||
|
# Narration is always in media/narration/
|
||||||
|
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||||
|
narration_dir = project_path / "media" / "narration"
|
||||||
|
local_narration_path = narration_dir / "narration.json"
|
||||||
|
|
||||||
|
# Try cache fallback for reading JSON
|
||||||
|
narration_path, _ = resolve_with_cache(local_narration_path, project_path)
|
||||||
|
|
||||||
|
# Narration is optional - return empty dict if not found
|
||||||
|
if not narration_path.exists():
|
||||||
|
return {}, narration_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _read_json(narration_path)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ParseError(f"Invalid JSON: {e}", narration_path)
|
||||||
|
|
||||||
|
# Get default_filters from config for resolving references
|
||||||
|
default_filters = config.default_filters if config else {}
|
||||||
|
|
||||||
|
narration = {}
|
||||||
|
for segment_id, segment_data in data.items():
|
||||||
|
if "source_file" not in segment_data:
|
||||||
|
raise ParseError(
|
||||||
|
f"Narration segment '{segment_id}' missing required field 'source_file'",
|
||||||
|
narration_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve filter - can be a list or a string reference to default_filters
|
||||||
|
filter_value = segment_data.get("filter", [])
|
||||||
|
if isinstance(filter_value, str):
|
||||||
|
# It's a reference to a named filter preset
|
||||||
|
if filter_value not in default_filters:
|
||||||
|
raise ParseError(
|
||||||
|
f"Narration segment '{segment_id}' references unknown filter preset '{filter_value}'. "
|
||||||
|
f"Available presets: {list(default_filters.keys())}",
|
||||||
|
narration_path,
|
||||||
|
)
|
||||||
|
filter_list = default_filters[filter_value]
|
||||||
|
else:
|
||||||
|
# It's an inline filter definition
|
||||||
|
filter_list = filter_value
|
||||||
|
|
||||||
|
# Handle skip/take - can use begin/end as user-friendly alternatives
|
||||||
|
# Fall back to project-level defaults if no explicit value is set
|
||||||
|
default_begin = config.default_begin if config else 0.0
|
||||||
|
skip = segment_data.get("skip", default_begin)
|
||||||
|
take = segment_data.get("take")
|
||||||
|
|
||||||
|
# Explicit begin/end always override defaults
|
||||||
|
if "begin" in segment_data and segment_data["begin"]:
|
||||||
|
skip = parse_timestamp(segment_data["begin"])
|
||||||
|
if "end" in segment_data and segment_data["end"]:
|
||||||
|
end_time = parse_timestamp(segment_data["end"])
|
||||||
|
# take = end - begin (duration from begin to end)
|
||||||
|
take = end_time - skip
|
||||||
|
|
||||||
|
narration[segment_id] = VideoSource(
|
||||||
|
source_file=segment_data["source_file"],
|
||||||
|
filter=filter_list,
|
||||||
|
output_file=segment_data.get("output_file"),
|
||||||
|
take=take,
|
||||||
|
skip=skip,
|
||||||
|
zoom=segment_data.get("zoom", 1.0),
|
||||||
|
cutout=segment_data.get("cutout"),
|
||||||
|
always_visible=segment_data.get("always_visible", False),
|
||||||
|
use_audio_channels=segment_data.get("use_audio_channels", "both"),
|
||||||
|
defer_loudnorm=segment_data.get("defer_loudnorm", False),
|
||||||
|
volume=float(segment_data.get("volume", 1.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return narration, narration_dir
|
||||||
|
|
||||||
|
|
||||||
def get_video_duration(video_path: Path) -> float:
|
def get_video_duration(video_path: Path) -> float:
|
||||||
@@ -221,10 +602,13 @@ def get_video_duration(video_path: Path) -> float:
|
|||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe",
|
"ffprobe",
|
||||||
"-v", "error",
|
"-v",
|
||||||
"-show_entries", "format=duration",
|
"error",
|
||||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
"-show_entries",
|
||||||
str(video_path)
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
str(video_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
@@ -256,12 +640,14 @@ def parse_video_metadata(metadata_path: Path) -> VideoMetadata:
|
|||||||
raise ParseError(f"Video metadata not found: {metadata_path}", metadata_path)
|
raise ParseError(f"Video metadata not found: {metadata_path}", metadata_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(metadata_path.read_text(encoding="utf-8"))
|
data = _read_json(metadata_path)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise ParseError(f"Invalid JSON: {e}", metadata_path)
|
raise ParseError(f"Invalid JSON: {e}", metadata_path)
|
||||||
|
|
||||||
if "source_file" not in data:
|
if "source_file" not in data:
|
||||||
raise ParseError("Video metadata missing required field 'source_file'", metadata_path)
|
raise ParseError(
|
||||||
|
"Video metadata missing required field 'source_file'", metadata_path
|
||||||
|
)
|
||||||
|
|
||||||
return VideoMetadata(
|
return VideoMetadata(
|
||||||
source_file=data["source_file"],
|
source_file=data["source_file"],
|
||||||
@@ -270,7 +656,9 @@ def parse_video_metadata(metadata_path: Path) -> VideoMetadata:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_video_file(project_path: Path, file_ref: str) -> tuple[Path, Optional[VideoMetadata]]:
|
def resolve_video_file(
|
||||||
|
project_path: Path, file_ref: str
|
||||||
|
) -> tuple[Path, Optional[VideoMetadata]]:
|
||||||
"""
|
"""
|
||||||
Resolve a video file reference, which can be either:
|
Resolve a video file reference, which can be either:
|
||||||
1. A direct path to a video file
|
1. A direct path to a video file
|
||||||
|
|||||||
+2254
-66
File diff suppressed because it is too large
Load Diff
+233
@@ -0,0 +1,233 @@
|
|||||||
|
"""Pull project metadata from gnommoweb server.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
gnommo pull -p video1 # pull parent video project
|
||||||
|
gnommo pull -p short_pixelated_universe # pull a short project
|
||||||
|
gnommo pull -p myproject --force # force pull, overwrite local
|
||||||
|
|
||||||
|
For a parent project: updates name, description, and the shorts index
|
||||||
|
(list of slugs) in project.json.
|
||||||
|
|
||||||
|
For a short project: updates title, hook, platform_targets, resolution,
|
||||||
|
fps, duration_seconds. Preserves local script path reference.
|
||||||
|
|
||||||
|
Conflict detection:
|
||||||
|
- If local project.json mtime > last_pushed_at → local has unpushed changes
|
||||||
|
→ warn and abort unless --force
|
||||||
|
|
||||||
|
Configuration (from .env or environment):
|
||||||
|
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
|
||||||
|
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"Error: 'requests' package is required. Run: pip install requests",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
SYNC_FILE_LOCAL = ".gnommo_sync.json"
|
||||||
|
SYNC_FILE_PROD = ".gnommo_sync.prod.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_file(prod: bool) -> str:
|
||||||
|
return SYNC_FILE_PROD if prod else SYNC_FILE_LOCAL
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file():
|
||||||
|
env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
with open(env_path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_sync(project_path: Path, prod: bool = False) -> dict:
|
||||||
|
sync_file = project_path / _sync_file(prod)
|
||||||
|
if sync_file.exists():
|
||||||
|
with open(sync_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sync(project_path: Path, data: dict, prod: bool = False):
|
||||||
|
with open(project_path / _sync_file(prod), "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts_str) -> datetime | None:
|
||||||
|
if not ts_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_pull(
|
||||||
|
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
|
||||||
|
) -> int:
|
||||||
|
_load_env_file()
|
||||||
|
|
||||||
|
if prod:
|
||||||
|
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||||
|
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||||
|
if not api_url:
|
||||||
|
print("Error: GNOMMOWEB_PROD_URL is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not api_key:
|
||||||
|
print("Error: GNOMMOWEB_PROD_API_KEY is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||||
|
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||||
|
if not api_url:
|
||||||
|
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not api_key:
|
||||||
|
print("Error: GNOMMOWEB_API_KEY is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
target = "production" if prod else "local"
|
||||||
|
print(f" → {target}: {api_url}")
|
||||||
|
|
||||||
|
project_file = project_path / "project.json"
|
||||||
|
if not project_file.exists():
|
||||||
|
print(f"Error: {project_file} not found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(project_file) as f:
|
||||||
|
local_project = json.load(f)
|
||||||
|
|
||||||
|
project_id = local_project.get("id")
|
||||||
|
if not project_id:
|
||||||
|
print("Error: project.json missing 'id'.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ── Conflict check ────────────────────────────────────────────────────────
|
||||||
|
if not force:
|
||||||
|
sync = _read_sync(project_path, prod)
|
||||||
|
last_pushed_at = _parse_ts(sync.get("last_pushed_at"))
|
||||||
|
local_mtime = datetime.fromtimestamp(
|
||||||
|
project_file.stat().st_mtime, tz=timezone.utc
|
||||||
|
)
|
||||||
|
if last_pushed_at and local_mtime > last_pushed_at:
|
||||||
|
print(
|
||||||
|
f"⚠ project.json has local changes since last push "
|
||||||
|
f"({local_mtime.strftime('%Y-%m-%d %H:%M')} > "
|
||||||
|
f"{last_pushed_at.strftime('%Y-%m-%d %H:%M')}).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Push first with `gnommo push -p` or use `gnommo pull -p --force`.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ── Fetch from server ─────────────────────────────────────────────────────
|
||||||
|
if verbose:
|
||||||
|
print(f"Pulling {project_id} from {api_url}…")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
f"{api_url}/api/projects/{project_id}",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f"✗ Could not connect to {api_url}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not r.ok:
|
||||||
|
if r.status_code == 404:
|
||||||
|
print(f"✗ Project '{project_id}' not found on server. Push it first.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
body = r.json()
|
||||||
|
except Exception:
|
||||||
|
body = r.text[:500]
|
||||||
|
print(f"✗ Server returned {r.status_code}: {body}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
server = r.json()
|
||||||
|
server_updated_at = server.get("updated_at")
|
||||||
|
project_type = server.get("type")
|
||||||
|
|
||||||
|
# ── Merge into project.json ───────────────────────────────────────────────
|
||||||
|
if project_type == "parent":
|
||||||
|
_merge_parent(local_project, server, verbose)
|
||||||
|
count = len(server.get("shorts", []))
|
||||||
|
print(f"✓ Pulled {project_id} (parent video) — {count} short(s) in index")
|
||||||
|
elif project_type == "short":
|
||||||
|
_merge_short(local_project, server, verbose)
|
||||||
|
print(f"✓ Pulled {project_id} (short) — [{server.get('status')}]")
|
||||||
|
else:
|
||||||
|
print(f"Error: unexpected project type: {project_type}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ── Write back ────────────────────────────────────────────────────────────
|
||||||
|
with open(project_file, "w") as f:
|
||||||
|
json.dump(local_project, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||||
|
existing_sync = _read_sync(project_path, prod)
|
||||||
|
_write_sync(
|
||||||
|
project_path,
|
||||||
|
{
|
||||||
|
**existing_sync,
|
||||||
|
"last_pulled_at": now_iso,
|
||||||
|
"server_updated_at": server_updated_at,
|
||||||
|
"last_pushed_at": existing_sync.get("last_pushed_at"),
|
||||||
|
},
|
||||||
|
prod,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_parent(local: dict, server: dict, verbose: bool):
|
||||||
|
"""Update parent project.json: name, description, shorts index (slugs)."""
|
||||||
|
local["name"] = server.get("title", local.get("name"))
|
||||||
|
local["description"] = server.get("description") or local.get("description")
|
||||||
|
# shorts is a list of slugs — update from server's shorts list
|
||||||
|
server_shorts = server.get("shorts", [])
|
||||||
|
local["shorts"] = [s["project_id"] for s in server_shorts]
|
||||||
|
if verbose:
|
||||||
|
print(f" shorts index: {local['shorts']}")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_short(local: dict, server: dict, verbose: bool):
|
||||||
|
"""Update short project.json: name, hook, platform_targets, resolution, fps, duration."""
|
||||||
|
local["name"] = server.get("title", local.get("name"))
|
||||||
|
if server.get("hook"):
|
||||||
|
local["hook"] = server["hook"]
|
||||||
|
if server.get("platform_targets"):
|
||||||
|
local["platform_targets"] = server["platform_targets"]
|
||||||
|
if server.get("resolution"):
|
||||||
|
local["resolution"] = server["resolution"]
|
||||||
|
if server.get("fps"):
|
||||||
|
local["fps"] = server["fps"]
|
||||||
|
if server.get("duration_seconds"):
|
||||||
|
local["duration_seconds"] = server["duration_seconds"]
|
||||||
|
if server.get("parent_project_id"):
|
||||||
|
local["parent_project"] = server["parent_project_id"]
|
||||||
|
# Never overwrite local script path — that stays local
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
"""Push project metadata to gnommoweb server.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
gnommo push -p video1 # push parent video project
|
||||||
|
gnommo push -p short_pixelated_universe # push a short project
|
||||||
|
gnommo push -p myproject --force # force push, overwrite server
|
||||||
|
|
||||||
|
Reads project.json and POSTs to POST /api/projects/push.
|
||||||
|
|
||||||
|
If project.json contains a "parent_project" field, the project is pushed
|
||||||
|
as a short and registered under that parent. Otherwise it is pushed as a
|
||||||
|
parent video project.
|
||||||
|
|
||||||
|
Parent project.json "shorts" field is a list of slugs (just an index):
|
||||||
|
"shorts": ["short_pixelated_universe", "short_planck_length"]
|
||||||
|
|
||||||
|
Short project.json has its own full config plus a parent_project field:
|
||||||
|
{
|
||||||
|
"id": "short_pixelated_universe",
|
||||||
|
"parent_project": "Video1",
|
||||||
|
"resolution": [1080, 1920],
|
||||||
|
"fps": 30,
|
||||||
|
"duration_seconds": 60,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
Conflict detection:
|
||||||
|
- If server.updated_at > our recorded server_updated_at → server has newer changes
|
||||||
|
→ warn and abort unless --force
|
||||||
|
|
||||||
|
Configuration (from .env or environment):
|
||||||
|
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
|
||||||
|
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"Error: 'requests' package is required. Run: pip install requests",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
SYNC_FILE_LOCAL = ".gnommo_sync.json"
|
||||||
|
SYNC_FILE_PROD = ".gnommo_sync.prod.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_file(prod: bool) -> str:
|
||||||
|
return SYNC_FILE_PROD if prod else SYNC_FILE_LOCAL
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file():
|
||||||
|
env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
with open(env_path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_sync(project_path: Path, prod: bool = False) -> dict:
|
||||||
|
sync_file = project_path / _sync_file(prod)
|
||||||
|
if sync_file.exists():
|
||||||
|
with open(sync_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sync(project_path: Path, data: dict, prod: bool = False):
|
||||||
|
with open(project_path / _sync_file(prod), "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts_str) -> datetime | None:
|
||||||
|
if not ts_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_push(
|
||||||
|
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
|
||||||
|
) -> int:
|
||||||
|
_load_env_file()
|
||||||
|
|
||||||
|
if prod:
|
||||||
|
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||||
|
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||||
|
if not api_url:
|
||||||
|
print("Error: GNOMMOWEB_PROD_URL is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not api_key:
|
||||||
|
print("Error: GNOMMOWEB_PROD_API_KEY is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||||
|
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||||
|
if not api_url:
|
||||||
|
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not api_key:
|
||||||
|
print("Error: GNOMMOWEB_API_KEY is not set.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
target = "production" if prod else "local"
|
||||||
|
print(f" → {target}: {api_url}")
|
||||||
|
|
||||||
|
project_file = project_path / "project.json"
|
||||||
|
if not project_file.exists():
|
||||||
|
print(f"Error: {project_file} not found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(project_file) as f:
|
||||||
|
project = json.load(f)
|
||||||
|
|
||||||
|
project_id = project.get("id")
|
||||||
|
name = project.get("name")
|
||||||
|
if not project_id or not name:
|
||||||
|
print("Error: project.json must have 'id' and 'name' fields.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parent_project = project.get("parent_project")
|
||||||
|
|
||||||
|
# ── Build payload ─────────────────────────────────────────────────────────
|
||||||
|
if parent_project:
|
||||||
|
payload = _build_short_payload(project, project_path, verbose)
|
||||||
|
else:
|
||||||
|
payload = _build_parent_payload(project, project_path, verbose)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
kind = "short" if parent_project else "parent video"
|
||||||
|
print(f"Pushing {project_id} ({kind}) to {api_url}")
|
||||||
|
|
||||||
|
# ── POST ──────────────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
f"{api_url}/api/projects/push",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f"✗ Could not connect to {api_url}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not r.ok:
|
||||||
|
try:
|
||||||
|
body = r.json()
|
||||||
|
except Exception:
|
||||||
|
body = r.text[:500]
|
||||||
|
print(f"✗ Server returned {r.status_code}: {body}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
result = r.json()
|
||||||
|
server_updated_at = result.get("server_updated_at")
|
||||||
|
|
||||||
|
# ── Write sync state ──────────────────────────────────────────────────────
|
||||||
|
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||||
|
existing_sync = _read_sync(project_path, prod)
|
||||||
|
_write_sync(
|
||||||
|
project_path,
|
||||||
|
{
|
||||||
|
**existing_sync,
|
||||||
|
"last_pushed_at": now_iso,
|
||||||
|
"server_updated_at": server_updated_at,
|
||||||
|
},
|
||||||
|
prod,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Print summary ─────────────────────────────────────────────────────────
|
||||||
|
asset = result.get("asset", {})
|
||||||
|
if result.get("type") == "short":
|
||||||
|
print(f"✓ {project_id} → gn_asset #{asset.get('id')} [{asset.get('status')}]")
|
||||||
|
if result.get("task_created"):
|
||||||
|
print(f" task #{result['task_id']} created")
|
||||||
|
else:
|
||||||
|
print(f"✓ {project_id} → gn_asset #{asset.get('id')} ({asset.get('name')})")
|
||||||
|
if verbose:
|
||||||
|
script_len = len(asset.get("script") or "")
|
||||||
|
print(
|
||||||
|
f" server.script: {script_len} chars | fps={asset.get('fps')} res={asset.get('resolution')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> dict:
|
||||||
|
# Read the manuscript file if one is specified
|
||||||
|
script_content = None
|
||||||
|
manuscript_str = project.get("manuscript")
|
||||||
|
if manuscript_str:
|
||||||
|
manuscript_path = project_path / manuscript_str
|
||||||
|
if manuscript_path.exists():
|
||||||
|
try:
|
||||||
|
script_content = manuscript_path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
script_content = manuscript_path.read_text(encoding="latin-1")
|
||||||
|
print(f" Warning: manuscript is not UTF-8, read as latin-1")
|
||||||
|
print(f" manuscript: {len(script_content)} chars")
|
||||||
|
else:
|
||||||
|
print(f" Warning: manuscript not found: {manuscript_path}")
|
||||||
|
else:
|
||||||
|
if verbose:
|
||||||
|
print(f" no manuscript field in project.json")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_id": project["id"],
|
||||||
|
"name": project["name"],
|
||||||
|
"description": project.get("description"),
|
||||||
|
"coursecode": project.get("coursecode"),
|
||||||
|
"script_content": script_content,
|
||||||
|
"resolution": project.get("resolution"),
|
||||||
|
"fps": project.get("fps"),
|
||||||
|
"duration_seconds": project.get("duration_seconds"),
|
||||||
|
"hook": project.get("hook"),
|
||||||
|
"platform_targets": project.get("platform_targets"),
|
||||||
|
"status": project.get("status"),
|
||||||
|
"youtube_url": project.get("youtube_url"),
|
||||||
|
"shorts": project.get("shorts", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_short_payload(project: dict, project_path: Path, verbose: bool) -> dict:
|
||||||
|
# Read the script file if one is specified
|
||||||
|
script_content = None
|
||||||
|
script_path_str = project.get("script")
|
||||||
|
if script_path_str:
|
||||||
|
script_path = project_path / script_path_str
|
||||||
|
if script_path.exists():
|
||||||
|
script_content = script_path.read_text()
|
||||||
|
if verbose:
|
||||||
|
print(f" Read script: {script_path} ({len(script_content)} chars)")
|
||||||
|
else:
|
||||||
|
print(f" Warning: script file not found: {script_path}", file=sys.stderr)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_id": project["id"],
|
||||||
|
"name": project["name"],
|
||||||
|
"description": project.get("description"),
|
||||||
|
"parent_project": project["parent_project"],
|
||||||
|
"hook": project.get("hook"),
|
||||||
|
"script_content": script_content,
|
||||||
|
"platform_targets": project.get("platform_targets", ["youtube"]),
|
||||||
|
"resolution": project.get("resolution"),
|
||||||
|
"fps": project.get("fps"),
|
||||||
|
"duration_seconds": project.get("duration_seconds"),
|
||||||
|
}
|
||||||
+1187
-77
File diff suppressed because it is too large
Load Diff
+111
-13
@@ -5,12 +5,15 @@ 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
|
||||||
class TranscribedWord:
|
class TranscribedWord:
|
||||||
"""A word with its timestamp from transcription."""
|
"""A word with its timestamp from transcription."""
|
||||||
|
|
||||||
word: str
|
word: str
|
||||||
start: float
|
start: float
|
||||||
end: float
|
end: float
|
||||||
@@ -18,6 +21,7 @@ class TranscribedWord:
|
|||||||
|
|
||||||
class TranscriptionError(GnommoError):
|
class TranscriptionError(GnommoError):
|
||||||
"""Error during transcription."""
|
"""Error during transcription."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -57,28 +61,38 @@ def transcribe_video(video_path: Path, model: str = "base") -> list[TranscribedW
|
|||||||
|
|
||||||
for segment in result.get("segments", []):
|
for segment in result.get("segments", []):
|
||||||
for word_info in segment.get("words", []):
|
for word_info in segment.get("words", []):
|
||||||
words.append(TranscribedWord(
|
words.append(
|
||||||
word=word_info["word"].strip(),
|
TranscribedWord(
|
||||||
start=word_info["start"],
|
word=word_info["word"].strip(),
|
||||||
end=word_info["end"],
|
start=word_info["start"],
|
||||||
))
|
end=word_info["end"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return words
|
return words
|
||||||
|
|
||||||
|
|
||||||
def save_transcript(words: list[TranscribedWord], output_path: Path) -> None:
|
def save_transcript(words: list[TranscribedWord], output_path: Path) -> None:
|
||||||
"""Save transcribed words to a JSON file."""
|
"""Save transcribed words to a JSON file."""
|
||||||
data = [
|
data = [{"word": w.word, "start": w.start, "end": w.end} for w in words]
|
||||||
{"word": w.word, "start": w.start, "end": w.end}
|
|
||||||
for w in words
|
|
||||||
]
|
|
||||||
|
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
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}")
|
||||||
|
|
||||||
@@ -86,6 +100,90 @@ def load_transcript(transcript_path: Path) -> list[TranscribedWord]:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
TranscribedWord(word=w["word"], start=w["start"], end=w["end"])
|
TranscribedWord(word=w["word"], start=w["start"], end=w["end"]) for w in data
|
||||||
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)
|
||||||
|
|||||||
+1124
-57
File diff suppressed because it is too large
Load Diff
+202
-68
@@ -2,8 +2,16 @@
|
|||||||
|
|
||||||
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 ProjectConfig, SlideDefinition, VideoSource, SLIDE_LAYOUTS
|
from .parser import _read_json
|
||||||
|
from .models import (
|
||||||
|
ProjectConfig,
|
||||||
|
SlideDefinition,
|
||||||
|
VideoSource,
|
||||||
|
SLIDE_LAYOUTS,
|
||||||
|
CAMERA_PRESETS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_project(
|
def validate_project(
|
||||||
@@ -12,10 +20,12 @@ def validate_project(
|
|||||||
config: ProjectConfig,
|
config: ProjectConfig,
|
||||||
slides: dict[str, SlideDefinition],
|
slides: dict[str, SlideDefinition],
|
||||||
videos: dict[str, VideoSource],
|
videos: dict[str, VideoSource],
|
||||||
|
videos_dir: Path,
|
||||||
malformed_markers: list[tuple[int, str]] = None,
|
malformed_markers: list[tuple[int, str]] = None,
|
||||||
) -> None:
|
) -> list[ValidationIssue]:
|
||||||
"""
|
"""
|
||||||
Validate all parsed project data. Raises ValidationError if any issues found.
|
Validate all parsed project data. Raises ValidationError if any issues found.
|
||||||
|
Returns a list of warnings (non-fatal issues).
|
||||||
|
|
||||||
Checks:
|
Checks:
|
||||||
- All slide markers in manuscript exist in slides.json
|
- All slide markers in manuscript exist in slides.json
|
||||||
@@ -26,23 +36,88 @@ def validate_project(
|
|||||||
- No malformed markers in manuscript
|
- No malformed markers in manuscript
|
||||||
"""
|
"""
|
||||||
issues: list[ValidationIssue] = []
|
issues: list[ValidationIssue] = []
|
||||||
|
warnings: list[ValidationIssue] = []
|
||||||
|
|
||||||
# Check for malformed markers first (these are likely typos)
|
# Check for malformed markers first (these are likely typos)
|
||||||
if malformed_markers:
|
if malformed_markers:
|
||||||
for line_num, marker_text in malformed_markers:
|
for line_num, marker_text in malformed_markers:
|
||||||
issues.append(ValidationIssue(
|
issues.append(
|
||||||
f"Malformed marker: {marker_text}",
|
ValidationIssue(
|
||||||
project_path / "manuscript.txt",
|
f"Malformed marker: {marker_text}",
|
||||||
line_num
|
project_path / "manuscript.txt",
|
||||||
))
|
line_num,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Check all manuscript markers have corresponding slides
|
# Check all manuscript markers have corresponding slides or videos
|
||||||
for marker in manuscript_markers:
|
for marker in manuscript_markers:
|
||||||
|
# Skip camera effect markers (Zoom0, TiltLeft, Reset, etc.)
|
||||||
|
if marker in CAMERA_PRESETS:
|
||||||
|
continue
|
||||||
|
# Skip audio markers (start with 'A' followed by audio id, e.g., Awoosh)
|
||||||
|
if marker.startswith("A") and len(marker) > 1 and marker[1:].isalnum():
|
||||||
|
continue
|
||||||
|
# Validate video trigger markers — both legacy [video:xxx] and
|
||||||
|
# shorthand [vft:xxx] / [vfb:xxx] / [vst:xxx] / [vsb:xxx].
|
||||||
|
_VIDEO_PREFIXES = {
|
||||||
|
"video:": 6,
|
||||||
|
"vft:": 4,
|
||||||
|
"vfb:": 4,
|
||||||
|
"vst:": 4,
|
||||||
|
"vsb:": 4,
|
||||||
|
}
|
||||||
|
matched_prefix = next(
|
||||||
|
(p for p in _VIDEO_PREFIXES if marker.startswith(p)), None
|
||||||
|
)
|
||||||
|
if matched_prefix is not None:
|
||||||
|
video_id = marker[_VIDEO_PREFIXES[matched_prefix] :]
|
||||||
|
if video_id not in videos:
|
||||||
|
hint = ""
|
||||||
|
if "." in video_id:
|
||||||
|
base_name = video_id.rsplit(".", 1)[0]
|
||||||
|
if base_name in videos:
|
||||||
|
hint = f" (Did you mean [{matched_prefix}{base_name}]? Don't include file extensions in markers)"
|
||||||
|
warnings.append(
|
||||||
|
ValidationIssue(
|
||||||
|
f"Video marker [{marker}] referenced in manuscript but '{video_id}' not defined in videos.json{hint} — using PlaceholderVideo instead",
|
||||||
|
project_path / "manuscript.txt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate narration trigger markers (narration:xxx) - continuous videos
|
||||||
|
if marker.startswith("narration:"):
|
||||||
|
video_id = marker[10:] # Remove 'narration:' prefix
|
||||||
|
if video_id not in videos:
|
||||||
|
warnings.append(
|
||||||
|
ValidationIssue(
|
||||||
|
f"Narration marker [{marker}] referenced in manuscript but '{video_id}' not defined in videos.json — using PlaceholderVideo instead",
|
||||||
|
project_path / "manuscript.txt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Segment markers are structural annotations, not slide references
|
||||||
|
if marker.startswith("segment:"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Unknown namespaced markers (e.g. [background:xxx]) — not supported, ignore with warning
|
||||||
|
if ":" in marker:
|
||||||
|
warnings.append(
|
||||||
|
ValidationIssue(
|
||||||
|
f"Unknown marker type [{marker}] — ignoring (no support for '{marker.split(':', 1)[0]}:' markers)",
|
||||||
|
project_path / "manuscript.txt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if marker not in slides:
|
if marker not in slides:
|
||||||
issues.append(ValidationIssue(
|
issues.append(
|
||||||
f"Slide marker [{marker}] referenced in manuscript but not defined in slides.json",
|
ValidationIssue(
|
||||||
project_path / "manuscript.txt"
|
f"Slide marker [{marker}] referenced in manuscript but not defined in slides.json",
|
||||||
))
|
project_path / "manuscript.txt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Check all slide images exist
|
# Check all slide images exist
|
||||||
# Slides are in the same directory as the slides.json file
|
# Slides are in the same directory as the slides.json file
|
||||||
@@ -51,82 +126,141 @@ def validate_project(
|
|||||||
|
|
||||||
for slide_id, slide_def in slides.items():
|
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(ValidationIssue(
|
issues.append(
|
||||||
f"Slide image not found: {slide_def.image}",
|
ValidationIssue(
|
||||||
slides_json_path
|
f"Slide image not found: {slide_def.image}", slides_json_path
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Check slide type is valid
|
# Check slide type is valid
|
||||||
if slide_def.type not in SLIDE_LAYOUTS:
|
if slide_def.type not in SLIDE_LAYOUTS:
|
||||||
issues.append(ValidationIssue(
|
issues.append(
|
||||||
f"Unknown slide type '{slide_def.type}' for slide {slide_id}. "
|
ValidationIssue(
|
||||||
f"Valid types: {list(SLIDE_LAYOUTS.keys())}",
|
f"Unknown slide type '{slide_def.type}' for slide {slide_id}. "
|
||||||
project_path / "slides.json"
|
f"Valid types: {list(SLIDE_LAYOUTS.keys())}",
|
||||||
))
|
project_path / "slides.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check all video files exist (paths relative to videos_dir or shared_assets)
|
||||||
|
videos_json_path = project_path / config.videos_path
|
||||||
|
|
||||||
|
# Find shared_assets directory
|
||||||
|
shared_assets_dir = None
|
||||||
|
if (project_path / "shared_assets").exists():
|
||||||
|
shared_assets_dir = project_path / "shared_assets"
|
||||||
|
elif (project_path.parent / "shared_assets").exists():
|
||||||
|
shared_assets_dir = project_path.parent / "shared_assets"
|
||||||
|
|
||||||
# Check all video files exist
|
|
||||||
for video_id, video_source in videos.items():
|
for video_id, video_source in videos.items():
|
||||||
video_path = project_path / video_source.file
|
# Determine base directory based on is_shared flag
|
||||||
|
if video_source.is_shared:
|
||||||
|
if shared_assets_dir:
|
||||||
|
base_dir = shared_assets_dir
|
||||||
|
else:
|
||||||
|
issues.append(
|
||||||
|
ValidationIssue(
|
||||||
|
f"Video '{video_id}' has is_shared=true but shared_assets directory not found",
|
||||||
|
videos_json_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
base_dir = videos_dir
|
||||||
|
|
||||||
|
video_path = base_dir / video_source.source_file
|
||||||
|
video_path, _ = resolve_with_cache(video_path, project_path)
|
||||||
if not video_path.exists():
|
if not video_path.exists():
|
||||||
issues.append(ValidationIssue(
|
warnings.append(
|
||||||
f"Video file not found: {video_source.file}",
|
ValidationIssue(
|
||||||
project_path / "videos.json"
|
f"Video file not found: {video_source.source_file} — falling back to PlaceholderVideo",
|
||||||
))
|
videos_json_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Check preprocessed output exists if preprocessing is defined
|
# Check preprocessed output exists if filters are defined
|
||||||
if video_source.preprocess and video_source.output_file:
|
if video_source.filter and video_source.output_file:
|
||||||
output_path = project_path / 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(ValidationIssue(
|
issues.append(
|
||||||
f"Preprocessed output not found: {video_source.output_file}. "
|
ValidationIssue(
|
||||||
f"Run with -a preprocess first.",
|
f"Preprocessed output not found: {video_source.output_file}. "
|
||||||
project_path / "videos.json"
|
f"Run with -a preprocess first.",
|
||||||
))
|
videos_json_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Check background exists (image or video)
|
# Check background exists — must be a handle in shared_assets/videos.json
|
||||||
# Try 'background' first, fall back to deprecated 'background_video'
|
bg_handle = config.background
|
||||||
bg_file = config.background or config.background_video
|
if bg_handle:
|
||||||
if bg_file:
|
shared_assets_dir = project_path.parent / "shared_assets"
|
||||||
# Check in project folder first, then parent (for shared_assets)
|
videos_json_path_bg = shared_assets_dir / "videos.json"
|
||||||
bg_path = project_path / bg_file
|
if not videos_json_path_bg.exists():
|
||||||
if not bg_path.exists():
|
issues.append(
|
||||||
# Try parent directory (shared_assets at repo root)
|
ValidationIssue(
|
||||||
bg_path = project_path.parent / bg_file
|
f"shared_assets/videos.json not found (needed for background handle '{bg_handle}')",
|
||||||
if not bg_path.exists():
|
project_path / "project.json",
|
||||||
issues.append(ValidationIssue(
|
)
|
||||||
f"Background not found: {bg_file}",
|
)
|
||||||
project_path / "project.json"
|
else:
|
||||||
))
|
bg_videos = _read_json(videos_json_path_bg)
|
||||||
|
if bg_handle not in bg_videos:
|
||||||
|
issues.append(
|
||||||
|
ValidationIssue(
|
||||||
|
f"Background handle '{bg_handle}' not found in shared_assets/videos.json",
|
||||||
|
project_path / "project.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bg_path = shared_assets_dir / bg_videos[bg_handle]["source_file"]
|
||||||
|
if not bg_path.exists():
|
||||||
|
issues.append(
|
||||||
|
ValidationIssue(
|
||||||
|
f"Background file not found: {bg_path} (from handle '{bg_handle}')",
|
||||||
|
project_path / "project.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Check we have at least one video source
|
# Check videos.json exists (empty is fine — project may not need triggered videos)
|
||||||
if not videos:
|
if not (project_path / config.videos_path).exists():
|
||||||
issues.append(ValidationIssue(
|
issues.append(
|
||||||
"No video sources defined in videos.json",
|
ValidationIssue(
|
||||||
project_path / "videos.json"
|
"videos.json not found — run 'gnommo import' to create it",
|
||||||
))
|
project_path / "videos.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 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(ValidationIssue(
|
issues.append(
|
||||||
f"Resolution too small: {width}x{height}",
|
ValidationIssue(
|
||||||
project_path / "project.json"
|
f"Resolution too small: {width}x{height}", project_path / "project.json"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if width > 7680 or height > 4320:
|
if width > 7680 or height > 4320:
|
||||||
issues.append(ValidationIssue(
|
issues.append(
|
||||||
f"Resolution too large: {width}x{height} (max 8K)",
|
ValidationIssue(
|
||||||
project_path / "project.json"
|
f"Resolution too large: {width}x{height} (max 8K)",
|
||||||
))
|
project_path / "project.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Check FPS is reasonable
|
# Check FPS is reasonable
|
||||||
if config.fps < 1 or config.fps > 120:
|
if config.fps < 1 or config.fps > 120:
|
||||||
issues.append(ValidationIssue(
|
issues.append(
|
||||||
f"Invalid FPS: {config.fps} (must be 1-120)",
|
ValidationIssue(
|
||||||
project_path / "project.json"
|
f"Invalid FPS: {config.fps} (must be 1-120)",
|
||||||
))
|
project_path / "project.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# If any issues, raise ValidationError
|
# If any issues, raise ValidationError
|
||||||
if issues:
|
if issues:
|
||||||
raise ValidationError(issues)
|
raise ValidationError(issues)
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||||
|
QyNTUxOQAAACCDr3tCxUf7HC+9s9N0TF9EECMshm6/Epcr6kZzaZGv0AAAAKC+5OiPvuTo
|
||||||
|
jwAAAAtzc2gtZWQyNTUxOQAAACCDr3tCxUf7HC+9s9N0TF9EECMshm6/Epcr6kZzaZGv0A
|
||||||
|
AAAEBKyC2/ZfItNXIf/UcSTYaV/eWjX6uKIrvliO+sdFJUV4Ove0LFR/scL72z03RMX0QQ
|
||||||
|
IyyGbr8SlyvqRnNpka/QAAAAHGplbnMudGFuZHN0YWRAZWFnbGVjb25kb3Iubm8B
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIOve0LFR/scL72z03RMX0QQIyyGbr8SlyvqRnNpka/Q jens.tandstad@eaglecondor.no
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import gnommo
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("This is the main module.")
|
||||||
|
|
||||||
|
gnommo.main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
openai-whisper
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"last_pushed_at": "2026-03-13T09:44:12+00:00",
|
||||||
|
"server_updated_at": "2026-03-13T09:44:12.934Z",
|
||||||
|
"last_pulled_at": "2026-03-13T09:35:00+00:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "short_is_universe_pixelated",
|
||||||
|
"name": "Is the universe pixelated?",
|
||||||
|
"description": "What if space is made of tiny blocks? A 60-second take on discrete physics.",
|
||||||
|
"parent_project": "Video1",
|
||||||
|
"hook": "What if reality is fundamentally blocky — like Minecraft, but smaller?",
|
||||||
|
"platform_targets": [
|
||||||
|
"youtube"
|
||||||
|
],
|
||||||
|
"resolution": [
|
||||||
|
1080,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"fps": 30,
|
||||||
|
"duration_seconds": 60,
|
||||||
|
"script": "script.md",
|
||||||
|
"output_video": "short_is_universe_pixelated.mp4",
|
||||||
|
"keynote_file": "../video1/media/video1.key",
|
||||||
|
"background": "../video1/shared_assets/BlackBackground.mp4",
|
||||||
|
"slides": "../video1/media/slides/Video1/slides.json",
|
||||||
|
"defaultSlideType": "fullscreen",
|
||||||
|
"cutouts": {
|
||||||
|
"talkinghead": {
|
||||||
|
"x": "-23%",
|
||||||
|
"y": "10%",
|
||||||
|
"height": "90%"
|
||||||
|
},
|
||||||
|
"fullscreen": {
|
||||||
|
"x": "0%",
|
||||||
|
"y": "0%",
|
||||||
|
"height": "100%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Short: Is the universe pixelated?
|
||||||
|
|
||||||
|
**HOOK**: What if reality is fundamentally blocky — like Minecraft, but smaller?
|
||||||
|
|
||||||
|
[SLIDE: title_card]
|
||||||
|
|
||||||
|
Everyone assumes space is smooth and continuous.
|
||||||
|
|
||||||
|
[SLIDE: smooth_space]
|
||||||
|
|
||||||
|
But what if it isn't?
|
||||||
|
|
||||||
|
[SLIDE: pixelated_space]
|
||||||
|
|
||||||
|
What if there's a *smallest* unit of space — and below that, nothing exists?
|
||||||
|
|
||||||
|
[SLIDE: planck_length]
|
||||||
|
|
||||||
|
This isn't new-age woo. The Planck length has been sitting in physics for a century.
|
||||||
|
|
||||||
|
[SLIDE: planck_formula]
|
||||||
|
|
||||||
|
The question is: is it a minimum, or just a measurement limit?
|
||||||
|
|
||||||
|
[SLIDE: question_mark]
|
||||||
|
|
||||||
|
That's what we're exploring at Glitch University.
|
||||||
|
|
||||||
|
[SLIDE: outro]
|
||||||
|
|
||||||
|
Link in description. The physics rabbit hole goes deep.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
API_URL="${GNOMMO_API_URL:-https://glitch.university}"
|
||||||
|
CONTENT_API_KEY=782y497821y491y3981212
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Gnommo Content Skills
|
||||||
|
|
||||||
|
Skills for generating content for the Gnommo/Glitch.University learning platform.
|
||||||
|
|
||||||
|
## Available Skills
|
||||||
|
|
||||||
|
| Skill | File | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| DEGLITCH Gates | `deglitch-gate-generator.md` | Generate quiz questions from manuscripts |
|
||||||
|
| Slide Content | `slide-content-generator.md` | Generate image prompts & text for slides |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DEGLITCH Gate Generator
|
||||||
|
|
||||||
|
Generate quiz questions from manuscript content for the Gnommo learning platform.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Read `manuscript.txt` (or specified file)
|
||||||
|
2. Identify 3-7 key concepts
|
||||||
|
3. Create 1-2 questions per concept
|
||||||
|
4. Output JSON or submit via API
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Each video project has:
|
||||||
|
- `manuscript.txt` - The narration script with `[SX]` slide markers
|
||||||
|
- `project.json` - Contains `coursecode` to identify the tech on the server
|
||||||
|
|
||||||
|
## API Configuration
|
||||||
|
|
||||||
|
```
|
||||||
|
Base URL: ${GNOMMO_API_URL:-http://localhost:3001}
|
||||||
|
Auth: Authorization: Bearer ${CONTENT_API_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /api/content/techs/available` - Find tech_id to link
|
||||||
|
- `POST /api/content/deglitch-gates` - Create gate
|
||||||
|
- `GET /api/content/deglitch-gates` - List gates
|
||||||
|
- `PUT /api/content/deglitch-gates/:id` - Update gate
|
||||||
|
|
||||||
|
## Question JSON Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tech_id": null,
|
||||||
|
"title": "Gate Title",
|
||||||
|
"description": "What this tests",
|
||||||
|
"passing_score": 0.8,
|
||||||
|
"shuffle_questions": true,
|
||||||
|
"shuffle_options": true,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question_type": "radio",
|
||||||
|
"text": "Question?",
|
||||||
|
"sort_order": 0,
|
||||||
|
"options": {
|
||||||
|
"a": { "answer": "Wrong", "correct": false, "why": "Explanation" },
|
||||||
|
"b": { "answer": "Right", "correct": true, "why": "Explanation" },
|
||||||
|
"c": { "answer": "Wrong", "correct": false, "why": "Explanation" },
|
||||||
|
"d": { "answer": "Wrong", "correct": false, "why": "Explanation" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Question Types
|
||||||
|
- `radio` - Single answer (most common)
|
||||||
|
- `checkbox` - Multiple answers
|
||||||
|
- `llm` - Free text (AI evaluated)
|
||||||
|
|
||||||
|
## Quality Guidelines
|
||||||
|
|
||||||
|
- Test understanding, not memorization
|
||||||
|
- One clear correct answer per radio question
|
||||||
|
- Plausible wrong answers with educational "why"
|
||||||
|
- Concise questions, avoid trick questions
|
||||||
|
- Vary difficulty across questions
|
||||||
|
|
||||||
|
## Workflow with API Key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Read project.json to get coursecode
|
||||||
|
cat /path/to/video/project.json | jq '.coursecode'
|
||||||
|
|
||||||
|
# 2. Find tech_id by matching coursecode
|
||||||
|
curl -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
$GNOMMO_API_URL/api/content/techs
|
||||||
|
|
||||||
|
# 3. Create gate with matched tech_id
|
||||||
|
curl -X POST -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$GNOMMO_API_URL/api/content/deglitch-gates \
|
||||||
|
-d '{"tech_id": 1, "title":"...","questions":[...]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Matching Coursecode to Tech
|
||||||
|
|
||||||
|
The `coursecode` in `project.json` matches the `code` field in the server's tech list:
|
||||||
|
- `♟️_#1.0` → Lightlane series, Video 1
|
||||||
|
- `♟️_#2.0` → Lightlane series, Video 2
|
||||||
|
- `WTF_#1` → What is Glitch University series, Video 1
|
||||||
|
|
||||||
|
## Workflow without API Key
|
||||||
|
|
||||||
|
Output the complete JSON for manual entry or later API submission.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# glitch gate API Helper Script
|
||||||
|
# Usage: source this file, then use the functions
|
||||||
|
|
||||||
|
# Configuration - set these or export before sourcing
|
||||||
|
GNOMMO_API_URL="${GNOMMO_API_URL:-http://localhost:3001}"
|
||||||
|
# CONTENT_API_KEY should be set in environment
|
||||||
|
|
||||||
|
# Check if API key is set
|
||||||
|
check_api_key() {
|
||||||
|
if [ -z "$CONTENT_API_KEY" ]; then
|
||||||
|
echo "Error: CONTENT_API_KEY not set"
|
||||||
|
echo "Run: export CONTENT_API_KEY=your-key-here"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# List all techs
|
||||||
|
list_techs() {
|
||||||
|
check_api_key || return 1
|
||||||
|
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
"$GNOMMO_API_URL/api/content/techs" | jq
|
||||||
|
}
|
||||||
|
|
||||||
|
# List techs without gates (available for linking)
|
||||||
|
list_available_techs() {
|
||||||
|
check_api_key || return 1
|
||||||
|
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
"$GNOMMO_API_URL/api/content/techs/available" | jq
|
||||||
|
}
|
||||||
|
|
||||||
|
# List all glitch gates
|
||||||
|
list_gates() {
|
||||||
|
check_api_key || return 1
|
||||||
|
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get a specific gate by ID
|
||||||
|
get_gate() {
|
||||||
|
check_api_key || return 1
|
||||||
|
local gate_id=$1
|
||||||
|
if [ -z "$gate_id" ]; then
|
||||||
|
echo "Usage: get_gate <gate_id>"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
curl -s -H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create a gate from JSON file
|
||||||
|
create_gate() {
|
||||||
|
check_api_key || return 1
|
||||||
|
local json_file=$1
|
||||||
|
if [ -z "$json_file" ]; then
|
||||||
|
echo "Usage: create_gate <json_file>"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$json_file" ]; then
|
||||||
|
echo "Error: File not found: $json_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @"$json_file" \
|
||||||
|
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create a gate from JSON string
|
||||||
|
create_gate_json() {
|
||||||
|
check_api_key || return 1
|
||||||
|
local json_data=$1
|
||||||
|
if [ -z "$json_data" ]; then
|
||||||
|
echo "Usage: create_gate_json '<json_string>'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$json_data" \
|
||||||
|
"$GNOMMO_API_URL/api/content/deglitch-gates" | jq
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update a gate from JSON file
|
||||||
|
update_gate() {
|
||||||
|
check_api_key || return 1
|
||||||
|
local gate_id=$1
|
||||||
|
local json_file=$2
|
||||||
|
if [ -z "$gate_id" ] || [ -z "$json_file" ]; then
|
||||||
|
echo "Usage: update_gate <gate_id> <json_file>"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$json_file" ]; then
|
||||||
|
echo "Error: File not found: $json_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @"$json_file" \
|
||||||
|
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete a gate
|
||||||
|
delete_gate() {
|
||||||
|
check_api_key || return 1
|
||||||
|
local gate_id=$1
|
||||||
|
if [ -z "$gate_id" ]; then
|
||||||
|
echo "Usage: delete_gate <gate_id>"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
read -p "Delete gate $gate_id? (y/N) " confirm
|
||||||
|
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: Bearer $CONTENT_API_KEY" \
|
||||||
|
"$GNOMMO_API_URL/api/content/deglitch-gates/$gate_id" | jq
|
||||||
|
else
|
||||||
|
echo "Cancelled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print available commands
|
||||||
|
deglitch_help() {
|
||||||
|
echo "glitch gate API Commands:"
|
||||||
|
echo ""
|
||||||
|
echo " list_techs - List all techs"
|
||||||
|
echo " list_available_techs - List techs without gates"
|
||||||
|
echo " list_gates - List all glitch gates"
|
||||||
|
echo " get_gate <id> - Get gate details"
|
||||||
|
echo " create_gate <file> - Create gate from JSON file"
|
||||||
|
echo " create_gate_json '<json>' - Create gate from JSON string"
|
||||||
|
echo " update_gate <id> <file> - Update gate from JSON file"
|
||||||
|
echo " delete_gate <id> - Delete a gate"
|
||||||
|
echo ""
|
||||||
|
echo "Configuration:"
|
||||||
|
echo " GNOMMO_API_URL=$GNOMMO_API_URL"
|
||||||
|
echo " CONTENT_API_KEY=$([ -n "$CONTENT_API_KEY" ] && echo "[set]" || echo "[not set]")"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "DEGLITCH API helper loaded. Run 'deglitch_help' for commands."
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# glitch gate Generator Skill
|
||||||
|
|
||||||
|
You are a quiz/assessment generator for the Gnommo learning platform. Your job is to read educational manuscript content and create glitch gate questions that test understanding.
|
||||||
|
|
||||||
|
## What is a glitch gate?
|
||||||
|
|
||||||
|
A glitch gate is a quiz that learners must pass to demonstrate mastery of a tech (lesson). Gates have:
|
||||||
|
- A title and description
|
||||||
|
- A passing score (default 80%)
|
||||||
|
- Multiple questions with explanations for why each answer is correct/incorrect
|
||||||
|
|
||||||
|
## Question Philosophy
|
||||||
|
|
||||||
|
Create questions that test **understanding**, not memorization:
|
||||||
|
|
||||||
|
- **Intuition questions**: Test pattern recognition and conceptual understanding
|
||||||
|
- **Grit questions**: Present tricky scenarios requiring careful thinking
|
||||||
|
- **Craft questions**: Test precise technical knowledge and attention to detail
|
||||||
|
|
||||||
|
### Good Question Characteristics
|
||||||
|
- Tests a single concept clearly
|
||||||
|
- Has one unambiguously correct answer
|
||||||
|
- Wrong answers are plausible (not obviously wrong)
|
||||||
|
- Each answer has a "why" explanation
|
||||||
|
- Avoids trick questions or gotchas
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Read the Manuscript
|
||||||
|
|
||||||
|
First, read the manuscript file to understand the content:
|
||||||
|
|
||||||
|
```
|
||||||
|
Read the file: manuscript.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if given a specific path:
|
||||||
|
```
|
||||||
|
Read the file: /path/to/manuscript.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Identify Key Concepts
|
||||||
|
|
||||||
|
After reading, identify 3-7 key concepts that learners should understand. Consider:
|
||||||
|
- Core principles explained in the text
|
||||||
|
- Common misconceptions to address
|
||||||
|
- Practical applications mentioned
|
||||||
|
- Relationships between concepts
|
||||||
|
|
||||||
|
### Step 3: Generate Questions
|
||||||
|
|
||||||
|
For each key concept, create 1-2 questions. Use this JSON structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tech_id": null,
|
||||||
|
"title": "Gate Title Based on Content",
|
||||||
|
"description": "Brief description of what this gate tests",
|
||||||
|
"passing_score": 0.8,
|
||||||
|
"shuffle_questions": true,
|
||||||
|
"shuffle_options": true,
|
||||||
|
"is_active": true,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question_type": "radio",
|
||||||
|
"text": "Question text here?",
|
||||||
|
"sort_order": 0,
|
||||||
|
"options": {
|
||||||
|
"a": {
|
||||||
|
"answer": "First option",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Explanation of why this is incorrect"
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"answer": "Second option (correct)",
|
||||||
|
"correct": true,
|
||||||
|
"why": "Explanation of why this is correct"
|
||||||
|
},
|
||||||
|
"c": {
|
||||||
|
"answer": "Third option",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Explanation of why this is incorrect"
|
||||||
|
},
|
||||||
|
"d": {
|
||||||
|
"answer": "Fourth option",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Explanation of why this is incorrect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question Types
|
||||||
|
|
||||||
|
- `radio` - Single correct answer (most common)
|
||||||
|
- `checkbox` - Multiple correct answers
|
||||||
|
- `llm` - Free text evaluated by AI (use sparingly)
|
||||||
|
|
||||||
|
## Step 4: Submit to API (if API key available)
|
||||||
|
|
||||||
|
If you have the Content API key, you can directly create the gate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-domain.com/api/content/deglitch-gates \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d 'YOUR_JSON_HERE'
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/content/techs` | List all techs to find tech_id |
|
||||||
|
| GET | `/api/content/techs/available` | Techs without gates |
|
||||||
|
| GET | `/api/content/deglitch-gates` | List existing gates |
|
||||||
|
| POST | `/api/content/deglitch-gates` | Create new gate |
|
||||||
|
| PUT | `/api/content/deglitch-gates/:id` | Update gate |
|
||||||
|
| DELETE | `/api/content/deglitch-gates/:id` | Delete gate |
|
||||||
|
|
||||||
|
### Finding the Right Tech ID
|
||||||
|
|
||||||
|
Before creating a gate, list available techs to find the correct `tech_id`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET https://your-domain.com/api/content/techs/available \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Complete Question Set
|
||||||
|
|
||||||
|
Here's an example of a well-structured gate for an "Atomic Structure" lesson:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tech_id": 1,
|
||||||
|
"title": "Atomic Structure Fundamentals",
|
||||||
|
"description": "Test your understanding of basic atomic structure and the components of atoms.",
|
||||||
|
"passing_score": 0.8,
|
||||||
|
"shuffle_questions": true,
|
||||||
|
"shuffle_options": true,
|
||||||
|
"is_active": true,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question_type": "radio",
|
||||||
|
"text": "What determines the chemical properties of an atom?",
|
||||||
|
"sort_order": 0,
|
||||||
|
"options": {
|
||||||
|
"a": {
|
||||||
|
"answer": "The number of neutrons",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Neutrons affect atomic mass and stability, but not chemical properties directly."
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"answer": "The number of protons",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Protons determine the element, but electrons determine how it bonds."
|
||||||
|
},
|
||||||
|
"c": {
|
||||||
|
"answer": "The number of electrons in the outer shell",
|
||||||
|
"correct": true,
|
||||||
|
"why": "Valence electrons determine how an atom bonds with others, defining its chemical behavior."
|
||||||
|
},
|
||||||
|
"d": {
|
||||||
|
"answer": "The total atomic mass",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Atomic mass affects physical properties like density, not chemical reactivity."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_type": "radio",
|
||||||
|
"text": "An atom has 6 protons and 8 neutrons. What element is it?",
|
||||||
|
"sort_order": 1,
|
||||||
|
"options": {
|
||||||
|
"a": {
|
||||||
|
"answer": "Oxygen",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Oxygen has 8 protons. The number of protons defines the element."
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"answer": "Carbon",
|
||||||
|
"correct": true,
|
||||||
|
"why": "Carbon has 6 protons. This is carbon-14, an isotope with 8 neutrons."
|
||||||
|
},
|
||||||
|
"c": {
|
||||||
|
"answer": "Nitrogen",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Nitrogen has 7 protons."
|
||||||
|
},
|
||||||
|
"d": {
|
||||||
|
"answer": "Carbon-14 is not carbon",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Isotopes are variants of the same element. Carbon-14 is still carbon."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question_type": "radio",
|
||||||
|
"text": "Why are noble gases chemically inert?",
|
||||||
|
"sort_order": 2,
|
||||||
|
"options": {
|
||||||
|
"a": {
|
||||||
|
"answer": "They have no electrons",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Noble gases have electrons; helium has 2, neon has 10, etc."
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"answer": "Their outer electron shell is full",
|
||||||
|
"correct": true,
|
||||||
|
"why": "A full valence shell means no tendency to gain, lose, or share electrons."
|
||||||
|
},
|
||||||
|
"c": {
|
||||||
|
"answer": "They are too heavy to react",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Mass doesn't determine reactivity. Francium is heavy but highly reactive."
|
||||||
|
},
|
||||||
|
"d": {
|
||||||
|
"answer": "They only exist at very low temperatures",
|
||||||
|
"correct": false,
|
||||||
|
"why": "Noble gases exist at all temperatures; they're gases at room temperature."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips for Quality Questions
|
||||||
|
|
||||||
|
1. **Start with the concept**, then craft the question around it
|
||||||
|
2. **Make wrong answers educational** - the "why" should teach something
|
||||||
|
3. **Vary difficulty** - include some easier and some harder questions
|
||||||
|
4. **Avoid "all of the above"** or "none of the above" options
|
||||||
|
5. **Keep questions concise** - if it needs a lot of context, split it
|
||||||
|
6. **Test understanding, not recall** - ask "why" and "how", not just "what"
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
If using the API programmatically, you need:
|
||||||
|
- `CONTENT_API_KEY` - Your API key for authentication
|
||||||
|
- API base URL (e.g., `https://gnommo.com` or `http://localhost:3001`)
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
When generating questions without API access, output:
|
||||||
|
1. A summary of key concepts identified
|
||||||
|
2. The complete JSON structure ready to copy
|
||||||
|
3. Any notes about the questions or suggestions for the tech linking
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Slide Content Generator Skill
|
||||||
|
|
||||||
|
Generate slide content (image prompts or text) from Gnommo manuscript files.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Gnommo presentations use a **square slide area next to a talking head**. Slides should be:
|
||||||
|
- Visually impactful but not cluttered
|
||||||
|
- Timed to appear with the first word after the `[SX]` marker
|
||||||
|
- Either **image-based** (generated via AI) or **text-based** (minimal, punchy text)
|
||||||
|
|
||||||
|
## Manuscript Format
|
||||||
|
|
||||||
|
Manuscripts use slide markers like `[S1]`, `[S2]`, etc. The content following each marker is what the presenter says while that slide is displayed.
|
||||||
|
|
||||||
|
```
|
||||||
|
[S1]
|
||||||
|
Welcome to the course...
|
||||||
|
|
||||||
|
[S2]
|
||||||
|
What if the universe is discrete?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Read the Manuscript
|
||||||
|
|
||||||
|
```
|
||||||
|
Read the file: /path/to/manuscript.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Analyze Each Slide
|
||||||
|
|
||||||
|
For each `[SX]` marker, determine:
|
||||||
|
1. **What is the core message?** - The key idea being communicated
|
||||||
|
2. **Visual or text?** - Would an image or text better support the message?
|
||||||
|
3. **Emotional tone?** - Dramatic, contemplative, humorous, technical?
|
||||||
|
|
||||||
|
### Step 3: Generate Content
|
||||||
|
|
||||||
|
For each slide, output one of:
|
||||||
|
|
||||||
|
#### IMAGE PROMPT
|
||||||
|
For conceptual, emotional, or complex ideas that benefit from visualization.
|
||||||
|
|
||||||
|
```
|
||||||
|
**[SX]** - "First few words..."
|
||||||
|
**IMAGE PROMPT:**
|
||||||
|
`Detailed description for AI image generation, style, mood, composition, lighting, specific elements to include`
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TEXT SLIDE
|
||||||
|
For lists, key terms, definitions, or when words ARE the point.
|
||||||
|
|
||||||
|
```
|
||||||
|
**[SX]** - "First few words..."
|
||||||
|
**TEXT SLIDE:**
|
||||||
|
```
|
||||||
|
HEADLINE
|
||||||
|
|
||||||
|
• Bullet point
|
||||||
|
• Another point
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
### When to Use IMAGE PROMPTS
|
||||||
|
|
||||||
|
- Abstract concepts (e.g., "the fabric of spacetime")
|
||||||
|
- Metaphors and analogies (e.g., "like changing engines while driving")
|
||||||
|
- Emotional moments (e.g., "this sounds insane")
|
||||||
|
- Scene-setting (e.g., "imagine a Minecraft universe")
|
||||||
|
|
||||||
|
### When to Use TEXT SLIDES
|
||||||
|
|
||||||
|
- Lists of items being enumerated
|
||||||
|
- Technical terms being defined
|
||||||
|
- Key questions or frameworks
|
||||||
|
- Course titles, section headers
|
||||||
|
- Quotes or key phrases
|
||||||
|
|
||||||
|
### Image Prompt Best Practices
|
||||||
|
|
||||||
|
1. **Be specific about style**: "isometric illustration", "cinematic lighting", "minimal vector style"
|
||||||
|
2. **Include mood/tone**: "mysterious", "hopeful", "dramatic contrast"
|
||||||
|
3. **Describe composition**: "split image", "centered subject", "deep space background"
|
||||||
|
4. **Avoid text in images**: AI image generators struggle with text - use text slides instead
|
||||||
|
5. **Keep it achievable**: Don't describe impossibly complex scenes
|
||||||
|
|
||||||
|
### Text Slide Best Practices
|
||||||
|
|
||||||
|
1. **Minimal words**: 3-7 words per line, 1-5 lines max
|
||||||
|
2. **Use hierarchy**: HEADLINES in caps, details below
|
||||||
|
3. **Bullets for lists**: Keep them short and scannable
|
||||||
|
4. **Leave breathing room**: Don't fill the entire square
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Output slides in order, with clear separation:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
|
||||||
|
**[S1]** - "First words of narration..."
|
||||||
|
**TYPE:** (IMAGE PROMPT or TEXT SLIDE)
|
||||||
|
Content here
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[S2]** - "First words of narration..."
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[S1]** - "Welcome to Glitch.University..."
|
||||||
|
**TEXT SLIDE:**
|
||||||
|
```
|
||||||
|
GLITCH.UNIVERSITY
|
||||||
|
WTF_#1
|
||||||
|
|
||||||
|
What is Glitch University?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[S2]** - "What if the universe is fundamentally discrete..."
|
||||||
|
**IMAGE PROMPT:**
|
||||||
|
`A hyper-detailed Minecraft-style voxel universe, showing galaxies and stars rendered as tiny glowing cubes, deep space background with blocky nebulae, cosmic scale but pixelated, dark background with vibrant cube-shaped stars, cinematic lighting`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customization Options
|
||||||
|
|
||||||
|
### Style Presets
|
||||||
|
|
||||||
|
You can request specific visual styles:
|
||||||
|
- **Tech/Corporate**: Clean vectors, isometric, blues and whites
|
||||||
|
- **Cosmic/Physics**: Deep space, nebulae, particle effects
|
||||||
|
- **Playful/Minecraft**: Voxels, bright colors, blocky
|
||||||
|
- **Philosophical**: Abstract, minimal, contemplative
|
||||||
|
- **Dramatic**: High contrast, cinematic, intense lighting
|
||||||
|
|
||||||
|
### Text Tone
|
||||||
|
|
||||||
|
- **Academic**: Formal terminology, structured
|
||||||
|
- **Casual**: Conversational, approachable
|
||||||
|
- **Punchy**: Short, impactful, memorable
|
||||||
|
|
||||||
|
## Integration with Gnommo
|
||||||
|
|
||||||
|
The generated content can be used to:
|
||||||
|
1. Create slides in Keynote/PowerPoint
|
||||||
|
2. Generate images via Midjourney/DALL-E/Stable Diffusion
|
||||||
|
3. Populate the `slides.json` file in the project's media folder
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Read the ENTIRE manuscript first to understand the arc
|
||||||
|
- Match slide density to pacing - fast sections need simpler slides
|
||||||
|
- Create visual continuity - recurring metaphors should have consistent imagery
|
||||||
|
- Consider what the talking head is doing - slides complement, not compete
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
# Gnommo Feature Development Roadmap
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Features to standardize the Keynote-to-YouTube workflow, so that once the presentation is complete, only a standardized recording session stands between you and a finished video.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Video Description Generator
|
||||||
|
|
||||||
|
**Command:** `gnommo -p <project> description`
|
||||||
|
|
||||||
|
Generate a complete YouTube description with citations, attributions, and chapters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.1 Manuscript Citations (`[cite:...]`)
|
||||||
|
|
||||||
|
Citations embedded in the manuscript represent sources, references, or links mentioned during narration. The text after `cite:` is the **literal reference** that should appear in the description.
|
||||||
|
|
||||||
|
**Format in manuscript.txt:**
|
||||||
|
```
|
||||||
|
[cite:Reference text exactly as it should appear]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
[S3]
|
||||||
|
According to this study [cite:Smith et al. (2024) "Effects of AI on Productivity" - https://example.com/paper],
|
||||||
|
the effect is significant.
|
||||||
|
|
||||||
|
[S7]
|
||||||
|
I'm using [cite:Keynote by Apple - https://apple.com/keynote] for all my presentations.
|
||||||
|
|
||||||
|
[S12]
|
||||||
|
This technique was pioneered by [cite:Dr. Jane Doe, MIT Media Lab].
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output in description:**
|
||||||
|
```
|
||||||
|
SOURCES & REFERENCES
|
||||||
|
━━━━━━━━━━━━━━━━━━━━
|
||||||
|
1:23 - Smith et al. (2024) "Effects of AI on Productivity" - https://example.com/paper
|
||||||
|
4:56 - Keynote by Apple - https://apple.com/keynote
|
||||||
|
8:30 - Dr. Jane Doe, MIT Media Lab
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Parse `[cite:...]` markers from manuscript.txt
|
||||||
|
- Extract the literal text after `cite:` as the reference
|
||||||
|
- Align citations to timestamps (same fuzzy matching as other markers)
|
||||||
|
- Group citations in order of appearance
|
||||||
|
- Citations are NOT aligned for rendering (ignored by renderer) but ARE timestamped for description
|
||||||
|
|
||||||
|
**Note:** `[cite:...]` markers should not affect video rendering or narration alignment - they are metadata-only markers for description generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Pexels/Stock Footage Attribution
|
||||||
|
|
||||||
|
Attribution for Pexels content is **not legally required** but is appreciated and professional.
|
||||||
|
|
||||||
|
**Official Pexels attribution format:**
|
||||||
|
```
|
||||||
|
by [Contributor Name] via Pexels
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Extend `videos.json` to include attribution metadata:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"beach_waves": {
|
||||||
|
"source_file": "pexels/beach.mp4",
|
||||||
|
"is_shared": true,
|
||||||
|
"attribution": {
|
||||||
|
"source": "pexels",
|
||||||
|
"creator": "John Doe",
|
||||||
|
"url": "https://pexels.com/video/12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Auto-detect Pexels videos from `shared_assets/pexels/` folder
|
||||||
|
- Support Pexels metadata JSON files (if downloaded with video)
|
||||||
|
- Generate attribution section for video description:
|
||||||
|
```
|
||||||
|
STOCK FOOTAGE
|
||||||
|
━━━━━━━━━━━━━
|
||||||
|
Beach waves by John Doe via Pexels: https://pexels.com/video/12345
|
||||||
|
City timelapse by Jane Smith via Pexels: https://pexels.com/video/67890
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pexels License Notes** (from pexels.com/license):
|
||||||
|
- Free for personal and commercial use
|
||||||
|
- Attribution not required but appreciated
|
||||||
|
- Cannot sell unaltered copies
|
||||||
|
- Cannot redistribute on other stock platforms
|
||||||
|
|
||||||
|
### 1.3 Complete Description Output
|
||||||
|
|
||||||
|
**Output file:** `out/description_youtube.txt`
|
||||||
|
|
||||||
|
Combine all elements into a ready-to-paste YouTube description.
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
[Video description from project.json "description" field]
|
||||||
|
|
||||||
|
CHAPTERS
|
||||||
|
━━━━━━━━
|
||||||
|
0:00 Introduction
|
||||||
|
1:23 Topic One
|
||||||
|
3:45 Topic Two
|
||||||
|
...
|
||||||
|
|
||||||
|
REFERENCES
|
||||||
|
━━━━━━━━━━
|
||||||
|
1:23 - Smith et al. (2024) "AI Study" - https://example.com
|
||||||
|
4:56 - Keynote by Apple - https://apple.com/keynote
|
||||||
|
...
|
||||||
|
|
||||||
|
STOCK FOOTAGE
|
||||||
|
━━━━━━━━━━━━━
|
||||||
|
Beach waves by John Doe via Pexels: https://pexels.com/video/12345
|
||||||
|
...
|
||||||
|
|
||||||
|
[Optional footer from project.json "footer" field - social links, subscribe CTA, etc.]
|
||||||
|
```
|
||||||
|
|
||||||
|
**project.json additions:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "In this video, I walk through the complete Gnommo workflow for creating YouTube videos from Keynote presentations.",
|
||||||
|
"footer": "Subscribe for more tutorials: https://youtube.com/@channel\nTwitter: https://twitter.com/handle"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Pull video description from `project.json` "description" field
|
||||||
|
- Generate chapters from slide markers (see Section 2)
|
||||||
|
- Collect all `[cite:...]` references with timestamps
|
||||||
|
- Collect all Pexels/stock attributions from `videos.json`
|
||||||
|
- Append optional footer from `project.json` "footer" field
|
||||||
|
- Output to `out/description_youtube.txt`
|
||||||
|
- Sections with no content are omitted (e.g., no STOCK FOOTAGE section if none used)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. YouTube Chapter Markers
|
||||||
|
|
||||||
|
**Command:** `gnommo -p <project> chapters`
|
||||||
|
|
||||||
|
Auto-generate chapter timestamps from slide markers.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Extract chapter titles from:
|
||||||
|
- Keynote slide titles (via presenter notes import)
|
||||||
|
- First sentence after each `[SN]` marker
|
||||||
|
- Optional `[chapter:Title]` markers for explicit chapter names
|
||||||
|
- Calculate timestamps from aligned marker timings
|
||||||
|
- Output copy-paste ready format:
|
||||||
|
```
|
||||||
|
CHAPTERS
|
||||||
|
━━━━━━━━
|
||||||
|
0:00 Introduction
|
||||||
|
1:23 What is Gnommo?
|
||||||
|
3:45 Setting Up Your Project
|
||||||
|
7:12 Recording Tips
|
||||||
|
10:30 Rendering Your Video
|
||||||
|
12:45 Outro
|
||||||
|
```
|
||||||
|
- Option to merge small chapters (minimum duration threshold)
|
||||||
|
- Support for nested chapters (main topics + subtopics)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Subtitle/Caption Export
|
||||||
|
|
||||||
|
**Command:** `gnommo -p <project> subtitles`
|
||||||
|
|
||||||
|
Generate subtitle files from Whisper transcription.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Export formats: SRT, VTT, TXT
|
||||||
|
- Use existing word-level timestamps from transcription
|
||||||
|
- Smart line breaking (max characters per line, break at punctuation)
|
||||||
|
- Speaker diarization support (future: multiple speakers)
|
||||||
|
- Options:
|
||||||
|
- `--format srt|vtt|txt`
|
||||||
|
- `--max-chars 42` (characters per line)
|
||||||
|
- `--max-duration 5` (seconds per subtitle block)
|
||||||
|
|
||||||
|
**Example output (SRT):**
|
||||||
|
```
|
||||||
|
1
|
||||||
|
00:00:01,500 --> 00:00:04,200
|
||||||
|
Hello and welcome to this tutorial
|
||||||
|
on video editing with Gnommo.
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:04,500 --> 00:00:07,800
|
||||||
|
Today we're going to cover
|
||||||
|
the complete workflow.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Thumbnail Generation
|
||||||
|
|
||||||
|
**Command:** `gnommo -p <project> thumbnail`
|
||||||
|
|
||||||
|
Auto-generate thumbnail candidates from slides.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Designate thumbnail slides with `[thumbnail]` marker
|
||||||
|
- If no marker, use slide 1 or title slide
|
||||||
|
- Apply text overlays from config:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thumbnail": {
|
||||||
|
"title_text": "Episode ${episode_number}",
|
||||||
|
"subtitle_text": "${title}",
|
||||||
|
"font": "Impact",
|
||||||
|
"text_color": "#FFFFFF",
|
||||||
|
"outline_color": "#000000",
|
||||||
|
"position": "bottom-left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Generate multiple variants:
|
||||||
|
- With/without text overlay
|
||||||
|
- Different zoom levels
|
||||||
|
- Different color treatments (saturated, high contrast)
|
||||||
|
- Output to `out/thumbnails/` folder
|
||||||
|
- Resolution: 1280x720 (YouTube standard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Intro/Outro Templates
|
||||||
|
|
||||||
|
**Configuration in project.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"intro": {
|
||||||
|
"template": "templates/intro_v2.mp4",
|
||||||
|
"duration": 3.5,
|
||||||
|
"transition": "fade",
|
||||||
|
"variables": {
|
||||||
|
"episode_number": "12",
|
||||||
|
"title": "Getting Started with Gnommo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outro": {
|
||||||
|
"template": "templates/outro_subscribe.mp4",
|
||||||
|
"duration": 8.0,
|
||||||
|
"transition": "fade"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Define intro/outro templates in `shared_assets/templates/`
|
||||||
|
- Auto-prepend intro before first slide
|
||||||
|
- Auto-append outro after last slide
|
||||||
|
- Support variable substitution in templates (episode number, title)
|
||||||
|
- Configurable transition types (fade, cut, wipe)
|
||||||
|
- End screen safe zone support (last 20 seconds)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Multi-Platform Format Presets
|
||||||
|
|
||||||
|
**Command:** `gnommo -p <project> render --format <preset>`
|
||||||
|
|
||||||
|
**Presets:**
|
||||||
|
| Preset | Aspect | Resolution | Notes |
|
||||||
|
|--------|--------|------------|-------|
|
||||||
|
| `youtube` | 16:9 | 1920x1080 | Default, standard horizontal |
|
||||||
|
| `youtube-4k` | 16:9 | 3840x2160 | 4K export |
|
||||||
|
| `shorts` | 9:16 | 1080x1920 | Vertical, auto-reframe slides |
|
||||||
|
| `podcast` | - | Audio only | MP3/M4A export for podcast feeds |
|
||||||
|
| `square` | 1:1 | 1080x1080 | Instagram/LinkedIn |
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Auto-adjust cutout positions per format
|
||||||
|
- Smart slide reframing for vertical (zoom to content area)
|
||||||
|
- Separate output folders per format
|
||||||
|
- Batch export to multiple formats: `--format youtube,shorts,podcast`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Teleprompter Script Generation
|
||||||
|
|
||||||
|
**Command:** `gnommo -p <project> teleprompter`
|
||||||
|
|
||||||
|
Extract clean narration text for teleprompter display.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Strip all markers from manuscript
|
||||||
|
- Keep only spoken text
|
||||||
|
- Output formats:
|
||||||
|
- `--format txt` - Plain text
|
||||||
|
- `--format html` - Scrollable HTML page with large font
|
||||||
|
- `--format json` - For teleprompter apps
|
||||||
|
- Optional: Include slide thumbnails as visual cues
|
||||||
|
- Configurable font size and scroll speed hints
|
||||||
|
|
||||||
|
**Example HTML output:**
|
||||||
|
```html
|
||||||
|
<div class="teleprompter">
|
||||||
|
<p class="cue">[SLIDE: Introduction]</p>
|
||||||
|
<p>Hello and welcome to this tutorial on video editing with Gnommo.</p>
|
||||||
|
<p class="cue">[SLIDE: What is Gnommo?]</p>
|
||||||
|
<p>Gnommo is a code-first video editing pipeline...</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Recording Checklist Generator
|
||||||
|
|
||||||
|
**Command:** `gnommo -p <project> checklist`
|
||||||
|
|
||||||
|
Generate a pre-recording checklist based on project configuration.
|
||||||
|
|
||||||
|
**Output includes:**
|
||||||
|
- [ ] Camera settings (resolution, fps from project.json)
|
||||||
|
- [ ] Lighting setup (if green screen detected in videos.json)
|
||||||
|
- [ ] Audio check (microphone levels)
|
||||||
|
- [ ] Props/demos needed (parsed from `[video:...]` markers)
|
||||||
|
- [ ] Slide count and estimated duration
|
||||||
|
- [ ] Teleprompter ready
|
||||||
|
- [ ] Recording space clear
|
||||||
|
|
||||||
|
**Customizable via `checklist_template.md` in project folder.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Audio Normalization
|
||||||
|
|
||||||
|
**Automatic during render or standalone command:**
|
||||||
|
`gnommo -p <project> normalize`
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Target: -14 LUFS (YouTube standard)
|
||||||
|
- Apply loudness normalization to narration track
|
||||||
|
- Preserve dynamic range (avoid over-compression)
|
||||||
|
- Normalize intro/outro audio to match
|
||||||
|
- Option: `--target-lufs -14`
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Use FFmpeg `loudnorm` filter
|
||||||
|
- Two-pass normalization for accurate results
|
||||||
|
- Report before/after levels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Project Templates
|
||||||
|
|
||||||
|
**Command:** `gnommo init <project-name> --template <template>`
|
||||||
|
|
||||||
|
**Built-in templates:**
|
||||||
|
| Template | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `tutorial` | Talking head + slides, square slide layout |
|
||||||
|
| `explainer` | Full-screen slides, minimal presenter |
|
||||||
|
| `review` | Product review format, multiple camera angles |
|
||||||
|
| `talking-head` | Full-screen presenter, no slides |
|
||||||
|
| `screencast` | Screen recording with small presenter PIP |
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Templates stored in `~/.gnommo/templates/` or `shared_assets/templates/`
|
||||||
|
- Each template includes:
|
||||||
|
- `project.json` with preset cutouts and settings
|
||||||
|
- `manuscript.txt` skeleton with example markers
|
||||||
|
- Sample `videos.json` structure
|
||||||
|
- User can create custom templates: `gnommo template save <name>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Batch Processing
|
||||||
|
|
||||||
|
**Command:** `gnommo batch render project1 project2 project3`
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Process multiple projects in sequence
|
||||||
|
- Continue on failure (don't stop batch for one failed project)
|
||||||
|
- Summary report at end:
|
||||||
|
```
|
||||||
|
BATCH COMPLETE
|
||||||
|
━━━━━━━━━━━━━━
|
||||||
|
✓ project1 - rendered in 5:23
|
||||||
|
✓ project2 - rendered in 4:17
|
||||||
|
✗ project3 - failed (missing slide S12)
|
||||||
|
```
|
||||||
|
- Options:
|
||||||
|
- `--parallel 2` - Run N renders in parallel
|
||||||
|
- `--skip-existing` - Skip if `out/final.mp4` exists
|
||||||
|
- `--format youtube,shorts` - Render all formats for each project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Progress Dashboard
|
||||||
|
|
||||||
|
**Command:** `gnommo status` or `gnommo -p <project> status`
|
||||||
|
|
||||||
|
Display pipeline status for all projects or specific project.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```
|
||||||
|
PROJECT STATUS
|
||||||
|
━━━━━━━━━━━━━━
|
||||||
|
Project Import Preprocess Transcribe Render Output
|
||||||
|
─────────────────────────────────────────────────────────────
|
||||||
|
video1 ✓ ✓ ✓ ✓ final.mp4 (12:34)
|
||||||
|
video2 ✓ ✓ ✓ ✗ -
|
||||||
|
video3 ✓ ✗ - - -
|
||||||
|
video4 ✗ - - - -
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Scan all project directories
|
||||||
|
- Check for existence of intermediate files
|
||||||
|
- Show file timestamps and durations
|
||||||
|
- Highlight what needs to be done next
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Recording Session Mode (Future)
|
||||||
|
|
||||||
|
**Command:** `gnommo -p <project> session`
|
||||||
|
|
||||||
|
Live recording assistant mode.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Display current slide on secondary monitor
|
||||||
|
- Show teleprompter text overlay
|
||||||
|
- Keyboard shortcuts to advance slides
|
||||||
|
- Real-time recording with proper settings
|
||||||
|
- Auto-stop at end of manuscript
|
||||||
|
- Voice command support: "next slide", "pause"
|
||||||
|
|
||||||
|
**Note:** This is a stretch goal requiring significant UI work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
### Phase 1 - Core YouTube Workflow (High Impact)
|
||||||
|
1. **Video Description Generator** (citations + Pexels attribution)
|
||||||
|
2. **YouTube Chapter Markers**
|
||||||
|
3. **Subtitle/Caption Export**
|
||||||
|
4. **Audio Normalization**
|
||||||
|
|
||||||
|
### Phase 2 - Content Creation Efficiency
|
||||||
|
5. **Thumbnail Generation**
|
||||||
|
6. **Intro/Outro Templates**
|
||||||
|
7. **Teleprompter Script Generation**
|
||||||
|
8. **Recording Checklist Generator**
|
||||||
|
|
||||||
|
### Phase 3 - Scale & Automation
|
||||||
|
9. **Project Templates**
|
||||||
|
10. **Multi-Platform Format Presets**
|
||||||
|
11. **Batch Processing**
|
||||||
|
12. **Progress Dashboard**
|
||||||
|
|
||||||
|
### Phase 4 - Advanced
|
||||||
|
13. **Recording Session Mode**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All new commands should follow existing CLI pattern: `gnommo -p <project> <command>`
|
||||||
|
- Output files go to `out/` subdirectory by default
|
||||||
|
- All features should support `--dry-run` where applicable
|
||||||
|
- Verbose mode (`-v`) should show detailed progress
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# to_mp3.sh — Convert .m4a and .wav files to .mp3
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./to_mp3.sh <file_or_folder> [options]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --quality N VBR quality 0-9 (default: 2 ≈ 190kbps; lower = better)
|
||||||
|
# --bitrate N CBR bitrate e.g. 192k (overrides --quality)
|
||||||
|
# --replace Delete originals after successful conversion
|
||||||
|
# --dry-run Show what would be converted without doing anything
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./to_mp3.sh recordings/
|
||||||
|
# ./to_mp3.sh interview.m4a
|
||||||
|
# ./to_mp3.sh recordings/ --replace
|
||||||
|
# ./to_mp3.sh recordings/ --bitrate 128k
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
QUALITY=2
|
||||||
|
BITRATE=""
|
||||||
|
REPLACE=false
|
||||||
|
DRY_RUN=false
|
||||||
|
TARGET=""
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--quality) QUALITY="$2"; shift 2 ;;
|
||||||
|
--bitrate) BITRATE="$2"; shift 2 ;;
|
||||||
|
--replace) REPLACE=true; shift ;;
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
|
*) TARGET="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$TARGET" ]]; then
|
||||||
|
echo "Usage: $(basename "$0") <file_or_folder> [--quality N] [--bitrate N] [--replace] [--dry-run]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v ffmpeg &>/dev/null; then
|
||||||
|
echo "Error: ffmpeg not found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect files
|
||||||
|
files=()
|
||||||
|
if [[ -f "$TARGET" ]]; then
|
||||||
|
files=("$TARGET")
|
||||||
|
elif [[ -d "$TARGET" ]]; then
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
files+=("$f")
|
||||||
|
done < <(find "$TARGET" -maxdepth 1 -type f \( -iname "*.m4a" -o -iname "*.wav" \) -print0 | sort -z)
|
||||||
|
else
|
||||||
|
echo "Error: '$TARGET' is not a file or directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#files[@]} -eq 0 ]]; then
|
||||||
|
echo "No .m4a or .wav files found in: $TARGET"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build audio quality flags
|
||||||
|
if [[ -n "$BITRATE" ]]; then
|
||||||
|
audio_flags=(-b:a "$BITRATE")
|
||||||
|
quality_desc="CBR ${BITRATE}"
|
||||||
|
else
|
||||||
|
audio_flags=(-q:a "$QUALITY")
|
||||||
|
quality_desc="VBR quality ${QUALITY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Converting ${#files[@]} file(s) to MP3 (${quality_desc})"
|
||||||
|
[[ "$REPLACE" == true ]] && echo " Originals will be deleted after conversion."
|
||||||
|
[[ "$DRY_RUN" == true ]] && echo " Dry-run mode — no files will be written."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
converted=0
|
||||||
|
skipped=0
|
||||||
|
errors=0
|
||||||
|
|
||||||
|
for src in "${files[@]}"; do
|
||||||
|
out="${src%.*}.mp3"
|
||||||
|
|
||||||
|
if [[ -f "$out" && "$DRY_RUN" == false ]]; then
|
||||||
|
echo " $(basename "$src"): output already exists, skipping"
|
||||||
|
((skipped++)) || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
size_mb=$(( $(stat -f%z "$src" 2>/dev/null || stat -c%s "$src") / 1048576 ))
|
||||||
|
printf " %-40s (%d MB)" "$(basename "$src")" "$size_mb"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
echo " [dry-run] → $(basename "$out")"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ffmpeg -i "$src" -vn "${audio_flags[@]}" -y "$out" -loglevel error; then
|
||||||
|
out_kb=$(( $(stat -f%z "$out" 2>/dev/null || stat -c%s "$out") / 1024 ))
|
||||||
|
echo " → $(basename "$out") (${out_kb} KB)"
|
||||||
|
((converted++)) || true
|
||||||
|
if [[ "$REPLACE" == true ]]; then
|
||||||
|
rm "$src"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ERROR"
|
||||||
|
((errors++)) || true
|
||||||
|
[[ -f "$out" ]] && rm "$out"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done: ${converted} converted, ${skipped} skipped, ${errors} errors."
|
||||||
Executable
+300
@@ -0,0 +1,300 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
#
|
||||||
|
# Video Transcoding Script
|
||||||
|
# Converts video files to H.265/HEVC at 1080p for significant size reduction
|
||||||
|
#
|
||||||
|
# Usage: ./transcode.sh <folder> [options]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --replace Delete original files after successful transcoding
|
||||||
|
# --dry-run Show what would be transcoded without doing it
|
||||||
|
# --crf <N> Quality level (default: 20, lower=better quality, 18-28 typical)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DEFAULT_CRF=18
|
||||||
|
EXTENSIONS=("mov" "mp4" "m4v" "avi" "mkv" "mxf")
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat << EOF
|
||||||
|
Video Transcoding Script
|
||||||
|
|
||||||
|
Converts video files to H.265/HEVC at 1080p for significant size reduction.
|
||||||
|
Typically achieves 80-95% size reduction from uncompressed 4K footage.
|
||||||
|
|
||||||
|
Usage: $(basename "$0") <folder|file> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--replace Delete original files after successful transcoding
|
||||||
|
--dry-run Show what would be transcoded without doing it
|
||||||
|
--crf <N> Quality level (default: 23)
|
||||||
|
Lower = better quality, larger files
|
||||||
|
18 = visually lossless, 23 = default, 28 = smaller
|
||||||
|
--help Show this help message
|
||||||
|
|
||||||
|
Output:
|
||||||
|
Files are saved alongside originals with '_compressed.mp4' suffix.
|
||||||
|
With --replace, originals are deleted after successful transcode.
|
||||||
|
When processing a folder, files are sorted smallest-first.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") ./video.mov # Transcode single file
|
||||||
|
$(basename "$0") ./media/videos # Transcode folder (smallest first)
|
||||||
|
$(basename "$0") ./media/videos --dry-run # Preview only
|
||||||
|
$(basename "$0") ./media/videos --replace # Transcode and delete originals
|
||||||
|
$(basename "$0") ./media/videos --crf 18 # Higher quality
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
FOLDER=""
|
||||||
|
REPLACE=false
|
||||||
|
DRY_RUN=false
|
||||||
|
CRF=$DEFAULT_CRF
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--replace)
|
||||||
|
REPLACE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--crf)
|
||||||
|
CRF="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z "$FOLDER" ]]; then
|
||||||
|
FOLDER="$1"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validate arguments
|
||||||
|
if [[ -z "$FOLDER" ]]; then
|
||||||
|
echo "Error: Folder path is required"
|
||||||
|
echo ""
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$FOLDER" && ! -f "$FOLDER" ]]; then
|
||||||
|
echo "Error: Path not found: $FOLDER"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for ffmpeg
|
||||||
|
if ! command -v ffmpeg &> /dev/null; then
|
||||||
|
echo "Error: ffmpeg is not installed"
|
||||||
|
echo "Install with: brew install ffmpeg"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build find pattern for video files
|
||||||
|
build_find_pattern() {
|
||||||
|
local pattern=""
|
||||||
|
for ext in "${EXTENSIONS[@]}"; do
|
||||||
|
if [[ -n "$pattern" ]]; then
|
||||||
|
pattern="$pattern -o"
|
||||||
|
fi
|
||||||
|
pattern="$pattern -iname '*.$ext'"
|
||||||
|
done
|
||||||
|
echo "$pattern"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Format file size for display
|
||||||
|
format_size() {
|
||||||
|
local bytes=$1
|
||||||
|
if (( bytes >= 1073741824 )); then
|
||||||
|
printf "%.1fG" $(echo "scale=1; $bytes / 1073741824" | bc)
|
||||||
|
elif (( bytes >= 1048576 )); then
|
||||||
|
printf "%.1fM" $(echo "scale=1; $bytes / 1048576" | bc)
|
||||||
|
else
|
||||||
|
printf "%.1fK" $(echo "scale=1; $bytes / 1024" | bc)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get file size in bytes
|
||||||
|
get_size() {
|
||||||
|
stat -f%z "$1" 2>/dev/null || echo 0
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "Video Transcoder"
|
||||||
|
echo "========================================"
|
||||||
|
echo "Folder: $FOLDER"
|
||||||
|
echo "Codec: H.265/HEVC"
|
||||||
|
echo "Resolution: 1080p (scaled down)"
|
||||||
|
echo "Quality: CRF $CRF"
|
||||||
|
echo "Replace: $REPLACE"
|
||||||
|
[[ "$DRY_RUN" == true ]] && echo "DRY RUN: Yes"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if input is a file or folder
|
||||||
|
IS_SINGLE_FILE=false
|
||||||
|
if [[ -f "$FOLDER" ]]; then
|
||||||
|
IS_SINGLE_FILE=true
|
||||||
|
VIDEO_FILES=("$FOLDER")
|
||||||
|
echo "Processing single file"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
# Find all video files (excluding already compressed ones), sorted by size (smallest first)
|
||||||
|
FIND_PATTERN=$(build_find_pattern)
|
||||||
|
|
||||||
|
# Use Python for robust sorting by size (handles spaces in paths correctly)
|
||||||
|
VIDEO_FILES=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
VIDEO_FILES+=("$file")
|
||||||
|
done < <(eval "find \"$FOLDER\" -type f \( $FIND_PATTERN \)" 2>/dev/null | python3 -c "
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for line in sys.stdin:
|
||||||
|
path = line.rstrip('\n')
|
||||||
|
if '_compressed.' in path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
files.append((size, path))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
files.sort(key=lambda x: x[0])
|
||||||
|
for size, path in files:
|
||||||
|
print(path)
|
||||||
|
")
|
||||||
|
|
||||||
|
if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
|
||||||
|
echo "No video files found in $FOLDER"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found ${#VIDEO_FILES[@]} video file(s) to process (smallest first)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Track totals
|
||||||
|
TOTAL_ORIGINAL=0
|
||||||
|
TOTAL_COMPRESSED=0
|
||||||
|
SUCCESS_COUNT=0
|
||||||
|
FAIL_COUNT=0
|
||||||
|
|
||||||
|
# Process each file
|
||||||
|
for input_file in "${VIDEO_FILES[@]}"; do
|
||||||
|
# Generate output filename
|
||||||
|
dir=$(dirname "$input_file")
|
||||||
|
basename=$(basename "$input_file")
|
||||||
|
name="${basename%.*}"
|
||||||
|
output_file="$dir/${name}_compressed.mp4"
|
||||||
|
|
||||||
|
# Get original size
|
||||||
|
original_size=$(get_size "$input_file")
|
||||||
|
original_size_fmt=$(format_size $original_size)
|
||||||
|
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "Input: $input_file ($original_size_fmt)"
|
||||||
|
echo "Output: $output_file"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
echo "Action: [DRY RUN] Would transcode"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip if output already exists
|
||||||
|
if [[ -f "$output_file" ]]; then
|
||||||
|
echo "Action: Skipped (output already exists)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Transcode with ffmpeg
|
||||||
|
# -vf scale=-2:1080 = scale to 1080p height, auto width (divisible by 2)
|
||||||
|
# -c:v libx265 = H.265/HEVC codec
|
||||||
|
# -crf = quality (lower = better)
|
||||||
|
# -preset medium = encoding speed/compression tradeoff
|
||||||
|
# -c:a aac -b:a 128k = audio to AAC at 128kbps
|
||||||
|
# -tag:v hvc1 = compatibility tag for Apple devices
|
||||||
|
echo "Action: Transcoding..."
|
||||||
|
|
||||||
|
if ffmpeg -i "$input_file" \
|
||||||
|
-vf "scale=-2:1080" \
|
||||||
|
-c:v libx265 \
|
||||||
|
-crf "$CRF" \
|
||||||
|
-preset medium \
|
||||||
|
-c:a aac -b:a 128k \
|
||||||
|
-tag:v hvc1 \
|
||||||
|
-y \
|
||||||
|
"$output_file" \
|
||||||
|
-loglevel warning -stats 2>&1; then
|
||||||
|
|
||||||
|
# Get compressed size
|
||||||
|
compressed_size=$(get_size "$output_file")
|
||||||
|
compressed_size_fmt=$(format_size $compressed_size)
|
||||||
|
|
||||||
|
# Calculate reduction
|
||||||
|
if (( original_size > 0 )); then
|
||||||
|
reduction=$(echo "scale=1; 100 - ($compressed_size * 100 / $original_size)" | bc)
|
||||||
|
else
|
||||||
|
reduction=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Result: $original_size_fmt → $compressed_size_fmt (${reduction}% reduction)"
|
||||||
|
|
||||||
|
TOTAL_ORIGINAL=$((TOTAL_ORIGINAL + original_size))
|
||||||
|
TOTAL_COMPRESSED=$((TOTAL_COMPRESSED + compressed_size))
|
||||||
|
((SUCCESS_COUNT++))
|
||||||
|
|
||||||
|
# Delete original if --replace is set
|
||||||
|
if [[ "$REPLACE" == true ]]; then
|
||||||
|
rm "$input_file"
|
||||||
|
echo "Deleted: $input_file"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Result: FAILED"
|
||||||
|
((FAIL_COUNT++))
|
||||||
|
# Remove partial output file if it exists
|
||||||
|
[[ -f "$output_file" ]] && rm "$output_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "Summary"
|
||||||
|
echo "========================================"
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
echo "DRY RUN - no files were transcoded"
|
||||||
|
else
|
||||||
|
echo "Processed: $SUCCESS_COUNT succeeded, $FAIL_COUNT failed"
|
||||||
|
if (( SUCCESS_COUNT > 0 )); then
|
||||||
|
total_orig_fmt=$(format_size $TOTAL_ORIGINAL)
|
||||||
|
total_comp_fmt=$(format_size $TOTAL_COMPRESSED)
|
||||||
|
if (( TOTAL_ORIGINAL > 0 )); then
|
||||||
|
total_reduction=$(echo "scale=1; 100 - ($TOTAL_COMPRESSED * 100 / $TOTAL_ORIGINAL)" | bc)
|
||||||
|
else
|
||||||
|
total_reduction=0
|
||||||
|
fi
|
||||||
|
echo "Total: $total_orig_fmt → $total_comp_fmt (${total_reduction}% reduction)"
|
||||||
|
|
||||||
|
if [[ "$REPLACE" == true ]]; then
|
||||||
|
saved=$(format_size $((TOTAL_ORIGINAL - TOTAL_COMPRESSED)))
|
||||||
|
echo "Freed: $saved"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "========================================"
|
||||||
Reference in New Issue
Block a user