Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8f662bee | ||
|
|
cdda2e9024 | ||
|
|
a8aab55bd2 | ||
|
|
4e1bfe03e2 | ||
|
|
9824f6d081 | ||
|
|
46e243b4dc | ||
|
|
f79a0cb082 | ||
|
|
e7dc402d9e | ||
|
|
409145f214 | ||
|
|
a84e02b494 | ||
|
|
b60be96978 | ||
|
|
9f30698801 | ||
|
|
a91acca695 | ||
|
|
7312fa2366 | ||
|
|
35c5e52fb0 | ||
|
|
5a439380ae | ||
|
|
c3e892cf89 | ||
|
|
0d48a38516 | ||
|
|
8247e5aa65 | ||
|
|
78e3aef0f2 | ||
|
|
09620c4e61 | ||
|
|
5f974a710e | ||
|
|
4913195b9d | ||
|
|
ee4d0b8b0e | ||
|
|
2ef88f3657 | ||
|
|
de329a8679 | ||
|
|
b1136a9f7d | ||
|
|
a3919f595a | ||
|
|
715a36cf6e | ||
|
|
cc566208c5 | ||
|
|
99ddc2e425 | ||
|
|
71f2c51b1b | ||
|
|
17e35920b6 | ||
|
|
4a05c1c78d | ||
|
|
c65b246401 | ||
|
|
e5bb437768 | ||
|
|
fa8512ff3d | ||
|
|
24b04fd55e | ||
|
|
316441ccb8 | ||
|
|
38e6c7940c | ||
|
|
83715e85e8 | ||
|
|
56e1cd985e | ||
|
|
eb9196e50d | ||
|
|
ce9c35c9b2 | ||
|
|
456070903c | ||
|
|
e8fc8411ea | ||
|
|
c4dedfa720 | ||
|
|
02a6131d15 | ||
|
|
bb1b17d531 | ||
|
|
c30c0f1c5e | ||
|
|
c6efbaf2ed | ||
|
|
7f9f8e3395 | ||
|
|
60478e13a3 | ||
|
|
9abaa237f7 | ||
|
|
d5dc3c5e33 | ||
|
|
1fcd511f77 | ||
|
|
308a9f8bcd | ||
|
|
f9ff847f6b | ||
|
|
b9b5a8e77d | ||
|
|
980bb84dac | ||
|
|
20aba06be1 | ||
|
|
12b052eb1d | ||
|
|
cf40a19b4e | ||
|
|
5d7c77db91 | ||
|
|
87424a6531 | ||
|
|
60e2f20b0f | ||
|
|
4a24d3987f | ||
|
|
7c53daec8a | ||
|
|
41d96501b6 | ||
|
|
ff47ffea8f | ||
|
|
b4c48d81b0 | ||
|
|
409d7790c0 | ||
|
|
994a2e0bb6 | ||
|
|
feb4df0506 | ||
|
|
b9376cd650 | ||
|
|
0c2d097cdf | ||
|
|
2dff8f45b9 | ||
|
|
00e01237ed | ||
|
|
3a9e5d17e9 | ||
|
|
dac6dfc48b | ||
|
|
a351022a8f | ||
|
|
efd1eba5df | ||
|
|
ad07de2e9a | ||
|
|
e6a6968109 | ||
|
|
d722272edc | ||
|
|
f8d359543a | ||
|
|
12bf494f2d | ||
|
|
831c0c4e60 | ||
|
|
f0387f24bb | ||
|
|
26d027a44e | ||
|
|
2516e3eeef | ||
|
|
4b4d6caacf | ||
|
|
7c75610fce | ||
|
|
0e22fcfbb3 | ||
|
|
e734dbfcac | ||
|
|
757d966803 | ||
|
|
6949124fa7 | ||
|
|
b6bc5a0463 | ||
|
|
b21ca6b394 | ||
|
|
3dcd7961c6 | ||
|
|
fdd275ac0e |
@@ -0,0 +1,35 @@
|
||||
### Problems
|
||||
|
||||
# Video 1 :
|
||||
Music ok
|
||||
Talkinghead too high, move down.
|
||||
|
||||
Glitch logo lasts 1 seconds, but render shows it should last 6 seconds and pause narration for 6 seconds.
|
||||
The narration correctly resumes after 6 seconds, leaving. The problem appears to be that S2 is indicated to start at 00:32.12 "Welcome to this video series about digital philosophy", which is incorrect. It should start at 00.37 or 00.38. So the positioning of the slide does not respect the pause narration.
|
||||
|
||||
Sent to claude
|
||||
|
||||
|
||||
02.07 slide of xray background with no text
|
||||
|
||||
02.30 : Move to "not just the sky around Antares" in a frame?
|
||||
|
||||
Discrete font on the one, two, three slide.
|
||||
|
||||
Black frame on wavepacket
|
||||
|
||||
Gedankenexperiment missing slide
|
||||
Done
|
||||
|
||||
"Assume the universe is fundamentally discrete" weird pause. Fixed
|
||||
|
||||
Need a slide before "Something else that has".
|
||||
|
||||
13.42: The Glitch slide. Talking head is above the cloud background. Cloud background should be mid level.
|
||||
Change to glitch video
|
||||
|
||||
DOne
|
||||
|
||||
# Video 2 :
|
||||
|
||||
The same problem as v1, the vf2m appears below the talking head. But the order is supposed to be
|
||||
+21
-2
@@ -7,16 +7,35 @@ __pycache__/
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
|
||||
Video1/*
|
||||
*.pdf
|
||||
*.png
|
||||
*.key
|
||||
*.bak
|
||||
shared_assets/*
|
||||
Video*/*
|
||||
Illustrations
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*/intermediate/*
|
||||
|
||||
# Output
|
||||
**/out/
|
||||
*.mp4
|
||||
*.mov
|
||||
*.mp3
|
||||
*.aifc
|
||||
*.wav
|
||||
|
||||
|
||||
# Temp
|
||||
*.tmp
|
||||
.cache/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Sync state (local only, per-environment)
|
||||
.gnommo_sync.json
|
||||
.gnommo_sync.prod.json
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"musicforyourdreams": {
|
||||
"file": "musicforyourdreams.m4a",
|
||||
"is_shared": true,
|
||||
"volume": 1.0,
|
||||
"duration": 448.832
|
||||
}
|
||||
}
|
||||
@@ -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 seems too weird to be worth trying.
|
||||
Nobody's really tried to take it seriously.
|
||||
Until now
|
||||
|
||||
[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,90 @@
|
||||
{
|
||||
"id": "GlitchTrailer",
|
||||
"coursecode": "TRAILER",
|
||||
"name": "Glitch University trailer",
|
||||
"description": "Welcome to Glitch University.",
|
||||
"hook": null,
|
||||
"platform_targets": ["youtube"],
|
||||
"status": "scripted",
|
||||
"youtube_url": null,
|
||||
"resolution": [1920, 1080],
|
||||
"fps": 30,
|
||||
"duration_seconds": null,
|
||||
"default_filters": {
|
||||
"audioonly": [
|
||||
{
|
||||
"type": "audio_normalize",
|
||||
"enable":false,
|
||||
"compress": false,
|
||||
"normalize": true,
|
||||
"target_lufs": -14,
|
||||
"target_lra": 11,
|
||||
"target_tp": -1.5
|
||||
}
|
||||
],
|
||||
"talkinghead": [
|
||||
{
|
||||
"type": "audio_normalize",
|
||||
"enable":false,
|
||||
"normalize": true,
|
||||
"target_lufs": -14,
|
||||
"target_lra": 11,
|
||||
"target_tp": -1.5
|
||||
},
|
||||
{
|
||||
"type": "color_grade",
|
||||
"saturation": 1.15,
|
||||
"contrast": 1.05,
|
||||
"bm": -0.1,
|
||||
"rm": 0.04
|
||||
},
|
||||
{
|
||||
"type": "gnommokey",
|
||||
"screen_color": [
|
||||
81,
|
||||
137,
|
||||
65
|
||||
],
|
||||
"screen_gain": 175,
|
||||
"screen_balance": 58,
|
||||
"despill_bias": [
|
||||
235,
|
||||
222,
|
||||
210
|
||||
],
|
||||
"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": "80%"
|
||||
},
|
||||
"square": {
|
||||
"x": "47.91875%",
|
||||
"y": "5.55%",
|
||||
"width": "50%",
|
||||
"height": "88.888888%"
|
||||
},
|
||||
"fullscreen": {
|
||||
"x": "0%",
|
||||
"y": "0%",
|
||||
"height": "100%"
|
||||
}
|
||||
},
|
||||
"manuscript": "manuscript.txt",
|
||||
"shorts": [],
|
||||
"output_video": "TRAILER.mp4"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
== Description ==
|
||||
Welcome to Glitch University.
|
||||
@@ -0,0 +1,103 @@
|
||||
[S1]
|
||||
Today we’re not proving quantum mechanics is discrete.
|
||||
We’re running a test.
|
||||
One test, named INT5000.
|
||||
|
||||
[S2]
|
||||
We already completed some simple tests. We called them the naïve test suite.
|
||||
It required us to define what is the Action, how does stuff move around.
|
||||
And what is momentum.
|
||||
But might be neat, but all it showed is that the CA glider has vaguely particle-like properties.
|
||||
But a fundamentally discrete universe is 50% of the option space. The other 50% is a continuous universe. Ruling out 50% of the option space like a decent bang for the buck, so we committed to giving a discrete universe every chance it can have to be true. Even if it sounds unlikely.
|
||||
We need to make it pass more tests. Lets introduce the advanced test suite.
|
||||
|
||||
[S3]
|
||||
If quantum mechanics is emergent from something discrete, there are many constraints it must satisfy.
|
||||
It needs Lorentz invariance, otherwise it contradicts Einstein.
|
||||
Most of these tests are beyond my abilities, even with AI assistance.
|
||||
But there is one I’m excited about, and it’s this one.
|
||||
Can a Cellular Automaton reproduce the spinorial rotation structure - SU(2)?
|
||||
|
||||
[S4]
|
||||
In quantum mechanics, certain objects - spinors - behave strangely.
|
||||
Rotate them 360 degrees, and they don’t return to their original state.
|
||||
They pick up a minus sign.
|
||||
Only after 720 degrees do they truly return.
|
||||
This behavior is encoded mathematically in a group called SU(2).
|
||||
If we can’t reproduce this, we can’t reproduce spin.
|
||||
If we can’t do that, QM is out of reach. And that means we’re back to the default scenario.
|
||||
|
||||
[S5]
|
||||
And the default scenario is - that the Naga is an neat and weird Cellular Automaton, of no significance to physics at all.
|
||||
Just like this channel, with its middle aged amateur creator dressed up in a yellow jumpsuit
|
||||
Trying to do stuff I don’t have the necessary training to do.
|
||||
This is the most likely outcome. I owning it, not afraid of it. It doesn’t hurt anyone, so lets keep going.
|
||||
|
||||
[S6]
|
||||
And as you know, physicists generally don’t understand code-speak, and developers struggle with physics-speak. So we’ll keep translating showing both sides.
|
||||
In Code speak - SU(2) is a interface exposed by QM that we must support.
|
||||
If we can’t support it, the migration fails.
|
||||
In physics speak - we isolate the SU(2) representation structure. Then we evaluate only whether the generators exposed by the proposed structure allows to satisfy the Lie algebra.
|
||||
|
||||
[S7]
|
||||
Basically we must derive SU(2) from the Naga in an elegant way. If we make any assumptions doing so, those same assumptions carry over to all other integrations tests.
|
||||
That’s the contract.
|
||||
|
||||
[S8]
|
||||
Importantly, this test does not require
|
||||
Not Lorentz invariance.
|
||||
Not isotropy of space.
|
||||
Not full quantum field theory.
|
||||
Just this single interface Naga - > SU(2)
|
||||
|
||||
[S9]
|
||||
It might be unnecessary to say this, but you never know, some people are sloppy when meting out criticism. So passing this test does NOT mean
|
||||
1. that QM is discrete
|
||||
2. That Bells theorem is not relevant
|
||||
3. Or that the theory not falsifiable
|
||||
It just mean that given the assumptions and tests, the test either fails or passes.
|
||||
If it passes, it earns the right to attempt the next one.
|
||||
That’s it. It’s test-driven development.
|
||||
|
||||
[S10]
|
||||
A Cellular Automaton is discrete.
|
||||
SU(2) is continuous.
|
||||
To derive something continuous from something discrete requires that the continuous is an approximation to the discrete.
|
||||
And this is very
|
||||
|
||||
[S11]
|
||||
So Spinors liven 3D space. Which means it should be possible to rotate them like this cup. I rotate it 360° brings you back.
|
||||
But spinors are weird, they don’t work like that.
|
||||
It’s kind of like this sock. Now if I rotate this sock 360 degrees, it returns back to where it was. But spinors is kind of like a sock that gets inside out each time it rotates. So when I rotate it 360 degrees it inverts. This inversion is captured by this extra -1. Then if I rotate it again it inverts again. So to return completely to both the same position and the same inside-out, we need two rotations.
|
||||
That “extra twist” is not decorative is deep in QM. You’re made from objets which rotate in this way, not the normal way.
|
||||
|
||||
[S12]
|
||||
What this means for our CA, is that if it naturally accumulates some kind of twist deficit after 2π that only resolves after 4π,
|
||||
we’re in business.
|
||||
Mathematically - we need a two-valued memory of rotation that flips sign after 2π.
|
||||
|
||||
[S13]
|
||||
So lets have a look at the CA. Let us first identify when it goes back to normal. We look at the head here, and keep iterating until the head is back in the original position. This takes N steps. So N steps is a full rotation. Now what happens if we apply just half of these steps, where do we en up - we end up here.
|
||||
Periodicity alone is not enough. We now examine how orientation accumulates during those steps.
|
||||
|
||||
[S14]
|
||||
And if we look carefully, we executed half of the X, half of the Y and half the Z. Which means that this midway point corresponds to half a rotation in all three planes. See here xy-plane, half rotated. Xz plane, half rotated. Yz plane half rotated. Meaning that T = N gives us a full rotation in all these three planes.
|
||||
A full rotation in one plane is 2pi, so a full rotation in all three plane should be 6pi. That is surprising. But look
|
||||
|
||||
[S15]
|
||||
A full rotation in the xy plane is also half a rotation in the xz plane, because the x-components are shared.
|
||||
And a full rotation in they yz plane is also a half rotation in the xz plane, because the z components are shared.
|
||||
Meaning that a full rotation in two planes gives us the full rotation in the third plane for free. 2/3 out of 6 pi is 4 pi.
|
||||
|
||||
[S16]
|
||||
Did that not convince you? Let’s do it differently.
|
||||
Suppose you define a rotation as the theta value of the HEAD relative to the center in a chosen plane.
|
||||
You will now have three rotation generators.
|
||||
You can’t add them together because they are never orthogonal to both other planes at the same time.
|
||||
Each theta value is orthogonal to half the theta value in each of the two remaining planes.
|
||||
Meaning you have theta = theta_xy + 1/2 theta_xz + 1/2 theta_yz.
|
||||
A slightly more general form of this is : 2/3(theta_xz + theta_xy + theta_yz)
|
||||
So if after a full rotation in a plane theta_xy = theta_xz = theta_yz = 2\pi, then the full rotation becomes
|
||||
2/3 (2 pi + 2pi + 2pi ) = 4 pi
|
||||
|
||||
[S17]
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"musicforyourdreams": {
|
||||
"file": "musicforyourdreams.m4a",
|
||||
"is_shared": true,
|
||||
"volume": 1.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"S1": {
|
||||
"image": "int5000.001.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S2": {
|
||||
"image": "int5000.002.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S3": {
|
||||
"image": "int5000.003.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S4": {
|
||||
"image": "int5000.004.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S5": {
|
||||
"image": "int5000.005.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S6": {
|
||||
"image": "int5000.006.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S7": {
|
||||
"image": "int5000.007.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S8": {
|
||||
"image": "int5000.008.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S9": {
|
||||
"image": "int5000.009.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S10": {
|
||||
"image": "int5000.010.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S11": {
|
||||
"image": "int5000.011.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S12": {
|
||||
"image": "int5000.012.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S13": {
|
||||
"image": "int5000.013.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S14": {
|
||||
"image": "int5000.014.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S15": {
|
||||
"image": "int5000.015.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S16": {
|
||||
"image": "int5000.016.png",
|
||||
"type": "fullscreen"
|
||||
},
|
||||
"S17": {
|
||||
"image": "int5000.017.png",
|
||||
"type": "fullscreen"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
# 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: Grading
|
||||
|
||||
Iterate on keying/grading without running a full preprocess. It seeks a few
|
||||
seconds into a raw clip, runs it through the `talkinghead` filter chain, and
|
||||
writes `grade_preview.mov` (ProRes 4444 with alpha) to the project root.
|
||||
|
||||
# 1. KEY — auto, objective. Generates a candidate + preview, applies on pick.
|
||||
gnommo -p video3 grade --stage key
|
||||
open video3/grade_sweep/key_1.png # sanity-check the matte
|
||||
gnommo -p video3 grade --pick key_1 # writes it to project.json
|
||||
|
||||
# 2. DESPILL — sweep, YOU pick. 7 stills, spill_suppress 0.7–1.5.
|
||||
gnommo -p video3 grade --stage despill
|
||||
open video3/grade_sweep/ # eyeball despill_1..7.png
|
||||
gnommo -p video3 grade --pick despill_5 # apply whichever looks clean
|
||||
|
||||
# 3. GRADE — sweep, YOU pick. 6 stills, paleness 0.0–1.0.
|
||||
gnommo -p video3 grade --stage grade
|
||||
open video3/grade_sweep/ # eyeball grade_1..6.png
|
||||
gnommo -p video3 grade --pick grade_3 # apply the paleness you like
|
||||
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
#### Green screen keying (`gnommokey`)
|
||||
|
||||
The `talkinghead` preset in `project.json` chains a `gnommokey` keyer (a
|
||||
Keylight-style color-difference keyer) with a `color_grade` step. Key fields:
|
||||
|
||||
| Field | What it does |
|
||||
|-------|--------------|
|
||||
| `screen_color` | RGB of your green screen (sampled from the footage). |
|
||||
| `screen_gain` | Key extraction strength. Higher = more aggressive matte. |
|
||||
| `screen_balance` | Mixes luminance into the key (helps darker/lighter greens). |
|
||||
| `clip_black` / `clip_white` | Compress the matte range (crush transparent / opaque). |
|
||||
| `despill_bias` | RGB the keyed *edges* shift toward. A light neutral/skin tone reads better than cool blue. |
|
||||
| `despill_strength` | How hard the edge/dominant-green despill pulls toward `despill_bias`. |
|
||||
| `spill_suppress` | **Interior green-limiter — see below.** |
|
||||
| `yellow_protect` | Shields saturated yellows/warm fabrics from `spill_suppress` (0–1). |
|
||||
| `edge_erode` | Shrinks the alpha edge by N passes to kill green fringe (0–5). |
|
||||
|
||||
**`spill_suppress` — the bald-head knob.** The regular despill only acts where
|
||||
green is the *dominant* channel (`g > max(r,b)`). On skin — especially a bald
|
||||
head catching green bounce — red stays dominant, so that despill never touches
|
||||
the interior and a green cast survives. `spill_suppress` caps green across the
|
||||
**whole frame** at a reference extrapolated through the other two channels:
|
||||
|
||||
```
|
||||
0.0 → cap at max(r, b) (only strong spill; effectively off)
|
||||
1.0 → cap at avg(r, b) (removes visible cast)
|
||||
2.0 → cap at min(r, b) (green can never exceed the smallest channel — nuclear)
|
||||
```
|
||||
|
||||
Green is only ever *reduced*, never boosted, so clean pixels are untouched.
|
||||
Start around `0.6`; go past `1.0` for heavy close-up spill. If skin tips
|
||||
magenta/pink, ease back down. Range is `0.0`–`2.0`.
|
||||
|
||||
**`yellow_protect` — keep yellows yellow.** Because `spill_suppress` caps green
|
||||
*everywhere*, it also drains green from things that are legitimately yellow (a
|
||||
yellow jumpsuit, warm props), turning them orange. Blue is the tell: skin keeps
|
||||
some blue, saturated yellow fabric reflects almost none. `yellow_protect`
|
||||
(0.0–1.0) gates the green-limiter back down where `min(r,g) − b` is high
|
||||
(yellow) while leaving skin/scalp spill fully suppressed. Bump it toward `1.0`
|
||||
if warm colours go orange; leave at `0` if you have no strong yellows.
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
gnommo -p myproject grade # first raw_mov clip, 3s from 5s in
|
||||
gnommo -p myproject grade --ss 12 --dur 4 # seek 12s in, make a 4s preview
|
||||
gnommo -p myproject grade --file media/narration/raw_mov/clipA.mov
|
||||
```
|
||||
|
||||
Tweak the `gnommokey` / `color_grade` values in `project.json`, re-run `grade`,
|
||||
re-open the `.mov`, repeat until it looks right.
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
A partial `--slides S1:S10` render writes a range-suffixed file (e.g.
|
||||
`PHIL_COSM_102_S1_S10.mp4`) so sections don't overwrite each other or the full render.
|
||||
|
||||
**Render log:** every render writes `<project>/<project>.log` (e.g. `video2.log`) with
|
||||
the platform/ffmpeg/memory header, the exact ffmpeg command, and a `[mem …]` memory
|
||||
sample every 3s. If a render crashes, check the tail of this log first.
|
||||
|
||||
---
|
||||
|
||||
### Render rig configuration (memory / performance)
|
||||
|
||||
The compositing ffmpeg graph holds a lot at once (RGBA layer buffers, many inputs, the
|
||||
final mux), so peak RAM is high. Two knobs keep it bounded — both matter on a render rig.
|
||||
|
||||
**1. FFmpeg thread cap — `~/.gnommo.conf`** (on the render machine)
|
||||
|
||||
Fewer filter threads = far less peak memory (each parallel `format=rgba`/swscaler stage
|
||||
holds its own full-frame buffers). The render honours `[performance] cpu_limit`, a
|
||||
fraction of logical CPUs. **Unset defaults to 1 thread (safest).** On a memory-tight box
|
||||
keep it low:
|
||||
|
||||
```ini
|
||||
[performance]
|
||||
cpu_limit = 0.25
|
||||
```
|
||||
|
||||
> Note: the render uses `-filter_complex`, capped by `-filter_complex_threads` (not
|
||||
> `-filter_threads`, which only applies to simple `-vf` graphs). This is why an
|
||||
> uncapped render graph could OOM even when the preprocessor was fine.
|
||||
|
||||
**2. WSL2 memory/swap — `C:\Users\<you>\.wslconfig`** (Windows host, for an Ubuntu-on-WSL rig)
|
||||
|
||||
A WSL2 VM only gets a *slice* of host RAM (default ~50%, or 8 GB on older builds). If the
|
||||
render exceeds that slice, **Windows OOM-kills the whole VM** — it surfaces as
|
||||
`Wsl/Service/E_UNEXPECTED` / "Catastrophic failure", not a normal out-of-memory error, and
|
||||
the Windows host still shows plenty of RAM free. Raise the cap and give it swap headroom:
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
memory=24GB # give the VM more of the host RAM
|
||||
swap=16GB # headroom so it pages instead of dying catastrophically
|
||||
processors=8
|
||||
```
|
||||
|
||||
Then, from PowerShell: `wsl --shutdown`, and restart the session. Confirm the VM's cap in
|
||||
the render log header — its `memory: … total` is the VM slice, not the host RAM.
|
||||
|
||||
---
|
||||
|
||||
## 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 grade # Preview the keyer/grade on a few seconds of raw footage
|
||||
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,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
claude --resume df8f915f-0f99-4e0f-b345-3562a49fcb06
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Atomic Events — Design Spec
|
||||
|
||||
Status: **Stage A + Stage B implemented (2026-07-27).** Motivated by a future **Glitch
|
||||
Studio GUI** that edits each video occurrence as a self-contained object.
|
||||
|
||||
Implemented:
|
||||
- Per-occurrence presentation resolves via `transformer.resolve_video_presentation`
|
||||
(precedence: inline/GUI override > shorthand prefix > videos.json > default; video
|
||||
`end_on` default = `next_video`, `[narration:]` runs to end).
|
||||
- events.json is materialized/atomic: `derive_events` writes `handle/cutout/layer/end_on/
|
||||
take`; `events_to_marker_timings` round-trips them as overrides.
|
||||
- Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through
|
||||
alignment into `MarkerTiming.overrides`. Supported inline keys: **cutout, layer, end_on,
|
||||
take** (the fully-wired per-event fields). Unknown keys are ignored.
|
||||
- The key-reuse collision validator hard-error was removed (reuse is legal now).
|
||||
|
||||
Deferred (follow-ups): inline override of the *global* params (skip/zoom/volume/
|
||||
use_audio_channels/pause_narration) — the renderer reads these from `video_source` in ~13
|
||||
places, so wiring them per-event is a separate change; stripping the moved fields from
|
||||
videos.json (kept as fallback defaults for now); a validator warning for unknown/unwired
|
||||
inline keys.
|
||||
|
||||
## Problem
|
||||
|
||||
Presentation/timing properties (`cutout`, `layer`, `end_on`, `take`, `pause_narration`)
|
||||
live on the **videos.json handle**, but they are really properties of *where a clip is
|
||||
used*, not of the file. The shorthand prefix (`vst:` = square/above, `vsb:` =
|
||||
square/below) is per-marker, but `_project_markers_to_videos` collapses it onto the
|
||||
single handle record (last-wins). So one handle used two ways collides:
|
||||
|
||||
- `[vst:glitch_ccd_binning]` (above) and `[vsb:glitch_ccd_binning]` (below) → videos.json
|
||||
can only store `layer: below`, so the first occurrence renders under the slide (hidden).
|
||||
- video5 has 5 such collisions today (glitch_ccd_binning, pexels/12471039…,
|
||||
mainvideopart1, shotnoiseacc, slide_periodogram).
|
||||
|
||||
A stopgap validator hard-error (`validate_project`, gnommo/validator.py) currently blocks
|
||||
render on these. This spec removes the *cause* so that guard is no longer needed.
|
||||
|
||||
The naive fixes are both rejected: copying the file/handle (duplication on disk), and a
|
||||
"hybrid override + materialize" layer (too much indirection). Instead: **the per-occurrence
|
||||
properties move onto the event.**
|
||||
|
||||
## Field homes
|
||||
|
||||
**videos.json — asset + global defaults (one value per handle):**
|
||||
`source_file`, `output_file`/`processed_file`, `filter`, `has_audio`, `is_shared`,
|
||||
`src_mtime`, `duration` (probed; asset-only, never per-event), and the globals
|
||||
`zoom`, `skip`, `volume`, `use_audio_channels`.
|
||||
|
||||
**events.json — per-occurrence (one value per event):**
|
||||
`handle` (the video id, **prefix-free**), `cutout`, `layer`, `end_on`, `take`,
|
||||
`pause_narration`.
|
||||
|
||||
**Resolution order for a rendered clip:** event field (if set) → videos.json value (for the
|
||||
globals) → config default. The per-occurrence fields have no videos.json fallback — they
|
||||
are always materialized onto the event at build time.
|
||||
|
||||
Notes:
|
||||
- `end_on` **defaults to `next_video`** for videos when unspecified (was implicitly
|
||||
`next_slide`). Existing videos.json `end_on` values are migrated onto events explicitly,
|
||||
so current projects keep their behavior; only *new* unspecified markers get the new default.
|
||||
- `take` is the event-level cut length, only meaningful when `end_on=take`; otherwise the
|
||||
end is implicit from `end_on` and `take` stays null.
|
||||
- `skip` stays a global (asset trim-in) while `take` is per-event — a deliberate asymmetry:
|
||||
"where this asset generally starts" vs. "how long this occurrence plays."
|
||||
- `zoom`/`volume`/`use_audio_channels` stay global but are inline-overridable per event
|
||||
(below), so they can diverge without a videos.json copy.
|
||||
|
||||
## Authoring: shorthand + inline overloads
|
||||
|
||||
The manuscript stays the compact authoring surface. The shorthand letters encode
|
||||
`cutout`+`layer` (and `pause_narration` via the `…p:` variants). Anything the letters
|
||||
don't encode — chiefly `end_on`, and any per-event override of a global — is given as
|
||||
inline **`key=value`** pairs (simplified from the earlier `{"json":"form"}`):
|
||||
|
||||
```
|
||||
[vsb:glitch_ccd_binning2] # square/below, end_on defaults to next_video
|
||||
[vsb:glitch_ccd_binning2, end_on=next_video] # + explicit end_on
|
||||
[vsb:glitch_ccd_binning2, take=5, volume=0.5] # + per-event overrides of globals
|
||||
[video:glitch_ccd_binning2, cutout=square, layer=below] # generic; equivalent to [vsb:…]
|
||||
```
|
||||
|
||||
Rules:
|
||||
- The first token inside `[]` is `prefix:handle` (handle may contain `/`, e.g. `pexels/123`).
|
||||
- Remaining comma-separated tokens are `key=value`. Values are type-inferred: numeric →
|
||||
float, `true`/`false` → bool, else string. Allowed keys: `cutout`, `layer`, `end_on`,
|
||||
`take`, `skip`, `zoom`, `volume`, `use_audio_channels`, `pause_narration`,
|
||||
`always_visible`.
|
||||
- An inline key overrides whatever the shorthand implied (e.g. `[vst:x, layer=below]` →
|
||||
above from the prefix, then below from the override). Last-writer-wins, prefix first.
|
||||
- `[video:handle, …]` is the fully-explicit form the GUI round-trips: no prefix magic, every
|
||||
presentation field named.
|
||||
|
||||
Why `key=value` over JSON: no braces/quotes to escape inside `[]`, one obvious separator,
|
||||
and it reads cleanly in a script. The GUI still stores the resolved values as real JSON
|
||||
fields on the event — the manuscript form is just sugar that populates them.
|
||||
|
||||
## Build-time materialization
|
||||
|
||||
At build (`build_render_plan` / scaffold construction), each video marker resolves to an
|
||||
atomic event dict:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "video",
|
||||
"handle": "glitch_ccd_binning",
|
||||
"cutout": "square",
|
||||
"layer": "above",
|
||||
"end_on": "next_video",
|
||||
"take": null,
|
||||
"pause_narration": 0.0,
|
||||
"narration_time": 0.0, "adjustment": 0.0, "final_time": 0.0,
|
||||
"mapping": "exact", "confidence": 1.0, "context": "…"
|
||||
}
|
||||
```
|
||||
|
||||
`id` (currently `"vst:glitch_ccd_binning"`) is replaced by `handle` + explicit fields. The
|
||||
render pass reads presentation straight off the event and no longer consults the prefix or
|
||||
the videos.json presentation fields. `merge_events` must preserve manual event edits (the
|
||||
GUI's writes) across rebuilds, the same way it preserves `adjustment` today.
|
||||
|
||||
## Code touchpoints
|
||||
|
||||
- **models.py** — `VideoSource` sheds `cutout`/`layer`/`end_on`/`take`/`pause_narration`
|
||||
(or they become defaults-only); `VideoEvent` already carries `cutout`/`layer`/`end_on` —
|
||||
extend to `take`/`pause_narration` sourced from the event, not the handle.
|
||||
- **parser.py `parse_manuscript`** — extend the marker grammar to accept
|
||||
`prefix:handle, key=value, …`; update the malformed-marker detector (which today flags
|
||||
spaces/commas inside `[]`).
|
||||
- **transformer.py `_extract_video_events`** — resolve `cutout/layer/end_on/take/
|
||||
pause_narration` from (prefix ∪ inline overrides), not from `video_source`.
|
||||
- **scaffold.py** — event schema: `handle` + presentation fields; `merge_events` preserves
|
||||
GUI edits; migration for existing events.json.
|
||||
- **cli.py** — retire `_project_markers_to_videos` and `_writeback_video_metadata` (they
|
||||
project/writeback per-handle presentation) in favor of seeding event fields.
|
||||
- **validator.py** — **remove** the key-reuse collision hard-error (reuse is legal now).
|
||||
- **renderer.py** — read presentation from the event (mostly already does via `VideoEvent`).
|
||||
|
||||
## Migration
|
||||
|
||||
Existing projects (video0–video6, …) have presentation on the handle and prefixed `id`s in
|
||||
events.json. A one-shot migration, run on build:
|
||||
|
||||
1. For each video event, split the prefixed `id` into `handle` + implied `cutout`/`layer`.
|
||||
2. Fill `end_on`/`take`/`pause_narration` from the handle's current videos.json values
|
||||
(preserving today's behavior — including handles that explicitly set `next_slide`).
|
||||
3. Strip the moved fields from videos.json handles (leave the globals).
|
||||
4. Idempotent: a second run is a no-op once events carry `handle`.
|
||||
|
||||
## Staging
|
||||
|
||||
- **Stage A** — schema split + per-event resolution from the shorthand prefix, migration,
|
||||
remove the collision guard. Shorthand-only authoring keeps working; the 5 video5
|
||||
collisions resolve. (This is the part that fixes the bug.)
|
||||
- **Stage B** — the inline `key=value` overload grammar + malformed-marker updates.
|
||||
|
||||
Keep the validator collision hard-error in place **until Stage A lands** — removing it
|
||||
earlier would let the hidden-overlay bug back in on video5.
|
||||
|
||||
## Open questions
|
||||
|
||||
- `always_visible`, `use_audio_channels`: confirmed as inline-overridable globals — do any
|
||||
need to become fully per-event?
|
||||
- Does the GUI want events fully flattened (every field present) or sparse (only overrides,
|
||||
inherit the rest)? Affects whether the build writes defaults explicitly.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Chunked Rendering v2 — Design Spec
|
||||
|
||||
Status: **implemented on branch `chunking-v2`, pending render-seam validation on the rig.**
|
||||
Plan-level logic is covered by `tests/test_chunking_v2.py` (all green). What remains
|
||||
is confirming the ffmpeg concat seam is frame/phase-accurate on a real render — see
|
||||
"Concat-seam correctness" below.
|
||||
|
||||
## Why chunking exists
|
||||
|
||||
The render is a single `ffmpeg -filter_complex` pass that opens every `-i` input up
|
||||
front. On memory-constrained machines (an 8 GB VM, or a WSL2 rig whose VM RAM is a
|
||||
slice of the host) the aggregate decoder + filter buffers OOM-kill the process — or,
|
||||
on WSL2, the whole VM. `render_chunk_slides` (in `~/.gnommo.conf` `[performance]`, or
|
||||
`--chunk-slides N`) splits the timeline into groups of N slides, renders each as a
|
||||
partial render, and concatenates the chunks with `-c copy`.
|
||||
|
||||
Each chunk is a partial render built via `build_render_plan(slide_range=(start,end))`,
|
||||
which passes `time_range=(time_offset, render_end_time)` to the event extractors. See
|
||||
`partial-rendering-spec.md` for the partial-render mechanics chunking reuses.
|
||||
|
||||
## The v1 defect this fixes
|
||||
|
||||
`_extract_video_events` / `_extract_audio_events` keep an event **only if its start
|
||||
falls inside the window**:
|
||||
|
||||
```python
|
||||
if start_time < range_start or start_time >= range_end:
|
||||
continue
|
||||
```
|
||||
|
||||
So a clip that **began in an earlier chunk and is still playing across the boundary**
|
||||
is dropped from every later chunk it overlaps. The full (non-chunked) render is
|
||||
correct; the chunked render silently diverges.
|
||||
|
||||
Who is affected:
|
||||
|
||||
- **Overlay videos that span slides:** `end_on: next_video`, `loop`, long `take`/`end`
|
||||
(multi-slide backgrounds / persistent picture-in-picture).
|
||||
- **Looping background audio/music:** started once early, meant to underlie the whole
|
||||
video — dropped from every chunk after the first. Highest blast radius.
|
||||
- **Not affected:** `end_on: next_slide` clips (they end exactly at a slide marker =
|
||||
a chunk seam), per-slide content, and the full-screen `plan.background` (a separate
|
||||
always-included input).
|
||||
|
||||
v1 status: `cli._chunk_boundary_span_warnings(plan, groups)` detects spanning
|
||||
video/outro/audio events and prints a loud warning before rendering, so the divergence
|
||||
is never silent. It does not yet correct the output.
|
||||
|
||||
## v2 algorithm
|
||||
|
||||
### 1. Overlap inclusion (not start-inside)
|
||||
|
||||
Replace the start-inside test with an overlap test in both extractors:
|
||||
|
||||
```python
|
||||
# keep the event if it overlaps [range_start, range_end)
|
||||
if end_time <= range_start or start_time >= range_end:
|
||||
continue
|
||||
```
|
||||
|
||||
### 2. Per-event seek for clips that began earlier
|
||||
|
||||
A clip included by overlap whose `start_time < range_start` is already mid-playback at
|
||||
the chunk boundary. It must resume at the correct frame, not restart. Compute how far
|
||||
into the clip the window begins and carry it as a **per-event seek**:
|
||||
|
||||
```
|
||||
into = range_start - start_time # seconds of the clip already elapsed
|
||||
base = video_source.skip or 0.0
|
||||
# non-looping clip:
|
||||
skip_override = base + into
|
||||
# looping clip (end_on: loop / next_video that wraps):
|
||||
period = (video_source.duration or 0) - base # one loop's playable length
|
||||
skip_override = base + (into % period) if period > 0 else base
|
||||
```
|
||||
|
||||
Then clamp the event to the window and let the offset pass zero it:
|
||||
|
||||
```
|
||||
start_time = max(start_time, range_start) # -> 0 after time_offset subtraction
|
||||
end_time = min(end_time, range_end)
|
||||
```
|
||||
|
||||
The seam for the *seek* is `VideoEvent.skip_override` (already added, inert until v2):
|
||||
the renderer prefers it over `video_source.skip` when set. `-ss {skip}` is applied as
|
||||
an input option, so ffmpeg decodes to that point — frame-accurate for the codecs in
|
||||
use.
|
||||
|
||||
### 3. Audio equivalent
|
||||
|
||||
`AudioEvent` has no `skip_override` / `end_time` yet. v2 adds both (or derives end from
|
||||
`audio_def.duration` / `loop` → `total_duration`) and applies the same overlap + seek.
|
||||
For looping music the seek is the loop-phase modulo above; `ignore_pauses` and
|
||||
`overlap` (crossfade) interactions must be re-checked at the seam.
|
||||
|
||||
### 4. Outro events
|
||||
|
||||
`OutroEvent` also spans (start_time/end_time). Extend the same treatment; outros
|
||||
normally live in the final chunk so this is lower priority but should be covered for
|
||||
completeness.
|
||||
|
||||
## Concat-seam correctness (the risk to validate)
|
||||
|
||||
Chunks are joined with `-c copy`, so the two sides of a seam must be frame-aligned:
|
||||
chunk *k* ends showing the clip at position `P`, chunk *k+1* must resume at exactly `P`
|
||||
via `skip_override`. Validation plan:
|
||||
|
||||
1. Pick a project with a known multi-slide overlay video **and** looping music
|
||||
(or synthesize one).
|
||||
2. Render it full (reference) and chunked (small `chunk_slides`, so a seam falls mid
|
||||
clip).
|
||||
3. Compare: identical duration; frame diff at ±3 frames around each seam below a
|
||||
threshold; audio cross-correlation shows no gap/jump; the overlay is present in
|
||||
every chunk it overlaps (the v1 bug is gone).
|
||||
4. Assert `_chunk_boundary_span_warnings` returns empty for the fixed path.
|
||||
|
||||
## Work items
|
||||
|
||||
- [x] `_extract_video_events`: overlap test + `skip_override` (loop-aware).
|
||||
- [x] `_extract_audio_events`: overlap + `src_offset` seek (loop phase / linear).
|
||||
- [x] `AudioEvent`: `src_offset` field; renderer audio paths (loop-with-pauses,
|
||||
standard loop, one-shot) honor it.
|
||||
- [x] `VideoEvent.skip_override`: renderer video input `-ss` honors it; the clip's
|
||||
embedded audio (`tvaud`) is seeked automatically by the same input seek.
|
||||
- [x] `_chunk_boundary_span_warnings`: downgraded from the "will be dropped" v1
|
||||
warning to an informational note (logged; terminal only under `--verbose`).
|
||||
- [x] Plan-level tests: `tests/test_chunking_v2.py`.
|
||||
- [ ] **Render-seam validation on the rig** (chunked-vs-full frame/audio diff) — the
|
||||
remaining gate before making v2 the trusted default.
|
||||
- [ ] Crossfade-loop audio (`_build_crossfade_loop_filter`) does not yet apply
|
||||
`src_offset` — a crossfaded looping bed restarts phase at the seam. Standard
|
||||
(non-crossfade) loops and one-shots are handled. Low priority.
|
||||
- [ ] `OutroEvent`: not needed — outros are extracted for the last chunk only
|
||||
(`config.outro if is_last_chunk`), so they never split across a seam.
|
||||
|
||||
## Not affected (verified)
|
||||
|
||||
- Slides: `_extract_slide_events` already used overlap+clamp — no change.
|
||||
- Full-screen `plan.background`: a separate always-included input.
|
||||
- Full (non-chunked) render: `time_range=None` path leaves `skip_override`/
|
||||
`src_offset` at their defaults, so output is byte-identical to before.
|
||||
@@ -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": "processed/talking_head_S1_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": "talkinghead"
|
||||
},
|
||||
"talking_head_S3": {
|
||||
"source_file": "talking_head_S3.mov",
|
||||
"output_file": "processed/talking_head_S3_processed.mov",
|
||||
"cutout": "talkinghead",
|
||||
"always_visible": true,
|
||||
"filter": "talkinghead"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
[
|
||||
{
|
||||
"word": "This",
|
||||
"start": 10.74,
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,497 @@
|
||||
[
|
||||
{
|
||||
"word": "This",
|
||||
"start": 10.632,
|
||||
"end": 11.312
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 11.312,
|
||||
"end": 11.512
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 11.512,
|
||||
"end": 11.692
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 11.692,
|
||||
"end": 11.912
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 11.912,
|
||||
"end": 12.312
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 12.852,
|
||||
"end": 13.192
|
||||
},
|
||||
{
|
||||
"word": "appears",
|
||||
"start": 13.192,
|
||||
"end": 13.552
|
||||
},
|
||||
{
|
||||
"word": "immediate.",
|
||||
"start": 13.552,
|
||||
"end": 14.032
|
||||
},
|
||||
{
|
||||
"word": "However,",
|
||||
"start": 15.452,
|
||||
"end": 15.932
|
||||
},
|
||||
{
|
||||
"word": "this",
|
||||
"start": 16.272,
|
||||
"end": 16.352
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 16.352,
|
||||
"end": 16.492
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 16.492,
|
||||
"end": 16.652
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 16.652,
|
||||
"end": 16.952
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 16.952,
|
||||
"end": 17.292
|
||||
},
|
||||
{
|
||||
"word": "It",
|
||||
"start": 17.572,
|
||||
"end": 17.872
|
||||
},
|
||||
{
|
||||
"word": "should",
|
||||
"start": 17.872,
|
||||
"end": 18.112
|
||||
},
|
||||
{
|
||||
"word": "appear",
|
||||
"start": 18.112,
|
||||
"end": 18.432
|
||||
},
|
||||
{
|
||||
"word": "one",
|
||||
"start": 18.432,
|
||||
"end": 18.892
|
||||
},
|
||||
{
|
||||
"word": "second",
|
||||
"start": 18.892,
|
||||
"end": 19.372
|
||||
},
|
||||
{
|
||||
"word": "prior",
|
||||
"start": 19.372,
|
||||
"end": 19.792
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 19.792,
|
||||
"end": 20.032
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 20.032,
|
||||
"end": 20.152
|
||||
},
|
||||
{
|
||||
"word": "word",
|
||||
"start": 20.152,
|
||||
"end": 20.412
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 20.412,
|
||||
"end": 21.112
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 21.112,
|
||||
"end": 21.192
|
||||
},
|
||||
{
|
||||
"word": "say",
|
||||
"start": 21.192,
|
||||
"end": 21.352
|
||||
},
|
||||
{
|
||||
"word": "whoever",
|
||||
"start": 21.352,
|
||||
"end": 21.732
|
||||
},
|
||||
{
|
||||
"word": "first",
|
||||
"start": 21.732,
|
||||
"end": 22.312
|
||||
},
|
||||
{
|
||||
"word": "time.",
|
||||
"start": 22.312,
|
||||
"end": 22.592
|
||||
},
|
||||
{
|
||||
"word": "This",
|
||||
"start": 24.532,
|
||||
"end": 24.872
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 24.872,
|
||||
"end": 25.032
|
||||
},
|
||||
{
|
||||
"word": "me",
|
||||
"start": 25.032,
|
||||
"end": 25.252
|
||||
},
|
||||
{
|
||||
"word": "taking,",
|
||||
"start": 25.252,
|
||||
"end": 25.652
|
||||
},
|
||||
{
|
||||
"word": "talking",
|
||||
"start": 26.092,
|
||||
"end": 27.052
|
||||
},
|
||||
{
|
||||
"word": "alongside",
|
||||
"start": 27.052,
|
||||
"end": 27.532
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 27.532,
|
||||
"end": 27.792
|
||||
},
|
||||
{
|
||||
"word": "video.",
|
||||
"start": 27.792,
|
||||
"end": 28.052
|
||||
},
|
||||
{
|
||||
"word": "The",
|
||||
"start": 28.652,
|
||||
"end": 28.832
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 28.832,
|
||||
"end": 29.092
|
||||
},
|
||||
{
|
||||
"word": "is",
|
||||
"start": 29.092,
|
||||
"end": 29.272
|
||||
},
|
||||
{
|
||||
"word": "constrained",
|
||||
"start": 29.272,
|
||||
"end": 29.712
|
||||
},
|
||||
{
|
||||
"word": "within",
|
||||
"start": 29.712,
|
||||
"end": 30.052
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 30.052,
|
||||
"end": 30.232
|
||||
},
|
||||
{
|
||||
"word": "red",
|
||||
"start": 30.232,
|
||||
"end": 30.392
|
||||
},
|
||||
{
|
||||
"word": "square.",
|
||||
"start": 30.392,
|
||||
"end": 30.792
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 30.792,
|
||||
"end": 31.352
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 31.352,
|
||||
"end": 31.652
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 31.652,
|
||||
"end": 31.832
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 31.832,
|
||||
"end": 32.032
|
||||
},
|
||||
{
|
||||
"word": "stops",
|
||||
"start": 32.032,
|
||||
"end": 32.372
|
||||
},
|
||||
{
|
||||
"word": "immediately",
|
||||
"start": 32.372,
|
||||
"end": 32.852
|
||||
},
|
||||
{
|
||||
"word": "when",
|
||||
"start": 32.852,
|
||||
"end": 33.272
|
||||
},
|
||||
{
|
||||
"word": "we",
|
||||
"start": 33.272,
|
||||
"end": 33.452
|
||||
},
|
||||
{
|
||||
"word": "make",
|
||||
"start": 33.452,
|
||||
"end": 33.632
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 33.632,
|
||||
"end": 33.872
|
||||
},
|
||||
{
|
||||
"word": "transition",
|
||||
"start": 33.872,
|
||||
"end": 34.292
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 34.292,
|
||||
"end": 34.592
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 34.592,
|
||||
"end": 34.712
|
||||
},
|
||||
{
|
||||
"word": "next",
|
||||
"start": 34.712,
|
||||
"end": 34.932
|
||||
},
|
||||
{
|
||||
"word": "slide.",
|
||||
"start": 34.932,
|
||||
"end": 35.392
|
||||
},
|
||||
{
|
||||
"word": "I",
|
||||
"start": 37.112,
|
||||
"end": 37.632
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 37.632,
|
||||
"end": 37.692
|
||||
},
|
||||
{
|
||||
"word": "continue",
|
||||
"start": 37.692,
|
||||
"end": 37.992
|
||||
},
|
||||
{
|
||||
"word": "to",
|
||||
"start": 37.992,
|
||||
"end": 38.212
|
||||
},
|
||||
{
|
||||
"word": "talk",
|
||||
"start": 38.212,
|
||||
"end": 38.452
|
||||
},
|
||||
{
|
||||
"word": "without",
|
||||
"start": 38.452,
|
||||
"end": 38.792
|
||||
},
|
||||
{
|
||||
"word": "pause,",
|
||||
"start": 38.792,
|
||||
"end": 39.132
|
||||
},
|
||||
{
|
||||
"word": "but",
|
||||
"start": 39.372,
|
||||
"end": 39.472
|
||||
},
|
||||
{
|
||||
"word": "in",
|
||||
"start": 39.472,
|
||||
"end": 39.592
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 39.592,
|
||||
"end": 39.652
|
||||
},
|
||||
{
|
||||
"word": "finished",
|
||||
"start": 39.652,
|
||||
"end": 39.872
|
||||
},
|
||||
{
|
||||
"word": "recording",
|
||||
"start": 39.872,
|
||||
"end": 40.352
|
||||
},
|
||||
{
|
||||
"word": "there",
|
||||
"start": 40.352,
|
||||
"end": 41.092
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 41.092,
|
||||
"end": 41.252
|
||||
},
|
||||
{
|
||||
"word": "be",
|
||||
"start": 41.252,
|
||||
"end": 41.452
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 41.452,
|
||||
"end": 41.552
|
||||
},
|
||||
{
|
||||
"word": "pause",
|
||||
"start": 41.552,
|
||||
"end": 41.812
|
||||
},
|
||||
{
|
||||
"word": "before",
|
||||
"start": 41.812,
|
||||
"end": 42.192
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 42.192,
|
||||
"end": 42.392
|
||||
},
|
||||
{
|
||||
"word": "narration",
|
||||
"start": 42.392,
|
||||
"end": 42.932
|
||||
},
|
||||
{
|
||||
"word": "continues.",
|
||||
"start": 42.932,
|
||||
"end": 43.552
|
||||
},
|
||||
{
|
||||
"word": "Now",
|
||||
"start": 44.232,
|
||||
"end": 44.432
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"start": 44.432,
|
||||
"end": 44.572
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 44.572,
|
||||
"end": 44.812
|
||||
},
|
||||
{
|
||||
"word": "will",
|
||||
"start": 44.812,
|
||||
"end": 44.972
|
||||
},
|
||||
{
|
||||
"word": "play",
|
||||
"start": 44.972,
|
||||
"end": 45.272
|
||||
},
|
||||
{
|
||||
"word": "that",
|
||||
"start": 45.272,
|
||||
"end": 45.672
|
||||
},
|
||||
{
|
||||
"word": "pauses",
|
||||
"start": 45.672,
|
||||
"end": 46.412
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 46.412,
|
||||
"end": 46.672
|
||||
},
|
||||
{
|
||||
"word": "narration.",
|
||||
"start": 46.672,
|
||||
"end": 47.092
|
||||
},
|
||||
{
|
||||
"word": "Notice",
|
||||
"start": 48.352,
|
||||
"end": 49.092
|
||||
},
|
||||
{
|
||||
"word": "how",
|
||||
"start": 49.092,
|
||||
"end": 49.312
|
||||
},
|
||||
{
|
||||
"word": "my",
|
||||
"start": 49.312,
|
||||
"end": 49.492
|
||||
},
|
||||
{
|
||||
"word": "voice",
|
||||
"start": 49.492,
|
||||
"end": 49.752
|
||||
},
|
||||
{
|
||||
"word": "continues",
|
||||
"start": 49.752,
|
||||
"end": 50.272
|
||||
},
|
||||
{
|
||||
"word": "after",
|
||||
"start": 50.272,
|
||||
"end": 50.752
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"start": 50.752,
|
||||
"end": 50.932
|
||||
},
|
||||
{
|
||||
"word": "video",
|
||||
"start": 50.932,
|
||||
"end": 51.152
|
||||
},
|
||||
{
|
||||
"word": "finished.",
|
||||
"start": 51.152,
|
||||
"end": 51.652
|
||||
}
|
||||
]
|
||||
@@ -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,46 @@
|
||||
{
|
||||
"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": 14,
|
||||
"take": 25,
|
||||
"skip": 0
|
||||
},
|
||||
"zoomin_montagezoom": {
|
||||
"description": "Montage zoom",
|
||||
"source_file": "MontageZoom.mp4",
|
||||
"output_file": "MontageZoom.mp4",
|
||||
"cutout": "square",
|
||||
"is_shared": true,
|
||||
"filter": []
|
||||
},
|
||||
"narration_combined": {
|
||||
"source_file": "narration_combined.mov",
|
||||
"output_file": "narration_combined.mov",
|
||||
"cutout": "square",
|
||||
"filter": []
|
||||
}
|
||||
}
|
||||
+91
-6
@@ -1,11 +1,96 @@
|
||||
{
|
||||
"id": "VideoExample",
|
||||
"name": "Example",
|
||||
"description": "In this video, I demonstrate the Gnommo video editing pipeline - a code-first approach to creating presenter-mode videos from Keynote presentations.",
|
||||
"footer": "Subscribe for more tutorials!\nTwitter: @example",
|
||||
"resolution": [1920, 1080],
|
||||
"fps": 30,
|
||||
"talkinghead": {
|
||||
"x": 50,
|
||||
"y": 600,
|
||||
"targetheight": 400
|
||||
"defaultSlideType": "fullscreen",
|
||||
"keynote_file": "media/example.key",
|
||||
"transcript": "media/videos/talking_head.transcript.json",
|
||||
"narration": "media/narration/narration.json",
|
||||
"background": "blackbackground",
|
||||
"videos": "media/videos/videos.json",
|
||||
"slides": "media/slides/Example/slides.json",
|
||||
"audio": "media/audio/audio.json",
|
||||
"output": "final.mp4",
|
||||
"default_filters": {
|
||||
"talkinghead": [
|
||||
{
|
||||
"type": "audio_normalize",
|
||||
"enable":false,
|
||||
"eq_bands": [
|
||||
{"freq": 47, "gain": -15, "type": "lowshelf"},
|
||||
{"freq": 107, "gain": -1.3, "q": 1.2},
|
||||
{"freq": 597, "gain": -5.2, "q": 2},
|
||||
{"freq": 11811, "gain": 2.8, "q": 1},
|
||||
{"freq": 24000, "gain": 3.9, "type": "highshelf"}
|
||||
],
|
||||
"highpass": 0,
|
||||
"room_eq": false,
|
||||
"dereverb_model": "shared_assets/models/std.rnnn",
|
||||
"dereverb_mix": 0.8,
|
||||
"denoise": true,
|
||||
"noise_floor": -25,
|
||||
"gate": true,
|
||||
"gate_threshold": -35,
|
||||
"gate_range": -20,
|
||||
"compress": true,
|
||||
"threshold": -20,
|
||||
"ratio": 3,
|
||||
"attack": 12,
|
||||
"release": 100,
|
||||
"makeup": 2,
|
||||
"normalize": true,
|
||||
"target_lufs": -16,
|
||||
"target_lra": 11,
|
||||
"target_tp": -1.5
|
||||
},
|
||||
"defaultSlideType": "square",
|
||||
"background_video": ""
|
||||
{
|
||||
"type": "color_grade",
|
||||
"saturation": 1.02,
|
||||
"contrast": 1.05,
|
||||
"brightness": 0.04,
|
||||
"bm": 0.0,
|
||||
"gm": 0.02,
|
||||
"rm": -0.07
|
||||
},
|
||||
{
|
||||
"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": "80%"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Tasks: example
|
||||
_Generated: 2026-07-15_
|
||||
|
||||
## Slide Alignment Issues (7)
|
||||
Slide markers that could not be matched to the spoken narration (likely adlibbed).
|
||||
|
||||
- [ ] `S6` — _"(end of: voice continues after the video finished)"_
|
||||
- [ ] `S7` — _"This is the first slide. It appears immediately."_
|
||||
- [ ] `S8` — _"However, this is the second slide. It should appea"_
|
||||
- [ ] `S9` — _"This is me talking alongside a video. The video is"_
|
||||
- [ ] `S10` — _"I will continue to talk without pause, but in the"_
|
||||
- [ ] `S11` — _"Notice how my voice continues after the video fini"_
|
||||
- [ ] `S12` — _"(repaired: voice continues after the video finished)"_
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"talkinghead": [
|
||||
{
|
||||
"type": "audio_normalize",
|
||||
"enabled": true,
|
||||
"highpass": 85,
|
||||
"eq_bands": [
|
||||
{
|
||||
"type": "peak",
|
||||
"freq": 200,
|
||||
"gain": -3.5,
|
||||
"q": 1.2
|
||||
}
|
||||
],
|
||||
"compress": false,
|
||||
"normalize": true,
|
||||
"target_lufs": -14,
|
||||
"target_lra": 11,
|
||||
"target_tp": -1.5
|
||||
},
|
||||
{
|
||||
"type": "gnommokey",
|
||||
"screen_color": [
|
||||
81,
|
||||
137,
|
||||
65
|
||||
],
|
||||
"screen_gain": 175,
|
||||
"screen_balance": 58,
|
||||
"despill_bias": [
|
||||
235,
|
||||
222,
|
||||
210
|
||||
],
|
||||
"despill_strength": 7.0,
|
||||
"spill_suppress": 1.3,
|
||||
"yellow_protect": 0.9,
|
||||
"edge_erode": 1.0,
|
||||
"clip_black": 0,
|
||||
"clip_white": 100
|
||||
},
|
||||
{
|
||||
"type": "color_grade",
|
||||
"saturation": 1.02,
|
||||
"contrast": 1.05,
|
||||
"brightness": 0.04,
|
||||
"bm": 0.0,
|
||||
"gm": 0.02,
|
||||
"rm": -0.07
|
||||
},
|
||||
{
|
||||
"type": "mask",
|
||||
"left": 0.05,
|
||||
"right": 0.1,
|
||||
"top": 0.1,
|
||||
"bottom": 0.0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,154 +1,21 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# GnommoEditor - Code-first video editing pipeline
|
||||
# This is a thin wrapper that activates the venv and runs the Python CLI.
|
||||
#
|
||||
# Usage:
|
||||
# gnommo.sh -p <project> Render project
|
||||
# gnommo.sh -p <project> import Generate slides.json from image files
|
||||
# gnommo.sh -p <project> validate Validate only
|
||||
# gnommo.sh -p <project> preprocess Apply video preprocessing filters
|
||||
# gnommo.sh -p <project> transcribe Transcribe video
|
||||
# gnommo.sh -p <project> align Align markers to transcript
|
||||
# gnommo.sh -p <project> all Full pipeline: transcribe → align → render
|
||||
# Usage: gnommo -p <project> [action] [options]
|
||||
# Run with -h for full help.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
|
||||
# Check for venv
|
||||
if [[ ! -f "$VENV_PYTHON" ]]; then
|
||||
echo "Error: Virtual environment not found at $SCRIPT_DIR/venv"
|
||||
echo "Create it with: python -m venv venv && ./venv/bin/pip install openai-whisper"
|
||||
echo "Create it with: python -m venv venv && ./venv/bin/pip install -e . openai-whisper"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
PROJECT=""
|
||||
COMMAND="render"
|
||||
VERBOSE=""
|
||||
FORCE=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: gnommo.sh -p <project> [command] [options]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " render Render video (default)"
|
||||
echo " import Generate slides.json from image files"
|
||||
echo " validate Validate project only"
|
||||
echo " preprocess Apply video preprocessing filters (chroma key, etc.)"
|
||||
echo " transcribe Transcribe video audio"
|
||||
echo " align Align manuscript to transcript"
|
||||
echo " all Full pipeline: transcribe → align → render"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -p <dir> Project directory (required)"
|
||||
echo " -v Verbose output"
|
||||
echo " -f Force overwrite existing files"
|
||||
echo " -h Show this help"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " gnommo.sh -p video1 # Render video1 project"
|
||||
echo " gnommo.sh -p video1 import # Generate slides.json"
|
||||
echo " gnommo.sh -p video1 import -f # Force overwrite slides.json"
|
||||
echo " gnommo.sh -p video1 validate # Validate only"
|
||||
echo " gnommo.sh -p video1 all # Full pipeline"
|
||||
exit 0
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-p|--project)
|
||||
PROJECT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-v|--verbose)
|
||||
VERBOSE="-v"
|
||||
shift
|
||||
;;
|
||||
-f|--force)
|
||||
FORCE="-f"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
import|validate|render|preprocess|transcribe|align|all)
|
||||
COMMAND="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate project argument
|
||||
if [[ -z "$PROJECT" ]]; then
|
||||
echo "Error: Project directory required (-p <project>)"
|
||||
echo ""
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ ! -d "$PROJECT" ]]; then
|
||||
echo "Error: Project directory not found: $PROJECT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$PROJECT/project.json" ]]; then
|
||||
echo "Error: project.json not found in $PROJECT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run commands using new CLI interface
|
||||
run_gnommo() {
|
||||
"$VENV_PYTHON" -m gnommo -p "$PROJECT" -a "$1" $VERBOSE
|
||||
}
|
||||
|
||||
run_gnommo_import() {
|
||||
"$VENV_PYTHON" -m gnommo -p "$PROJECT" -a validate -i $FORCE $VERBOSE
|
||||
}
|
||||
|
||||
case $COMMAND in
|
||||
import)
|
||||
echo "=== Importing assets for $PROJECT ==="
|
||||
run_gnommo_import
|
||||
;;
|
||||
|
||||
validate)
|
||||
echo "=== Validating $PROJECT ==="
|
||||
run_gnommo validate
|
||||
;;
|
||||
|
||||
transcribe)
|
||||
echo "=== Transcribing $PROJECT ==="
|
||||
run_gnommo transcribe
|
||||
;;
|
||||
|
||||
align)
|
||||
echo "=== Aligning $PROJECT ==="
|
||||
run_gnommo align
|
||||
;;
|
||||
|
||||
render)
|
||||
echo "=== Rendering $PROJECT ==="
|
||||
run_gnommo render
|
||||
;;
|
||||
|
||||
preprocess)
|
||||
echo "=== Preprocessing $PROJECT ==="
|
||||
run_gnommo preprocess
|
||||
;;
|
||||
|
||||
all)
|
||||
echo "=== Full Pipeline: $PROJECT ==="
|
||||
run_gnommo all
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown command: $COMMAND"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
# Pass all arguments directly to the Python CLI
|
||||
exec "$VENV_PYTHON" -m gnommo "$@"
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Alignment stage: match manuscript markers to transcript timestamps."""
|
||||
|
||||
import csv
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import GnommoError
|
||||
from .transcriber import TranscribedWord
|
||||
|
||||
|
||||
class AlignmentError(GnommoError):
|
||||
"""Error during alignment."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkerAlignment:
|
||||
"""A marker with its aligned timestamp."""
|
||||
marker_id: str
|
||||
timestamp: float
|
||||
matched_phrase: str
|
||||
confidence: float # 0-1, how confident the match is
|
||||
|
||||
|
||||
def extract_marker_contexts(manuscript_text: str) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Extract markers and the text immediately following them.
|
||||
|
||||
Returns:
|
||||
List of (marker_id, following_text) tuples
|
||||
"""
|
||||
# Split by markers, keeping the markers
|
||||
parts = re.split(r"\[([A-Za-z0-9_]+)\]", manuscript_text)
|
||||
|
||||
# parts will be: [text_before, marker1, text_after1, marker2, text_after2, ...]
|
||||
contexts = []
|
||||
|
||||
for i in range(1, len(parts), 2):
|
||||
marker_id = parts[i]
|
||||
if i + 1 < len(parts):
|
||||
following_text = parts[i + 1].strip()
|
||||
# Get first sentence or first N words
|
||||
following_text = _get_first_phrase(following_text)
|
||||
contexts.append((marker_id, following_text))
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def _get_first_phrase(text: str, max_words: int = 10) -> str:
|
||||
"""Extract first phrase (up to first sentence end or max_words)."""
|
||||
# Clean up the text
|
||||
text = text.replace("\n", " ").strip()
|
||||
|
||||
# Find first sentence boundary
|
||||
match = re.search(r"[.!?]", text)
|
||||
if match and match.start() < 200:
|
||||
text = text[: match.start()]
|
||||
|
||||
# Limit to max_words
|
||||
words = text.split()[:max_words]
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize text for matching (lowercase, remove punctuation)."""
|
||||
text = text.lower()
|
||||
text = re.sub(r"[^\w\s]", "", text)
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def find_phrase_in_transcript(
|
||||
phrase: str,
|
||||
transcript: list[TranscribedWord],
|
||||
start_from: int = 0,
|
||||
) -> tuple[int, float]:
|
||||
"""
|
||||
Find a phrase in the transcript and return the word index and timestamp.
|
||||
|
||||
Uses sliding window matching with normalization.
|
||||
|
||||
Returns:
|
||||
Tuple of (word_index, timestamp) or (-1, 0.0) if not found
|
||||
"""
|
||||
phrase_normalized = normalize_text(phrase)
|
||||
phrase_words = phrase_normalized.split()
|
||||
|
||||
if not phrase_words:
|
||||
return -1, 0.0
|
||||
|
||||
# Try to find increasingly shorter prefixes
|
||||
for length in range(len(phrase_words), 2, -1):
|
||||
target = " ".join(phrase_words[:length])
|
||||
|
||||
# Sliding window through transcript
|
||||
for i in range(start_from, len(transcript) - length + 1):
|
||||
window_words = [normalize_text(transcript[j].word) for j in range(i, i + length)]
|
||||
window_text = " ".join(window_words)
|
||||
|
||||
if target in window_text or window_text in target:
|
||||
return i, transcript[i].start
|
||||
|
||||
# Fallback: try to find just the first few words
|
||||
if len(phrase_words) >= 2:
|
||||
target = " ".join(phrase_words[:3])
|
||||
for i in range(start_from, len(transcript) - 2):
|
||||
window_words = [normalize_text(transcript[j].word) for j in range(i, min(i + 5, len(transcript)))]
|
||||
window_text = " ".join(window_words)
|
||||
if phrase_words[0] in window_text and phrase_words[1] in window_text:
|
||||
return i, transcript[i].start
|
||||
|
||||
return -1, 0.0
|
||||
|
||||
|
||||
def align_markers(
|
||||
manuscript_text: str,
|
||||
transcript: list[TranscribedWord],
|
||||
offset_seconds: float = -1.0,
|
||||
) -> list[MarkerAlignment]:
|
||||
"""
|
||||
Align manuscript markers to transcript timestamps.
|
||||
|
||||
Args:
|
||||
manuscript_text: Full manuscript text with [S1], [S2] etc.
|
||||
transcript: Word-level transcript with timestamps
|
||||
offset_seconds: Offset to apply to found timestamps (default -1.0)
|
||||
|
||||
Returns:
|
||||
List of MarkerAlignment with timestamps
|
||||
"""
|
||||
contexts = extract_marker_contexts(manuscript_text)
|
||||
alignments: list[MarkerAlignment] = []
|
||||
|
||||
last_index = 0
|
||||
|
||||
for marker_id, following_text in contexts:
|
||||
idx, timestamp = find_phrase_in_transcript(
|
||||
following_text, transcript, start_from=last_index
|
||||
)
|
||||
|
||||
if idx >= 0:
|
||||
# Apply offset (e.g., -1 second before the word)
|
||||
adjusted_time = max(0.0, timestamp + offset_seconds)
|
||||
alignments.append(MarkerAlignment(
|
||||
marker_id=marker_id,
|
||||
timestamp=adjusted_time,
|
||||
matched_phrase=following_text[:50],
|
||||
confidence=1.0,
|
||||
))
|
||||
last_index = idx
|
||||
else:
|
||||
# Could not find match - report but continue
|
||||
alignments.append(MarkerAlignment(
|
||||
marker_id=marker_id,
|
||||
timestamp=-1.0, # Indicates not found
|
||||
matched_phrase=following_text[:50],
|
||||
confidence=0.0,
|
||||
))
|
||||
|
||||
return alignments
|
||||
|
||||
|
||||
def save_aligned_transcript(
|
||||
alignments: list[MarkerAlignment],
|
||||
transcript: list[TranscribedWord],
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Save aligned transcript as CSV compatible with gnommo's transcript.csv format.
|
||||
|
||||
Format:
|
||||
t,word
|
||||
0.00,Hello
|
||||
1.50,[S1]
|
||||
1.51,This
|
||||
...
|
||||
"""
|
||||
# Build list of (timestamp, word) including markers
|
||||
entries: list[tuple[float, str]] = []
|
||||
|
||||
# Add all words from transcript
|
||||
for word in transcript:
|
||||
entries.append((word.start, word.word))
|
||||
|
||||
# Add markers at their aligned positions
|
||||
for alignment in alignments:
|
||||
if alignment.timestamp >= 0:
|
||||
entries.append((alignment.timestamp, f"[{alignment.marker_id}]"))
|
||||
|
||||
# Sort by timestamp
|
||||
entries.sort(key=lambda x: x[0])
|
||||
|
||||
# Write CSV
|
||||
with open(output_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["t", "word"])
|
||||
for timestamp, word in entries:
|
||||
writer.writerow([f"{timestamp:.2f}", word])
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
"""GnommoCache - External storage extension for large media files.
|
||||
|
||||
Provides transparent fallback to external storage when files are not found locally.
|
||||
Configure via ~/.gnommo.conf:
|
||||
|
||||
[cache]
|
||||
path = /Volumes/GnommoDisk/gnommo
|
||||
|
||||
Files are looked up first locally, then in the cache at:
|
||||
{cache_path}/{project_name}/{relative_path}
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
_cache_config: Optional[dict] = None
|
||||
_assets_config: Optional[dict] = None
|
||||
_perf_config: Optional[dict] = None
|
||||
|
||||
|
||||
def get_ffmpeg_thread_count() -> int:
|
||||
"""Return FFmpeg thread count based on [performance] cpu_limit in ~/.gnommo.conf.
|
||||
|
||||
cpu_limit is a fraction of logical CPUs (e.g. 0.8 = 80%).
|
||||
Defaults to 1 when not configured, which is safe on memory-constrained machines.
|
||||
|
||||
Example ~/.gnommo.conf:
|
||||
[performance]
|
||||
cpu_limit = 0.8
|
||||
"""
|
||||
global _perf_config
|
||||
if _perf_config is None:
|
||||
config_path = Path.home() / ".gnommo.conf"
|
||||
_perf_config = {}
|
||||
if config_path.exists():
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(config_path)
|
||||
if cfg.has_option("performance", "cpu_limit"):
|
||||
try:
|
||||
_perf_config["cpu_limit"] = float(
|
||||
cfg.get("performance", "cpu_limit")
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
cpu_limit = _perf_config.get("cpu_limit")
|
||||
if cpu_limit is None:
|
||||
return 1
|
||||
cpu_count = os.cpu_count() or 1
|
||||
return max(1, int(cpu_count * cpu_limit))
|
||||
|
||||
|
||||
def get_render_chunk_size() -> Optional[int]:
|
||||
"""Return slides-per-chunk for auto-chunked rendering, or None if not configured.
|
||||
|
||||
When set, cmd_render splits the filter graph into chunks of this many slides
|
||||
to avoid OOM from allocating filter buffers for the entire video at once.
|
||||
|
||||
Example ~/.gnommo.conf:
|
||||
[performance]
|
||||
render_chunk_slides = 15
|
||||
"""
|
||||
global _perf_config
|
||||
if _perf_config is None:
|
||||
get_ffmpeg_thread_count() # populates _perf_config
|
||||
val = _perf_config.get("render_chunk_slides")
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return max(1, int(val))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def load_cache_config() -> Optional[Path]:
|
||||
"""Load gnommo.conf and return cache path if configured.
|
||||
|
||||
Configuration file location: ~/.gnommo.conf
|
||||
|
||||
Returns:
|
||||
Path to the cache root directory, or None if not configured.
|
||||
"""
|
||||
global _cache_config
|
||||
if _cache_config is not None:
|
||||
return _cache_config.get("path")
|
||||
|
||||
config_path = Path.home() / ".gnommo.conf"
|
||||
if not config_path.exists():
|
||||
_cache_config = {}
|
||||
return None
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
if config.has_option("cache", "path"):
|
||||
cache_path = Path(config.get("cache", "path"))
|
||||
_cache_config = {"path": cache_path}
|
||||
return cache_path
|
||||
|
||||
_cache_config = {}
|
||||
return None
|
||||
|
||||
|
||||
def load_assets_process_cache() -> Optional[Path]:
|
||||
"""Return the process-cache path on the [assets] disk, or None if not configured.
|
||||
|
||||
Derived by replacing the last component of the [assets] path with
|
||||
that name + "cache". E.g.:
|
||||
[assets] path = /Volumes/LaCie Jens/Projects/gnommo
|
||||
→ process cache = /Volumes/LaCie Jens/Projects/gnommocache
|
||||
|
||||
This mirrors the GnommoDisk convention where the asset root is
|
||||
/Volumes/GnommoDisk/gnommo and the process cache is /Volumes/GnommoDisk/gnommocache.
|
||||
"""
|
||||
assets_path = load_assets_config()
|
||||
if assets_path is None:
|
||||
return None
|
||||
return assets_path.parent / (assets_path.name + "cache")
|
||||
|
||||
|
||||
def load_assets_config() -> Optional[Path]:
|
||||
"""Load gnommo.conf and return the [assets] path if configured.
|
||||
|
||||
The assets path is a second external fallback (e.g. a LaCie drive) with
|
||||
the same directory layout as the gnommo project root. Resolution order is:
|
||||
local → cache ([cache] path) → assets ([assets] path).
|
||||
|
||||
Example ~/.gnommo.conf:
|
||||
[assets]
|
||||
path = /Volumes/LaCie Jens/Projects/gnommo
|
||||
"""
|
||||
global _assets_config
|
||||
if _assets_config is not None:
|
||||
return _assets_config.get("path")
|
||||
|
||||
config_path = Path.home() / ".gnommo.conf"
|
||||
if not config_path.exists():
|
||||
_assets_config = {}
|
||||
return None
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
if config.has_option("assets", "path"):
|
||||
assets_path = Path(config.get("assets", "path"))
|
||||
_assets_config = {"path": assets_path}
|
||||
return assets_path
|
||||
|
||||
_assets_config = {}
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_against_base(
|
||||
local_path: Path, project_path: Path, base: Path
|
||||
) -> Optional[Path]:
|
||||
"""Try to find local_path mirrored under base.
|
||||
|
||||
Tries two mappings:
|
||||
1. project-relative: base / project_name / relative_to_project
|
||||
2. gnommo-root-relative: base / relative_to_project_parent (e.g. shared_assets/…)
|
||||
"""
|
||||
try:
|
||||
relative = local_path.relative_to(project_path)
|
||||
p = base / project_path.name / relative
|
||||
if p.exists():
|
||||
return p
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
relative = local_path.relative_to(project_path.parent)
|
||||
p = base / relative
|
||||
if p.exists():
|
||||
return p
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_with_cache(
|
||||
local_path: Path,
|
||||
project_path: Path,
|
||||
) -> Tuple[Path, bool]:
|
||||
"""Resolve a file path with external-disk fallback (read-only).
|
||||
|
||||
Resolution order:
|
||||
1. local_path (always checked first)
|
||||
2. [cache] path — typically GnommoDisk
|
||||
3. [assets] path — optional second drive (e.g. LaCie)
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_path, is_from_external) where is_from_external=True
|
||||
when the file was found on an external drive rather than locally.
|
||||
"""
|
||||
if local_path.exists():
|
||||
return local_path, False
|
||||
|
||||
for base in (load_cache_config(), load_assets_config()):
|
||||
if base is None:
|
||||
continue
|
||||
resolved = _resolve_against_base(local_path, project_path, base)
|
||||
if resolved is not None:
|
||||
return resolved, True
|
||||
|
||||
return local_path, False
|
||||
|
||||
|
||||
def load_server_config() -> Optional[dict]:
|
||||
"""Load server rsync config from ~/.gnommo.conf.
|
||||
|
||||
Expected config:
|
||||
[server]
|
||||
host = 76.13.144.52
|
||||
user = root
|
||||
path = /gnommo/project
|
||||
|
||||
Returns:
|
||||
Dict with keys host, user, path (and optionally port), or None.
|
||||
"""
|
||||
config_path = Path.home() / ".gnommo.conf"
|
||||
if not config_path.exists():
|
||||
return None
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
if not config.has_section("server"):
|
||||
return None
|
||||
|
||||
host = config.get("server", "host", fallback=None)
|
||||
user = config.get("server", "user", fallback="root")
|
||||
path = config.get("server", "path", fallback="/gnommo/project")
|
||||
port = config.get("server", "port", fallback="22")
|
||||
|
||||
if not host:
|
||||
return None
|
||||
|
||||
return {"host": host, "user": user, "path": path, "port": port}
|
||||
|
||||
|
||||
def is_cache_configured() -> bool:
|
||||
"""Check if any external fallback is configured."""
|
||||
return load_cache_config() is not None or load_assets_config() is not None
|
||||
|
||||
|
||||
def get_cache_info() -> Optional[str]:
|
||||
"""Get a human-readable string of all configured external paths."""
|
||||
parts = []
|
||||
for label, path in (("cache", load_cache_config()), ("assets", load_assets_config())):
|
||||
if path is None:
|
||||
continue
|
||||
status = "connected" if path.exists() else "not connected"
|
||||
parts.append(f"{path} ({status})")
|
||||
return "; ".join(parts) if parts else None
|
||||
+6122
-180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
"""Description generator: Create YouTube description with chapters, citations, and attributions."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .models import (
|
||||
Attribution,
|
||||
Citation,
|
||||
ProjectConfig,
|
||||
SlideDefinition,
|
||||
VideoSource,
|
||||
)
|
||||
from .transcriber import TranscribedWord
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChapterMarker:
|
||||
"""A chapter marker with timestamp and title."""
|
||||
|
||||
slide_id: str
|
||||
timestamp: float
|
||||
title: str
|
||||
|
||||
|
||||
def _format_timestamp(seconds: float) -> str:
|
||||
"""Format seconds as M:SS or H:MM:SS for YouTube chapters."""
|
||||
if seconds < 0:
|
||||
return "0:00"
|
||||
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
else:
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
|
||||
def _extract_chapter_title(
|
||||
manuscript_text: str, slide_id: str, slides: dict[str, SlideDefinition]
|
||||
) -> str:
|
||||
"""
|
||||
Extract a chapter title for a slide.
|
||||
|
||||
Tries to find meaningful title from:
|
||||
1. First sentence/line after the slide marker
|
||||
2. Falls back to slide ID if nothing useful found
|
||||
"""
|
||||
# Find the marker and text after it
|
||||
pattern = rf"\[{re.escape(slide_id)}\]\s*(.+?)(?=\[S\d+\]|\[video:|\[narration:|\Z)"
|
||||
match = re.search(pattern, manuscript_text, re.DOTALL)
|
||||
|
||||
if match:
|
||||
text = match.group(1).strip()
|
||||
# Remove any other markers from the text
|
||||
text = re.sub(r"\[[^\]]+\]", "", text).strip()
|
||||
|
||||
if text:
|
||||
# Take first line or first sentence
|
||||
first_line = text.split("\n")[0].strip()
|
||||
# Truncate if too long
|
||||
if len(first_line) > 50:
|
||||
# Try to break at word boundary
|
||||
truncated = first_line[:47]
|
||||
last_space = truncated.rfind(" ")
|
||||
if last_space > 30:
|
||||
truncated = truncated[:last_space]
|
||||
first_line = truncated + "..."
|
||||
|
||||
if first_line:
|
||||
return first_line
|
||||
|
||||
# Fallback to slide number
|
||||
slide_num = slide_id[1:] if slide_id.startswith("S") else slide_id
|
||||
return f"Section {slide_num}"
|
||||
|
||||
|
||||
def _align_citation_to_transcription(
|
||||
citation: Citation,
|
||||
transcription: list[TranscribedWord],
|
||||
manuscript_text: str,
|
||||
) -> float:
|
||||
"""
|
||||
Align a citation to the transcription to find its timestamp.
|
||||
|
||||
Uses the context text following the citation to find the approximate
|
||||
position in the audio.
|
||||
|
||||
Returns timestamp in seconds, or -1 if not found.
|
||||
"""
|
||||
if not transcription or not citation.context:
|
||||
return -1.0
|
||||
|
||||
# Get more context from the manuscript for better matching
|
||||
# Find the citation in the manuscript and get surrounding text
|
||||
pattern = rf"\[cite:{re.escape(citation.reference)}\]\s*(.{{0,200}})"
|
||||
match = re.search(pattern, manuscript_text, re.DOTALL)
|
||||
|
||||
if not match:
|
||||
return -1.0
|
||||
|
||||
context_text = match.group(1).strip()
|
||||
# Clean up: remove markers, normalize whitespace
|
||||
context_text = re.sub(r"\[[^\]]+\]", "", context_text)
|
||||
context_text = " ".join(context_text.split())
|
||||
|
||||
if not context_text:
|
||||
return -1.0
|
||||
|
||||
# Normalize for matching
|
||||
context_words = context_text.lower().split()[:10] # Use up to 10 words
|
||||
if not context_words:
|
||||
return -1.0
|
||||
|
||||
# Build normalized transcription
|
||||
trans_words = [(w.word.lower(), w.start) for w in transcription]
|
||||
|
||||
# Simple sliding window match
|
||||
best_match_score = 0
|
||||
best_match_time = -1.0
|
||||
|
||||
for i in range(len(trans_words) - len(context_words) + 1):
|
||||
matches = 0
|
||||
for j, ctx_word in enumerate(context_words):
|
||||
trans_word = trans_words[i + j][0]
|
||||
# Allow partial matches for longer words
|
||||
if ctx_word == trans_word:
|
||||
matches += 1
|
||||
elif len(ctx_word) >= 4 and (
|
||||
ctx_word in trans_word or trans_word in ctx_word
|
||||
):
|
||||
matches += 0.5
|
||||
|
||||
score = matches / len(context_words)
|
||||
if score > best_match_score and score >= 0.5:
|
||||
best_match_score = score
|
||||
best_match_time = trans_words[i][1]
|
||||
|
||||
return best_match_time
|
||||
|
||||
|
||||
def generate_chapters(
|
||||
manuscript_text: str,
|
||||
slides: dict[str, SlideDefinition],
|
||||
marker_timings: list, # List of MarkerTiming from transformer
|
||||
min_chapter_duration: float = 30.0,
|
||||
) -> list[ChapterMarker]:
|
||||
"""
|
||||
Generate chapter markers from slide timings.
|
||||
|
||||
Args:
|
||||
manuscript_text: The manuscript content
|
||||
slides: Slide definitions
|
||||
marker_timings: Aligned marker timings from the transformer
|
||||
min_chapter_duration: Minimum seconds between chapters (merges short ones)
|
||||
|
||||
Returns:
|
||||
List of ChapterMarker objects
|
||||
"""
|
||||
chapters = []
|
||||
|
||||
# Build timing lookup
|
||||
timing_lookup = {
|
||||
t.marker_id: t.timestamp for t in marker_timings if t.timestamp >= 0
|
||||
}
|
||||
|
||||
# Process slides in order
|
||||
slide_ids = sorted(
|
||||
[s for s in slides.keys() if s.startswith("S")],
|
||||
key=lambda x: int(x[1:]) if x[1:].isdigit() else 0,
|
||||
)
|
||||
|
||||
for slide_id in slide_ids:
|
||||
if slide_id not in timing_lookup:
|
||||
continue
|
||||
timestamp = timing_lookup[slide_id]
|
||||
title = _extract_chapter_title(manuscript_text, slide_id, slides)
|
||||
if chapters and (timestamp - chapters[-1].timestamp) < min_chapter_duration:
|
||||
continue # Skip this chapter, previous one covers it
|
||||
|
||||
chapters.append(
|
||||
ChapterMarker(
|
||||
slide_id=slide_id,
|
||||
timestamp=timestamp,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
# Ensure first chapter starts at 0:00
|
||||
if chapters and chapters[0].timestamp > 0:
|
||||
chapters[0] = ChapterMarker(
|
||||
slide_id=chapters[0].slide_id,
|
||||
timestamp=0.0,
|
||||
title=chapters[0].title,
|
||||
)
|
||||
|
||||
return chapters
|
||||
|
||||
|
||||
def collect_attributions(
|
||||
videos: dict[str, VideoSource],
|
||||
video_events: list = None,
|
||||
) -> list[tuple[str, Attribution]]:
|
||||
"""
|
||||
Collect all video attributions.
|
||||
|
||||
Returns list of (video_id, Attribution) tuples for videos that have attribution.
|
||||
Only includes videos that are actually used in the project (via video_events)
|
||||
or videos from shared assets that have attribution.
|
||||
"""
|
||||
attributions = []
|
||||
|
||||
# Get set of used video IDs from events
|
||||
used_video_ids = set()
|
||||
if video_events:
|
||||
for event in video_events:
|
||||
used_video_ids.add(event.video_id)
|
||||
|
||||
for video_id, video_source in videos.items():
|
||||
if video_source.attribution:
|
||||
# Include if used in video or if it's a shared asset
|
||||
if video_id in used_video_ids or video_source.is_shared:
|
||||
attributions.append((video_id, video_source.attribution))
|
||||
|
||||
return attributions
|
||||
|
||||
|
||||
def generate_description(
|
||||
config: ProjectConfig,
|
||||
manuscript_text: str,
|
||||
slides: dict[str, SlideDefinition],
|
||||
videos: dict[str, VideoSource],
|
||||
marker_timings: list,
|
||||
transcription: list[TranscribedWord] = None,
|
||||
video_events: list = None,
|
||||
citations: list[Citation] = None,
|
||||
include_chapters: bool = True,
|
||||
include_citations: bool = True,
|
||||
include_attributions: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Generate complete YouTube description.
|
||||
|
||||
Combines:
|
||||
- Video description from project.json
|
||||
- Chapter markers (optional)
|
||||
- Citations from manuscript (optional)
|
||||
- Stock footage attributions (optional)
|
||||
- Footer from project.json
|
||||
|
||||
Returns formatted description text.
|
||||
"""
|
||||
sections = []
|
||||
|
||||
# 1. Video description
|
||||
if config.description:
|
||||
sections.append(config.description.strip())
|
||||
|
||||
# 2. Chapters
|
||||
if include_chapters:
|
||||
chapters = generate_chapters(manuscript_text, slides, marker_timings)
|
||||
if chapters:
|
||||
chapter_lines = ["CHAPTERS", ""]
|
||||
for ch in chapters:
|
||||
chapter_lines.append(f"{_format_timestamp(ch.timestamp)} {ch.title}")
|
||||
sections.append("\n".join(chapter_lines))
|
||||
|
||||
# 3. Citations/References
|
||||
if include_citations:
|
||||
citations = citations or []
|
||||
if citations and transcription:
|
||||
# Align citations to get timestamps
|
||||
for citation in citations:
|
||||
citation.timestamp = _align_citation_to_transcription(
|
||||
citation, transcription, manuscript_text
|
||||
)
|
||||
|
||||
if citations:
|
||||
ref_lines = ["REFERENCES", ""]
|
||||
for citation in citations:
|
||||
if citation.timestamp >= 0:
|
||||
ref_lines.append(
|
||||
f"{_format_timestamp(citation.timestamp)} - {citation.reference}"
|
||||
)
|
||||
else:
|
||||
ref_lines.append(f"- {citation.reference}")
|
||||
sections.append("\n".join(ref_lines))
|
||||
|
||||
# 4. Stock footage attributions
|
||||
if include_attributions:
|
||||
attributions = collect_attributions(videos, video_events)
|
||||
if attributions:
|
||||
attr_lines = ["STOCK FOOTAGE", ""]
|
||||
for video_id, attr in attributions:
|
||||
# Format: "Description by Creator via Source: URL"
|
||||
line = f"{video_id.replace('_', ' ').title()} by {attr.creator} via {attr.source.title()}"
|
||||
if attr.url:
|
||||
line += f": {attr.url}"
|
||||
attr_lines.append(line)
|
||||
sections.append("\n".join(attr_lines))
|
||||
|
||||
# 5. Footer
|
||||
if config.footer:
|
||||
sections.append(config.footer.strip())
|
||||
|
||||
# Join sections with double newlines
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def write_description_file(
|
||||
output_path: Path,
|
||||
config: ProjectConfig,
|
||||
manuscript_text: str,
|
||||
slides: dict[str, SlideDefinition],
|
||||
videos: dict[str, VideoSource],
|
||||
marker_timings: list,
|
||||
transcription: list[TranscribedWord] = None,
|
||||
video_events: list = None,
|
||||
citations: list[Citation] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate and write YouTube description to file.
|
||||
|
||||
Args:
|
||||
output_path: Path to write description (e.g., out/description_youtube.txt)
|
||||
config: Project configuration
|
||||
manuscript_text: Manuscript content
|
||||
slides: Slide definitions
|
||||
videos: Video definitions
|
||||
marker_timings: Aligned marker timings
|
||||
transcription: Word-level transcription (optional, for citation timestamps)
|
||||
video_events: Video events from render plan (optional, for attribution filtering)
|
||||
citations: Pre-extracted citations (optional, loaded from citations.json)
|
||||
|
||||
Returns:
|
||||
The generated description text
|
||||
"""
|
||||
description = generate_description(
|
||||
config=config,
|
||||
manuscript_text=manuscript_text,
|
||||
slides=slides,
|
||||
videos=videos,
|
||||
marker_timings=marker_timings,
|
||||
transcription=transcription,
|
||||
video_events=video_events,
|
||||
citations=citations,
|
||||
)
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write description
|
||||
output_path.write_text(description, encoding="utf-8")
|
||||
|
||||
return description
|
||||
+15
-3
@@ -7,12 +7,14 @@ from typing import Optional
|
||||
|
||||
class GnommoError(Exception):
|
||||
"""Base exception for all GnommoEditor errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""A single validation issue with location context."""
|
||||
|
||||
message: str
|
||||
file: Optional[Path] = None
|
||||
line: Optional[int] = None
|
||||
@@ -30,7 +32,9 @@ class ValidationIssue:
|
||||
class ParseError(GnommoError):
|
||||
"""Error during parsing of input files."""
|
||||
|
||||
def __init__(self, message: str, file: Optional[Path] = None, line: Optional[int] = None):
|
||||
def __init__(
|
||||
self, message: str, file: Optional[Path] = None, line: Optional[int] = None
|
||||
):
|
||||
self.issue = ValidationIssue(message, file, line)
|
||||
super().__init__(str(self.issue))
|
||||
|
||||
@@ -48,7 +52,9 @@ class ValidationError(GnommoError):
|
||||
class RenderError(GnommoError):
|
||||
"""Error during rendering stage."""
|
||||
|
||||
def __init__(self, message: str, command: Optional[str] = None, stderr: Optional[str] = None):
|
||||
def __init__(
|
||||
self, message: str, command: Optional[str] = None, stderr: Optional[str] = None
|
||||
):
|
||||
self.command = command
|
||||
self.stderr = stderr
|
||||
full_message = message
|
||||
@@ -62,7 +68,13 @@ class RenderError(GnommoError):
|
||||
class PreprocessError(GnommoError):
|
||||
"""Error during preprocessing stage."""
|
||||
|
||||
def __init__(self, message: str, filter_type: Optional[str] = None, command: Optional[str] = None, stderr: Optional[str] = None):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
filter_type: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
stderr: Optional[str] = None,
|
||||
):
|
||||
self.filter_type = filter_type
|
||||
self.command = command
|
||||
self.stderr = stderr
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
ObjC.import('stdlib');
|
||||
ObjC.import('Foundation');
|
||||
|
||||
function toAbsolutePath(p) {
|
||||
// Expand ~ and make absolute relative to current working directory
|
||||
var s = $(String(p)).stringByExpandingTildeInPath;
|
||||
if (!s.isAbsolutePath) {
|
||||
var cwd = $.NSFileManager.defaultManager.currentDirectoryPath;
|
||||
s = cwd.stringByAppendingPathComponent(s);
|
||||
}
|
||||
return s.stringByStandardizingPath.js;
|
||||
}
|
||||
|
||||
function fileExists(p) {
|
||||
return $.NSFileManager.defaultManager.fileExistsAtPath($(p));
|
||||
}
|
||||
|
||||
function getNotes(slide) {
|
||||
try { return slide.presenterNotes(); } catch (e) {}
|
||||
try { return slide.speakerNotes(); } catch (e) {}
|
||||
return "";
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
if (!argv || argv.length < 1) throw new Error("Usage: script.js <file.key> [slides_output_dir]");
|
||||
var abs = toAbsolutePath(argv[0]);
|
||||
var slidesDir = argv.length >= 2 ? toAbsolutePath(argv[1]) : null;
|
||||
|
||||
if (!fileExists(abs)) {
|
||||
throw new Error("File not found: " + abs);
|
||||
}
|
||||
|
||||
var Keynote = Application('Keynote');
|
||||
Keynote.activate();
|
||||
|
||||
// Keynote is happiest when given a Path() made from an absolute POSIX path
|
||||
var doc = Keynote.open(Path(abs));
|
||||
|
||||
// Export slides as PNG if output directory is provided
|
||||
if (slidesDir) {
|
||||
// Create directory if it doesn't exist
|
||||
var fm = $.NSFileManager.defaultManager;
|
||||
if (!fm.fileExistsAtPath($(slidesDir))) {
|
||||
fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(
|
||||
$(slidesDir), true, $(), $()
|
||||
);
|
||||
}
|
||||
|
||||
// Export using AppleScript (more reliable than JXA for Keynote export)
|
||||
var app = Application.currentApplication();
|
||||
app.includeStandardAdditions = true;
|
||||
|
||||
// Build osascript command with proper escaping
|
||||
// Using multiple -e flags to avoid quoting issues
|
||||
var cmd = '/usr/bin/osascript' +
|
||||
' -e \'tell application "Keynote"\'' +
|
||||
' -e \'export front document to POSIX file "' + slidesDir + '" as slide images with properties {image format:PNG}\'' +
|
||||
' -e \'end tell\'';
|
||||
|
||||
app.doShellScript(cmd);
|
||||
}
|
||||
|
||||
var slides = doc.slides();
|
||||
var out = [];
|
||||
for (var i = 0; i < slides.length; i++) {
|
||||
out.push({
|
||||
slide_index: i + 1,
|
||||
notes: String(getNotes(slides[i]) || "")
|
||||
});
|
||||
}
|
||||
|
||||
doc.close({ saving: 'no' });
|
||||
return JSON.stringify(out, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract presenter notes from a Keynote .key file.
|
||||
|
||||
Usage:
|
||||
python extract_keynote_notes.py path/to/deck.key --out notes.json
|
||||
|
||||
Notes:
|
||||
- A .key file is a package (zip). The presenter notes live in an XML-ish file
|
||||
typically called index.apxl inside the package.
|
||||
- This script tries to be robust across minor format changes by searching for
|
||||
likely note fields.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from gnommo.parser import _read_json
|
||||
|
||||
|
||||
def write_manuscript(data: Path, out_path: Path):
|
||||
data = _read_json(data.read_text(encoding="utf-8"))
|
||||
lines = []
|
||||
i = 0
|
||||
for item in data:
|
||||
print(f"Writing notes for slide {i} to file")
|
||||
idx = item.get("slide_index")
|
||||
notes = (item.get("notes") or "").rstrip()
|
||||
|
||||
lines.append(f"[S{idx}]")
|
||||
lines.append(notes)
|
||||
lines.append("") # blank line between slides
|
||||
i += 1
|
||||
|
||||
out_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
|
||||
def main():
|
||||
keynote_file = Path("video1/video1.key").expanduser().resolve()
|
||||
if not keynote_file.exists():
|
||||
raise FileNotFoundError(f"Keynote file not found: {keynote_file}")
|
||||
|
||||
script_file = Path("gnommo/extract_keynote_notes.js").expanduser().resolve()
|
||||
if not script_file.exists():
|
||||
raise FileNotFoundError(f"Extractor script not found: {script_file}")
|
||||
|
||||
presenter_notes_json_file = Path("video1/manuscript.json").expanduser().resolve()
|
||||
|
||||
# Run JXA extractor
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"osascript",
|
||||
"-l",
|
||||
"JavaScript",
|
||||
str(script_file),
|
||||
str(keynote_file),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
"Failed to extract presenter notes:\n"
|
||||
f"STDERR:\n{proc.stderr}\n"
|
||||
f"STDOUT:\n{proc.stdout}"
|
||||
)
|
||||
|
||||
# Write JSON output
|
||||
presenter_notes_json_file.write_text(proc.stdout, encoding="utf-8")
|
||||
|
||||
if not presenter_notes_json_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Failed to extract presenter notes to {presenter_notes_json_file}"
|
||||
)
|
||||
|
||||
# Convert JSON → manuscript.txt
|
||||
write_manuscript(
|
||||
presenter_notes_json_file, out_path=keynote_file.parent / "manuscript.txt"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Hand off a finished video to gnommoweb (the review app) — MinIO upload + version bump.
|
||||
|
||||
Works for any gnommo project type: parent videos and shorts alike.
|
||||
|
||||
Usage:
|
||||
gnommo handoff -p video1 # → local gnommoweb
|
||||
gnommo handoff -p video1 --prod # → production gnommoweb (glitch.university)
|
||||
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 (both local and --prod):
|
||||
- Uploads the video via POST /api/projects/:id/handoff on gnommoweb, which
|
||||
stores it in MinIO and bumps the project's video_version (so it shows up on
|
||||
the review page).
|
||||
- Updates .gnommo_sync.json (local) / .gnommo_sync.prod.json (--prod) with the
|
||||
new video_version.
|
||||
|
||||
Configuration (from .env or environment):
|
||||
GNOMMOWEB_URL Base URL for local dev (e.g. http://localhost:3001)
|
||||
GNOMMOWEB_API_KEY Bearer token for local (CONTENT_API_KEY from gnommoweb)
|
||||
GNOMMOWEB_PROD_URL Base URL for production (e.g. https://glitch.university)
|
||||
GNOMMOWEB_PROD_API_KEY Bearer token for production (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 cmd_handoff(
|
||||
project_path: Path,
|
||||
verbose: bool = False,
|
||||
file_override: str | None = None,
|
||||
prod: bool = False,
|
||||
res: str = "full",
|
||||
) -> int:
|
||||
_load_env_file()
|
||||
|
||||
# Handoff always targets gnommoweb (the review app). --prod selects the
|
||||
# production instance; without it, the local dev server.
|
||||
if prod:
|
||||
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||
url_var, key_var = "GNOMMOWEB_PROD_URL", "GNOMMOWEB_PROD_API_KEY"
|
||||
else:
|
||||
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||
url_var, key_var = "GNOMMOWEB_URL", "GNOMMOWEB_API_KEY"
|
||||
if not api_url:
|
||||
print(f"Error: {url_var} is not set.", file=sys.stderr)
|
||||
return 1
|
||||
if not api_key:
|
||||
print(f"Error: {key_var} is not set.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if verbose:
|
||||
target = "production" if prod else "local"
|
||||
print(f" → gnommoweb {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 ─────────────────────────────────────────────────────────────────
|
||||
# gnommoweb: POST /api/projects/:id/handoff — uploads to MinIO and bumps the
|
||||
# project's video_version so it appears on the review page.
|
||||
extra_data = {}
|
||||
course = project.get("course")
|
||||
if course:
|
||||
extra_data["course"] = course
|
||||
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))},
|
||||
data=extra_data or None,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=None,
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"✗ Could not connect to {api_url}")
|
||||
return 1
|
||||
|
||||
if not r.ok:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = r.text[:500]
|
||||
print(f"✗ Server returned {r.status_code}: {body}")
|
||||
return 1
|
||||
|
||||
result = r.json()
|
||||
|
||||
# ── Write sync state ───────────────────────────────────────────────────────
|
||||
# gnommoweb response: { video_version, video_url, asset: { updated_at } }
|
||||
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||
existing_sync = _read_sync(project_path, prod)
|
||||
video_version = result.get("video_version", "?")
|
||||
video_url = result.get("video_url", "")
|
||||
_write_sync(
|
||||
project_path,
|
||||
{
|
||||
**existing_sync,
|
||||
"last_handoff_at": now_iso,
|
||||
"video_version": video_version,
|
||||
"server_updated_at": result.get("asset", {}).get(
|
||||
"updated_at", existing_sync.get("server_updated_at")
|
||||
),
|
||||
},
|
||||
prod,
|
||||
)
|
||||
print(f"✓ {project_id} → v{video_version} [uploaded]")
|
||||
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")
|
||||
+517
-37
@@ -2,35 +2,91 @@
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class TalkingHeadConfig:
|
||||
"""Configuration for talking head video positioning."""
|
||||
x: int
|
||||
y: int
|
||||
target_height: int # in pixels, or -1 for percentage-based
|
||||
target_height_percent: float = 0.0 # percentage (0.0-1.0) if target_height is -1
|
||||
file: Optional[str] = None # Path to video or metadata JSON file
|
||||
class CutoutDefinition:
|
||||
"""Definition of a named zone for placing video content.
|
||||
|
||||
All positioning values support both pixels (int) and percentages (str like "50%").
|
||||
Percentage values are stored as floats (0.0-1.0) with pixel value set to -1.
|
||||
|
||||
Videos placed in cutouts are cropped to fit the cutout dimensions.
|
||||
"""
|
||||
|
||||
x: int # in pixels, or -1 for percentage-based
|
||||
y: int # in pixels, or -1 for percentage-based
|
||||
height: int # in pixels, or -1 for percentage-based
|
||||
width: int = (
|
||||
-1
|
||||
) # in pixels, or -1 for percentage-based (defaults to height for square)
|
||||
x_percent: float = 0.0 # percentage (0.0-1.0) if x is -1
|
||||
y_percent: float = 0.0 # percentage (0.0-1.0) if y is -1
|
||||
height_percent: float = 0.0 # percentage (0.0-1.0) if height is -1
|
||||
width_percent: float = 0.0 # percentage (0.0-1.0) if width is -1
|
||||
|
||||
# New center-based model (opt-in via cx/cy in project.json). When cx_percent is
|
||||
# set: cx/cy are the CENTER as fractions of frame width/height; width_percent and
|
||||
# height_percent are fractions of min(W,H) (so equal values → a true square on any
|
||||
# aspect ratio); margin_percent is a per-side inset, also a fraction of min(W,H).
|
||||
cx_percent: Optional[float] = None
|
||||
cy_percent: Optional[float] = None
|
||||
margin_percent: float = 0.0
|
||||
size_relative_to_min: bool = False
|
||||
|
||||
|
||||
# Backwards compatibility alias
|
||||
TalkingHeadConfig = CutoutDefinition
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""Global project configuration from project.json."""
|
||||
|
||||
resolution: tuple[int, int]
|
||||
fps: int
|
||||
talking_head: TalkingHeadConfig
|
||||
default_slide_type: str
|
||||
cutouts: dict[str, CutoutDefinition] = field(
|
||||
default_factory=dict
|
||||
) # Named zones for video placement
|
||||
default_filters: dict[str, list[dict]] = field(
|
||||
default_factory=dict
|
||||
) # Named filter presets that can be referenced in videos.json
|
||||
background: str = "" # Background image or video path (in shared_assets/)
|
||||
background_video: str = "" # Deprecated: use background instead
|
||||
slides_path: str = "slides.json" # path to slides.json relative to project
|
||||
videos_path: str = "videos.json" # path to videos.json relative to project
|
||||
audio_path: str = "audio.json" # path to audio.json relative to project
|
||||
transcript_path: Optional[str] = None # path to transcript.json relative to project (always saved locally)
|
||||
audio_source: Optional[str] = None # defaults to talking head
|
||||
main_video: Optional[
|
||||
Union[str, list]
|
||||
] = None # ID(s) of main video(s) - array for multi-segment narration
|
||||
gnommo_scratch: Optional[
|
||||
str
|
||||
] = None # directory for intermediate files (e.g., external SSD)
|
||||
process_cache: Optional[
|
||||
str
|
||||
] = None # external directory for processed/combined outputs (saves laptop disk space)
|
||||
default_begin: float = 0.0 # Trim this many seconds from the start of each segment (if no explicit begin/skip)
|
||||
default_end_trim: float = 0.0 # Trim this many seconds from the end of each segment (if no explicit end/take)
|
||||
# Outro sequence - plays after narration ends (not marker-triggered)
|
||||
outro: list[str] = field(
|
||||
default_factory=list
|
||||
) # List of video IDs to play in sequence after narration
|
||||
# YouTube description fields
|
||||
description: str = "" # Video description text for YouTube
|
||||
footer: str = "" # Footer text (social links, subscribe CTA, etc.)
|
||||
output_video: str = (
|
||||
"" # Output filename (e.g. "DISC_INT3.mp4"); placed in out/ or out/<res>/
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlideDefinition:
|
||||
"""Definition of a single slide from slides.json."""
|
||||
|
||||
image: str
|
||||
type: str # "fullscreen" | "square"
|
||||
|
||||
@@ -38,25 +94,268 @@ class SlideDefinition:
|
||||
@dataclass
|
||||
class ChromaKeyConfig:
|
||||
"""Configuration for chroma key (green screen) filter."""
|
||||
|
||||
color: tuple[int, int, int] = (0, 255, 0) # RGB color to key out
|
||||
similarity: float = 0.15 # Color similarity threshold (0.0-1.0)
|
||||
blend: float = 0.1 # Edge blend/feathering (0.0-1.0)
|
||||
spill: float = 0.0 # Spill suppression amount (0.0-1.0)
|
||||
similarity: float = (
|
||||
0.4 # Color similarity threshold (0.0-1.0), higher = more aggressive
|
||||
)
|
||||
blend: float = 0.08 # Edge blend/feathering (0.0-1.0), lower = tighter edges
|
||||
spill: float = 0.1 # Spill suppression amount (0.0-1.0)
|
||||
edge_erode: int = 0 # Pixels to erode from alpha edge (0-5), removes green fringe
|
||||
# Color protection - restore opacity for colors that shouldn't be keyed
|
||||
protect_color: tuple[int, int, int] = None # RGB color to protect from keying
|
||||
protect_tolerance: float = (
|
||||
0.15 # How much variation from protect_color to allow (0-1)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GnommoKeyConfig:
|
||||
"""Configuration for gnommokey filter - Keylight-style color-difference keyer.
|
||||
|
||||
Uses YCbCr color-difference keying (like Keylight/Ultimatte) instead of
|
||||
simple Euclidean distance. This handles lighting variation much better
|
||||
than basic chromakey.
|
||||
"""
|
||||
|
||||
# Screen color (the green/blue screen color to key out)
|
||||
screen_color: tuple[int, int, int] = (0, 177, 64) # RGB of the screen
|
||||
|
||||
# Key extraction strength (default 100, higher = more aggressive)
|
||||
# Values 80-150 are typical. Maps to Keylight's Screen Gain.
|
||||
screen_gain: float = 100.0
|
||||
|
||||
# Balance between chrominance and luminance in key calculation (0-100)
|
||||
# 0 = pure color-difference, 100 = luminance weighted
|
||||
# Maps to Keylight's Screen Balance.
|
||||
screen_balance: float = 50.0
|
||||
|
||||
# Alpha/matte adjustments
|
||||
clip_black: float = 0.0 # Crush blacks (0-100). Higher = more transparent areas
|
||||
clip_white: float = 100.0 # Crush whites (0-100). Lower = more opaque areas
|
||||
|
||||
# Despill: color to shift green spill toward (RGB)
|
||||
# Typical values: skin tone [217, 200, 180] or neutral [200, 200, 200]
|
||||
despill_bias: tuple[int, int, int] = None
|
||||
|
||||
# How aggressively to apply despill (0-1)
|
||||
despill_strength: float = 0.5
|
||||
|
||||
# Interior green-limiter (0.0-2.0, 0 = off). Suppresses green cast/spill
|
||||
# across the whole frame even where green is NOT the dominant channel — the
|
||||
# case the bias/edge despill misses (e.g. green bounce on skin/a bald head).
|
||||
# Caps green at a reference through the other two channels: max(r,b) [0.0] ->
|
||||
# average [1.0] -> min(r,b) [2.0]. 0.5-0.7 for light cast; >1.0 for heavy
|
||||
# close-up spill (2.0 = green can never exceed the smallest channel).
|
||||
spill_suppress: float = 0.0
|
||||
|
||||
# Protect saturated yellows/warm fabrics from spill_suppress (0.0-1.0, 0 = off).
|
||||
# spill_suppress caps green everywhere, which turns legit yellow (high r+g,
|
||||
# low b) into orange. Blue is the tell: skin keeps some blue, yellow fabric
|
||||
# reflects almost none. This gates the green-limiter down where
|
||||
# min(r,g)-b is high (yellow) while leaving skin/scalp spill fully suppressed.
|
||||
# 1.0 = full protection for strong yellows. Only matters when spill_suppress>0.
|
||||
yellow_protect: float = 0.0
|
||||
|
||||
# Alpha bias: influences edge treatment (RGB)
|
||||
# Can help with edge color contamination
|
||||
alpha_bias: tuple[int, int, int] = None
|
||||
|
||||
# Luminance protection: pixels with luma above this stay fully opaque (0-255, -1 = off)
|
||||
# Use ~220 to protect white objects (headphones, teeth) from being partially keyed.
|
||||
protect_luma: int = -1
|
||||
|
||||
# Shadow boost: extra key strength for dark pixels (0.0-5.0, 0 = off)
|
||||
# Ramps up key signal proportionally to how dark a pixel is, helping key dark greens
|
||||
# without affecting bright foreground areas. Values 1.0-2.0 are typical.
|
||||
shadow_boost: float = 0.0
|
||||
|
||||
# Edge refinement
|
||||
edge_erode: int = 0 # Pixels to erode from alpha edge (0-5)
|
||||
edge_soften: float = 0.0 # Blur the alpha edge (0-5 pixels)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorGradeConfig:
|
||||
"""Configuration for color grading filter.
|
||||
|
||||
Applies color balance, contrast curves, and saturation adjustments
|
||||
while preserving the alpha channel.
|
||||
"""
|
||||
|
||||
# Color balance (range: -1.0 to 1.0, 0 = no change)
|
||||
# Midtones
|
||||
rm: float = 0.0 # Red midtones adjustment
|
||||
gm: float = 0.0 # Green midtones adjustment
|
||||
bm: float = 0.0 # Blue midtones adjustment
|
||||
# Highlights
|
||||
rh: float = 0.0 # Red highlights adjustment
|
||||
gh: float = 0.0 # Green highlights adjustment
|
||||
bh: float = 0.0 # Blue highlights adjustment
|
||||
# Shadows
|
||||
rs: float = 0.0 # Red shadows adjustment
|
||||
gs: float = 0.0 # Green shadows adjustment
|
||||
bs: float = 0.0 # Blue shadows adjustment
|
||||
|
||||
# Curves preset (none, lighter, darker, increase_contrast, medium_contrast, etc.)
|
||||
curves_preset: str = "none"
|
||||
|
||||
# EQ adjustments
|
||||
contrast: float = 1.0 # Contrast multiplier (0.0-2.0, 1.0 = no change)
|
||||
brightness: float = 0.0 # Brightness adjustment (-1.0 to 1.0, 0 = no change)
|
||||
saturation: float = 1.0 # Saturation multiplier (0.0-3.0, 1.0 = no change)
|
||||
|
||||
# Auto-levels: a fixed histogram stretch (crush blacks, lift whites) for
|
||||
# punch, like Photoshop auto-levels. 0 = off, 1 = strong. Fixed (not
|
||||
# per-frame adaptive) so it can't flicker; it remaps a constant [lo,hi]
|
||||
# window to full range, so the keyed-out background never skews it.
|
||||
auto_levels: float = 0.0
|
||||
|
||||
# Yellow tint: hue-selective nudge of ONLY the yellow range (leaves reds/skin
|
||||
# alone), to counter the orange shift auto-levels/saturation gives a yellow
|
||||
# costume. <0 pulls yellows back toward green/pure yellow (preserve), >0
|
||||
# pushes them warmer/orange. Range roughly -1.0..1.0. 0 = off.
|
||||
yellow_tint: float = 0.0
|
||||
|
||||
# Custom curves for lift/gamma/gain control
|
||||
# Format: "0/0 0.5/0.56 1/1" means (input/output) control points
|
||||
curves_r: str = "" # Red channel curve
|
||||
curves_g: str = "" # Green channel curve
|
||||
curves_b: str = "" # Blue channel curve
|
||||
curves_master: str = "" # Master (luminance) curve
|
||||
|
||||
|
||||
@dataclass
|
||||
class EQBand:
|
||||
"""A single parametric EQ band."""
|
||||
|
||||
freq: float # Center frequency in Hz
|
||||
gain: float # Gain in dB (negative = cut, positive = boost)
|
||||
q: float = 1.0 # Q factor (bandwidth), higher = narrower
|
||||
type: str = "peak" # "peak", "lowshelf", or "highshelf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioNormalizeConfig:
|
||||
"""Configuration for audio normalization filter.
|
||||
|
||||
Applies noise reduction, compression, and loudness normalization
|
||||
to improve audio quality and consistency.
|
||||
"""
|
||||
enabled: bool = True # Master switch to enable/disable all audio processing
|
||||
# Parametric EQ bands (applied before other processing)
|
||||
eq_bands: list[EQBand] = field(default_factory=list)
|
||||
|
||||
# High-pass filter (remove room rumble)
|
||||
highpass: float = (
|
||||
0.0 # High-pass frequency in Hz (0 = disabled, try 80-120 for voice)
|
||||
)
|
||||
|
||||
# Low-pass filter (remove harsh highs)
|
||||
lowpass: float = (
|
||||
0.0 # Low-pass frequency in Hz (0 = disabled, try 12000-16000 if needed)
|
||||
)
|
||||
|
||||
# Room resonance EQ cut (reduce muddy room buildup)
|
||||
room_eq: bool = False # Enable room resonance cut
|
||||
room_eq_freq: float = 300.0 # Center frequency for room cut (Hz, typically 200-400)
|
||||
room_eq_gain: float = -4.0 # Gain in dB (negative = cut)
|
||||
room_eq_width: float = 1.5 # Q/bandwidth (higher = narrower cut)
|
||||
|
||||
# Noise gate (reduce reverb tails during pauses)
|
||||
gate: bool = False # Enable noise gate
|
||||
gate_threshold: float = -35.0 # Threshold in dB (signal below this gets attenuated)
|
||||
gate_range: float = -20.0 # Attenuation amount in dB when gate is closed
|
||||
gate_attack: float = 10.0 # Attack time in ms
|
||||
gate_release: float = 150.0 # Release time in ms
|
||||
|
||||
# Neural de-reverb (arnndn filter - very effective but needs model file)
|
||||
dereverb_model: str = "" # Path to RNNoise model file (empty = disabled)
|
||||
dereverb_mix: float = (
|
||||
0.8 # Mix ratio 0.0-1.0 (1.0 = full effect, 0.8 = preserve some natural room)
|
||||
)
|
||||
|
||||
# Noise reduction (afftdn filter)
|
||||
denoise: bool = True # Enable noise reduction
|
||||
noise_floor: float = (
|
||||
-25.0
|
||||
) # Noise floor in dB (default -25, lower = more aggressive)
|
||||
|
||||
# Compression (acompressor filter)
|
||||
compress: bool = True # Enable dynamic range compression
|
||||
threshold: float = -20.0 # Compression threshold in dB
|
||||
ratio: float = 4.0 # Compression ratio (4:1 default)
|
||||
attack: float = 5.0 # Attack time in ms
|
||||
release: float = 50.0 # Release time in ms
|
||||
makeup: float = 2.0 # Makeup gain in dB
|
||||
|
||||
# Loudness normalization (loudnorm filter - EBU R128)
|
||||
normalize: bool = True # Enable loudness normalization
|
||||
target_lufs: float = (
|
||||
-16.0
|
||||
) # Target integrated loudness (YouTube recommends -14 to -16)
|
||||
target_lra: float = 11.0 # Target loudness range
|
||||
target_tp: float = -1.5 # Target true peak in dB
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterConfig:
|
||||
"""Base configuration for a preprocessing filter."""
|
||||
|
||||
type: str
|
||||
# Type-specific config stored in subclasses or as dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attribution:
|
||||
"""Attribution information for stock footage (e.g., Pexels)."""
|
||||
|
||||
source: str # Source platform (e.g., "pexels", "pixabay", "unsplash")
|
||||
creator: str # Creator/photographer name
|
||||
url: Optional[str] = None # URL to the original content
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoSource:
|
||||
"""Video source definition from videos.json."""
|
||||
file: str
|
||||
preprocess: list[dict] = field(default_factory=list) # List of filter config dicts
|
||||
output_file: Optional[str] = None # Path to preprocessed output (if any)
|
||||
|
||||
source_file: str # Source video filename (relative to videos.json location or shared_assets/)
|
||||
filter: list[dict] = field(default_factory=list) # List of filter config dicts
|
||||
output_file: Optional[
|
||||
str
|
||||
] = None # Path to preprocessed output (relative to videos.json)
|
||||
take: Optional[
|
||||
float
|
||||
] = None # Max duration to play (seconds). Default: until next slide or end of clip
|
||||
skip: float = 0.0 # Skip this many seconds at start of video (seek point)
|
||||
zoom: float = (
|
||||
1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge)
|
||||
)
|
||||
cutout: Optional[
|
||||
str
|
||||
] = None # Name of cutout to place video in (from project.json cutouts)
|
||||
always_visible: bool = False # If True, video is always shown (like talking head)
|
||||
is_shared: bool = False # If True, source_file is relative to shared_assets/
|
||||
pause_narration: float = (
|
||||
0.0 # Seconds to pause narration during this video (0 = no pause)
|
||||
)
|
||||
attribution: Optional[Attribution] = None # Attribution for stock footage
|
||||
use_audio_channels: str = (
|
||||
"both" # Audio channel selection: "both", "left", or "right"
|
||||
)
|
||||
defer_loudnorm: bool = (
|
||||
False # If True, skip loudnorm during preprocessing (apply after concatenation)
|
||||
)
|
||||
volume: float = 1.0 # Volume multiplier (1.0=full, >1.0=boost, <1.0=reduce)
|
||||
layer: str = "above" # "above" = on top of slides; "mid" = above narrator/below slides; "below" = behind narrator
|
||||
duration: Optional[
|
||||
float
|
||||
] = None # Pre-probed file duration in seconds (set by import)
|
||||
has_audio: Optional[bool] = None # Pre-detected audio presence (set by import)
|
||||
end_on: Optional[
|
||||
str
|
||||
] = None # When video event ends: "end" (play once to natural length) | "loop" (loop to render end)
|
||||
# | "next_slide" | "next_video" | "take" (None = marker-type default: next_slide for videos)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,50 +366,231 @@ class VideoMetadata:
|
||||
This allows defining preprocessing steps separately from videos.json,
|
||||
enabling per-video preprocessing configuration.
|
||||
"""
|
||||
|
||||
source_file: str # Original source video file
|
||||
preprocess: list[dict] = field(default_factory=list) # Preprocessing filters
|
||||
output: Optional[dict] = None # Output config {"file": "...", "colorspace": "...", "alpha": "..."}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimedWord:
|
||||
"""A word or marker with its timestamp from transcript.csv."""
|
||||
time: float
|
||||
word: str
|
||||
|
||||
@property
|
||||
def is_marker(self) -> bool:
|
||||
"""Check if this is a slide marker like [S1]."""
|
||||
return self.word.startswith("[") and self.word.endswith("]")
|
||||
|
||||
@property
|
||||
def marker_id(self) -> Optional[str]:
|
||||
"""Extract marker ID (e.g., 'S1' from '[S1]')."""
|
||||
if self.is_marker:
|
||||
return self.word[1:-1]
|
||||
return None
|
||||
output: Optional[
|
||||
dict
|
||||
] = None # Output config {"file": "...", "colorspace": "...", "alpha": "..."}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlideEvent:
|
||||
"""A resolved slide event with timing information."""
|
||||
|
||||
slide_id: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
slide_def: SlideDefinition
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioDefinition:
|
||||
"""Definition of an audio clip from audio.json."""
|
||||
|
||||
file: str # Audio filename (relative to audio.json location, or to shared_assets/media/audio/ if is_shared)
|
||||
volume: float = 1.0 # Volume multiplier (0.0-1.0)
|
||||
loop: bool = False # If True, loop for entire duration from trigger point
|
||||
overlap: Optional[float] = None # Crossfade overlap in seconds when looping
|
||||
ignore_pauses: bool = (
|
||||
False # If True, audio continues playing during narration pauses
|
||||
)
|
||||
duration: Optional[float] = None # Pre-probed duration in seconds (set by import)
|
||||
is_shared: bool = False # If True, file is relative to shared_assets/media/audio/
|
||||
|
||||
|
||||
@dataclass
|
||||
class Citation:
|
||||
"""A citation extracted from manuscript.txt [cite:...] markers."""
|
||||
|
||||
reference: str # The literal reference text after cite:
|
||||
marker_id: str # The full marker (e.g., "cite:Smith et al...")
|
||||
timestamp: float = -1.0 # Aligned timestamp (-1 if not aligned)
|
||||
context: str = "" # Text following the citation for alignment
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioEvent:
|
||||
"""A resolved audio event with timing information."""
|
||||
|
||||
audio_id: str
|
||||
start_time: float # When to start playing (marker time - offset)
|
||||
audio_def: AudioDefinition
|
||||
# Chunking v2 (docs/chunking_v2.md): when a clip began before this chunk's
|
||||
# window it must resume mid-track, not restart at the seam. src_offset is the
|
||||
# position (seconds) into the source stream to begin at — the loop phase for
|
||||
# looping music, or a linear seek for one-shots. 0.0 = play from the start (v1).
|
||||
src_offset: float = 0.0
|
||||
|
||||
|
||||
@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
|
||||
# Chunking v2 seam (see docs/chunking_v2.md): when a clip began before this
|
||||
# chunk's window, the render must seek into it so it resumes mid-clip instead of
|
||||
# restarting at the boundary. None = play from video_source.skip (the v1/default).
|
||||
# Set only by the v2 overlap-inclusion path; the renderer prefers it when present.
|
||||
skip_override: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraState:
|
||||
"""State of the virtual camera at a point in time.
|
||||
|
||||
The camera transforms the entire composed scene (background, slides, cutouts).
|
||||
This ensures all elements stay spatially synchronized when zooming/tilting.
|
||||
"""
|
||||
|
||||
zoom: float = 1.0 # 1.0 = 100%, 1.25 = 125%, etc.
|
||||
rotation: float = 0.0 # degrees, positive = clockwise
|
||||
pan_x: float = 0.0 # -1.0 to 1.0, percentage of frame width
|
||||
pan_y: float = 0.0 # -1.0 to 1.0, percentage of frame height
|
||||
focal_x: float = 0.5 # 0.0 to 1.0, zoom focal point X (0.5 = center)
|
||||
focal_y: float = 0.5 # 0.0 to 1.0, zoom focal point Y (0.5 = center)
|
||||
|
||||
def __post_init__(self):
|
||||
# Clamp values to reasonable ranges
|
||||
self.zoom = max(0.5, min(3.0, self.zoom))
|
||||
self.rotation = max(-45.0, min(45.0, self.rotation))
|
||||
self.pan_x = max(-1.0, min(1.0, self.pan_x))
|
||||
self.pan_y = max(-1.0, min(1.0, self.pan_y))
|
||||
self.focal_x = max(0.0, min(1.0, self.focal_x))
|
||||
self.focal_y = max(0.0, min(1.0, self.focal_y))
|
||||
|
||||
def is_default(self) -> bool:
|
||||
"""Check if this is the default camera state (no transform)."""
|
||||
return (
|
||||
self.zoom == 1.0
|
||||
and self.rotation == 0.0
|
||||
and self.pan_x == 0.0
|
||||
and self.pan_y == 0.0
|
||||
and self.focal_x == 0.5
|
||||
and self.focal_y == 0.5
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraEvent:
|
||||
"""A camera state change at a specific time.
|
||||
|
||||
Camera events can be instant (duration=0) or animated (duration>0).
|
||||
When animated, the camera smoothly transitions from its current state
|
||||
to the target state over the specified duration using the easing function.
|
||||
"""
|
||||
|
||||
time: float # timestamp in seconds
|
||||
target_state: CameraState
|
||||
duration: float = 0.2 # transition duration (0 = instant snap)
|
||||
easing: str = "ease-out" # linear, ease-in, ease-out, ease-in-out
|
||||
|
||||
|
||||
# Camera effect presets - map marker names to camera states
|
||||
# Effect strengths are intentionally subtle for professional look
|
||||
CAMERA_PRESETS: dict[str, CameraState] = {
|
||||
# Zoom levels (halved for subtlety)
|
||||
"Zoom0": CameraState(zoom=1.0),
|
||||
"Zoom1": CameraState(zoom=1.05),
|
||||
"Zoom2": CameraState(zoom=1.125),
|
||||
"Zoom3": CameraState(zoom=1.25),
|
||||
# Tilt/rotation (halved)
|
||||
"TiltLeft": CameraState(rotation=-7.5),
|
||||
"TiltRight": CameraState(rotation=7.5),
|
||||
"NoTilt": CameraState(), # Full reset to default state
|
||||
# Pan (halved)
|
||||
"PanLeft": CameraState(pan_x=-0.1),
|
||||
"PanRight": CameraState(pan_x=0.1),
|
||||
"PanUp": CameraState(pan_y=-0.075),
|
||||
"PanDown": CameraState(pan_y=0.075),
|
||||
"PanCenter": CameraState(pan_x=0.0, pan_y=0.0),
|
||||
# Reset all
|
||||
"Reset": CameraState(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class NarrationPause:
|
||||
"""A pause in the narration timeline for an interstitial video."""
|
||||
|
||||
output_time: float # When the pause starts in the OUTPUT timeline
|
||||
narration_time: float # Where we are in the NARRATION source when pause starts
|
||||
duration: float # How long the pause lasts
|
||||
video_id: str # The video that plays during the pause
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutroEvent:
|
||||
"""A video that plays as part of the outro sequence (after narration ends)."""
|
||||
|
||||
video_id: str
|
||||
start_time: float # When this outro video starts (in output timeline)
|
||||
end_time: float # When this outro video ends
|
||||
video_source: "VideoSource"
|
||||
cutout: Optional["CutoutDefinition"] = None # None = fullscreen
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderPlan:
|
||||
"""Complete plan for rendering the final video."""
|
||||
|
||||
project_path: Path
|
||||
config: ProjectConfig
|
||||
talking_head: VideoSource
|
||||
slide_events: list[SlideEvent]
|
||||
total_duration: float
|
||||
slides: dict[str, SlideDefinition]
|
||||
videos: dict[str, VideoSource] = field(default_factory=dict)
|
||||
video_events: list[VideoEvent] = field(
|
||||
default_factory=list
|
||||
) # Triggered video overlays
|
||||
narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = field(
|
||||
default_factory=list
|
||||
) # (video_id, source, cutout)
|
||||
slides_dir: Path = None # directory containing slide images
|
||||
talking_head_path: Path = None # Resolved path to actual video file
|
||||
videos_dir: Path = None # directory containing videos.json and video files
|
||||
audio_events: list[AudioEvent] = field(default_factory=list)
|
||||
audio: dict[str, AudioDefinition] = field(default_factory=dict)
|
||||
audio_dir: Path = None # directory containing audio.json and audio files
|
||||
camera_events: list[CameraEvent] = field(
|
||||
default_factory=list
|
||||
) # Virtual camera keyframes
|
||||
# Partial rendering support
|
||||
time_offset: float = (
|
||||
0.0 # Offset subtracted from all timestamps (for partial render)
|
||||
)
|
||||
initial_camera_state: "CameraState" = (
|
||||
None # Camera state at render start (for partial render)
|
||||
)
|
||||
input_seek_time: float = 0.0 # Seek position for input videos (for partial render)
|
||||
# Shared assets support
|
||||
shared_assets_dir: Path = None # Directory containing shared assets (pexels, etc.)
|
||||
# Narration pause support
|
||||
narration_pauses: list[NarrationPause] = field(
|
||||
default_factory=list
|
||||
) # Gaps in narration for interstitial videos
|
||||
# Render-time narration concat: ordered segments (skip/take + offset) to
|
||||
# concatenate directly at render time. Typed loosely (list of
|
||||
# narration.NarrationSegment) to avoid a circular import between models and
|
||||
# narration.
|
||||
narration_segments: list = field(default_factory=list)
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Deterministic narration scheduling for render-time segment concatenation.
|
||||
|
||||
Rather than pre-concatenating segments into one file, the render stage
|
||||
concatenates the processed segments directly. From narration.json + the cached
|
||||
per-segment transcripts this module computes two things:
|
||||
|
||||
1. an ordered segment schedule (processed file, skip, take, and offset in the
|
||||
combined timeline) — this drives the ffmpeg concat at render time; and
|
||||
2. the merged word-level transcript, with every word re-timed into the
|
||||
combined timeline — this drives slide alignment, exactly what
|
||||
re-transcribing a pre-concatenated narration file used to produce, but derived
|
||||
deterministically (no re-transcription, no separate combined file).
|
||||
|
||||
The processed files share framerate and format and are uncompressed, so the
|
||||
computed offsets match the concatenated audio timeline sample-for-sample.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .models import VideoSource
|
||||
from .preprocessor import get_preprocessed_path
|
||||
from .transcriber import TranscribedWord, load_transcript
|
||||
|
||||
|
||||
@dataclass
|
||||
class NarrationSegment:
|
||||
"""One narration segment placed on the combined render timeline."""
|
||||
|
||||
seg_id: str
|
||||
source_path: Path # processed file to concatenate
|
||||
skip: float # seconds trimmed from the segment's start
|
||||
take: Optional[float] # seconds kept from `skip` (None → to end)
|
||||
duration: float # effective seconds contributed to the timeline
|
||||
offset: float # start time of this segment in the combined timeline
|
||||
|
||||
|
||||
def segment_order(narration: dict) -> list[str]:
|
||||
"""Natural sort of segment ids (S2 before S10, s1-15 before s16-end)."""
|
||||
return sorted(
|
||||
narration.keys(),
|
||||
key=lambda s: [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)],
|
||||
)
|
||||
|
||||
|
||||
def build_narration_schedule(
|
||||
narration: dict[str, VideoSource],
|
||||
narration_dir: Path,
|
||||
get_duration: Callable[[Path], float],
|
||||
transcripts_dir: Optional[Path] = None,
|
||||
verbose: bool = False,
|
||||
) -> tuple[list[NarrationSegment], list[TranscribedWord]]:
|
||||
"""Return (ordered segments with offsets, merged transcript in timeline).
|
||||
|
||||
Args:
|
||||
narration: seg_id -> VideoSource (from parse_narration).
|
||||
narration_dir: base dir the processed files resolve against
|
||||
(media/narration; output_file is like processed/…mov).
|
||||
get_duration: callable(Path) -> float (ffprobe duration), used only
|
||||
when a segment has no explicit take.
|
||||
transcripts_dir: where per-segment {seg_id}.json transcripts live
|
||||
(defaults to narration_dir/transcripts).
|
||||
"""
|
||||
if transcripts_dir is None:
|
||||
transcripts_dir = narration_dir / "transcripts"
|
||||
|
||||
segments: list[NarrationSegment] = []
|
||||
merged: list[TranscribedWord] = []
|
||||
offset = 0.0
|
||||
|
||||
for seg_id in segment_order(narration):
|
||||
vs = narration[seg_id]
|
||||
source_path = get_preprocessed_path(narration_dir, vs)
|
||||
|
||||
skip = vs.skip or 0.0
|
||||
take = vs.take
|
||||
if take is not None:
|
||||
eff = max(0.0, take)
|
||||
else:
|
||||
full_dur = get_duration(source_path) if source_path.exists() else 0.0
|
||||
eff = max(0.0, full_dur - skip)
|
||||
seg_end = skip + eff # kept window end in the segment's own timeline
|
||||
|
||||
segments.append(
|
||||
NarrationSegment(
|
||||
seg_id=seg_id,
|
||||
source_path=source_path,
|
||||
skip=skip,
|
||||
take=take,
|
||||
duration=eff,
|
||||
offset=offset,
|
||||
)
|
||||
)
|
||||
|
||||
# Re-time this segment's transcript into the combined timeline: keep only
|
||||
# words inside [skip, seg_end], subtract skip, and add the running offset.
|
||||
tpath = transcripts_dir / f"{seg_id}.json"
|
||||
if tpath.exists():
|
||||
kept = 0
|
||||
for w in load_transcript(tpath):
|
||||
if w.end <= skip or w.start >= seg_end:
|
||||
continue # entirely outside the kept window
|
||||
new_start = max(w.start, skip) - skip + offset
|
||||
new_end = min(w.end, seg_end) - skip + offset
|
||||
merged.append(
|
||||
TranscribedWord(word=w.word, start=round(new_start, 3), end=round(new_end, 3))
|
||||
)
|
||||
kept += 1
|
||||
if verbose:
|
||||
print(f" {seg_id}: +{kept} words (skip={skip:.2f}s take={eff:.2f}s → offset {offset:.2f}s)")
|
||||
elif verbose:
|
||||
print(f" ⚠ {seg_id}: no transcript at {tpath} — words missing from merged transcript")
|
||||
|
||||
offset += eff
|
||||
|
||||
return segments, merged
|
||||
+731
-77
@@ -1,28 +1,120 @@
|
||||
"""Extract stage: parse all input files."""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .cache import resolve_with_cache
|
||||
from .errors import ParseError
|
||||
from .models import (
|
||||
Attribution,
|
||||
AudioDefinition,
|
||||
Citation,
|
||||
CutoutDefinition,
|
||||
ProjectConfig,
|
||||
SlideDefinition,
|
||||
TalkingHeadConfig,
|
||||
TimedWord,
|
||||
VideoMetadata,
|
||||
VideoSource,
|
||||
)
|
||||
|
||||
|
||||
def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int, str]]]:
|
||||
def _read_json(path: Path) -> Any:
|
||||
"""Read and parse a JSON file, treating an empty file as {}."""
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
return json.loads(text) if text else {}
|
||||
|
||||
|
||||
def _resolve_case_insensitive(path: Path) -> Path:
|
||||
"""Return the real on-disk path, resolving each component case-insensitively.
|
||||
|
||||
On case-insensitive filesystems (macOS) paths just work. On case-sensitive
|
||||
ones (Linux/WSL) a mismatch between project.json and the actual directory
|
||||
name causes a FileNotFoundError. This walks each component and picks the
|
||||
first directory entry whose name matches case-insensitively, returning the
|
||||
corrected path. If the path already exists, it is returned unchanged.
|
||||
"""
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
resolved = path.anchor and Path(path.anchor) or Path(".")
|
||||
for part in path.parts[len(Path(path.anchor).parts) :]:
|
||||
if (resolved / part).exists():
|
||||
resolved = resolved / part
|
||||
else:
|
||||
try:
|
||||
match = next(
|
||||
(p for p in resolved.iterdir() if p.name.lower() == part.lower()),
|
||||
None,
|
||||
)
|
||||
except (OSError, NotADirectoryError):
|
||||
match = None
|
||||
resolved = match if match else (resolved / part)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
# Inline marker-override keys honored at build time. These are the per-occurrence
|
||||
# presentation fields materialized onto events.json and resolved per-event
|
||||
# (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not
|
||||
# yet overridable inline; unknown keys are ignored.
|
||||
_MARKER_OVERRIDE_KEYS = frozenset({"cutout", "layer", "end_on", "take"})
|
||||
|
||||
|
||||
def _coerce_marker_value(key: str, raw: str):
|
||||
"""Coerce an inline override value: `take` → float; everything else → string."""
|
||||
v = raw.strip().strip('"').strip("'")
|
||||
if key == "take":
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def parse_marker(raw: str) -> "tuple[str, Optional[dict]]":
|
||||
"""Split a bracket's contents into (marker_id, overrides).
|
||||
|
||||
Supports the inline-overload grammar `[prefix:handle, key=value, key=value]`:
|
||||
the text before the first comma is the marker id (prefix:handle); the rest are
|
||||
comma-separated key=value overrides. Only _MARKER_OVERRIDE_KEYS are kept (others
|
||||
ignored). A plain marker (no comma) returns (marker_id, None).
|
||||
|
||||
Examples:
|
||||
"vsb:clip" -> ("vsb:clip", None)
|
||||
"vsb:clip, end_on=next_video" -> ("vsb:clip", {"end_on": "next_video"})
|
||||
"video:clip, cutout=square, layer=below"
|
||||
-> ("video:clip", {"cutout": "square", "layer": "below"})
|
||||
"""
|
||||
if "," not in raw:
|
||||
return raw.strip(), None
|
||||
head, _, tail = raw.partition(",")
|
||||
marker_id = head.strip()
|
||||
overrides: dict = {}
|
||||
for tok in tail.split(","):
|
||||
if "=" not in tok:
|
||||
continue
|
||||
key, _, val = tok.partition("=")
|
||||
key = key.strip().lower()
|
||||
if key in _MARKER_OVERRIDE_KEYS:
|
||||
coerced = _coerce_marker_value(key, val)
|
||||
if coerced is not None:
|
||||
overrides[key] = coerced
|
||||
return marker_id, (overrides or None)
|
||||
|
||||
|
||||
def parse_manuscript(
|
||||
project_path: Path,
|
||||
) -> tuple[str, list[str], list[tuple[int, str]], list[Citation]]:
|
||||
"""
|
||||
Parse manuscript.txt and extract text content and slide markers.
|
||||
|
||||
Strips [cite:...] and [marker:...] markers from the returned text so they
|
||||
never pollute alignment contexts. Citations are extracted and returned
|
||||
separately. Marker cues are personal recording notes and are simply discarded.
|
||||
|
||||
Returns:
|
||||
Tuple of (full text, list of marker IDs found, list of malformed markers as (line_num, text))
|
||||
Tuple of (full text, list of marker IDs found, list of malformed markers, list of citations)
|
||||
"""
|
||||
manuscript_path = project_path / "manuscript.txt"
|
||||
|
||||
@@ -31,8 +123,24 @@ def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int
|
||||
|
||||
text = manuscript_path.read_text(encoding="utf-8")
|
||||
|
||||
# Extract all valid slide markers like [S1], [S2], etc.
|
||||
markers = re.findall(r"\[([A-Za-z0-9_]+)\]", text)
|
||||
# Extract citations before stripping them
|
||||
citations = parse_citations(text)
|
||||
|
||||
# Strip [cite:...] markers from text so they don't pollute alignment
|
||||
text = re.sub(r"\[cite:[^\]]+\]", "", text)
|
||||
|
||||
# Strip narrator cues (ignored by pipeline)
|
||||
text = re.sub(r"\[marker:[^\]]+\]", "", text)
|
||||
text = re.sub(r"\[cue:[^\]]+\]", "", text)
|
||||
text = re.sub(r"\[pause\]", "", text)
|
||||
text = re.sub(r"\[stop\]", "", text)
|
||||
|
||||
# Extract all valid markers like [S1], [video:demo], [vf2m:pexels/clip-name], and
|
||||
# inline-override forms like [vsb:clip, end_on=next_video]. Include / and - for
|
||||
# pexels/library video IDs; . for file extensions; an optional ",…" tail carries
|
||||
# per-event overrides (parsed out by parse_marker, so `markers` holds bare ids).
|
||||
raw_markers = re.findall(r"\[([A-Za-z0-9_:./\-]+(?:,[^\]\n]*)?)\]", text)
|
||||
markers = [parse_marker(m)[0] for m in raw_markers]
|
||||
|
||||
# Find malformed markers (missing brackets, extra spaces, etc.)
|
||||
malformed: list[tuple[int, str]] = []
|
||||
@@ -56,48 +164,72 @@ def parse_manuscript(project_path: Path) -> tuple[str, list[str], list[tuple[int
|
||||
for match in spaced:
|
||||
malformed.append((line_num, match))
|
||||
|
||||
return text, markers, malformed
|
||||
return text, markers, malformed, citations
|
||||
|
||||
|
||||
def parse_transcript(project_path: Path) -> list[TimedWord]:
|
||||
def parse_citations(manuscript_text: str) -> list[Citation]:
|
||||
"""
|
||||
Parse transcript.csv into a list of timed words.
|
||||
Extract all [cite:...] markers from manuscript text.
|
||||
|
||||
Expected format:
|
||||
t,word
|
||||
0.00,This
|
||||
0.42,is
|
||||
...
|
||||
The text after 'cite:' is the literal reference that should appear
|
||||
in the video description.
|
||||
|
||||
Returns:
|
||||
List of Citation objects with reference text and context for alignment.
|
||||
"""
|
||||
transcript_path = project_path / "transcript.csv"
|
||||
citations = []
|
||||
|
||||
if not transcript_path.exists():
|
||||
raise ParseError("transcript.csv not found", transcript_path)
|
||||
# Match [cite:...] markers - content can include any characters except ]
|
||||
# Use a more permissive pattern that handles multi-word citations
|
||||
pattern = r"\[cite:([^\]]+)\]"
|
||||
|
||||
timed_words = []
|
||||
for match in re.finditer(pattern, manuscript_text):
|
||||
reference = match.group(1).strip()
|
||||
marker_id = f"cite:{reference}"
|
||||
|
||||
with open(transcript_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
# Extract context: text following the citation (for alignment)
|
||||
# Get up to 100 chars after the marker, stopping at next marker or newline
|
||||
end_pos = match.end()
|
||||
context_text = manuscript_text[end_pos : end_pos + 150]
|
||||
|
||||
if reader.fieldnames is None or "t" not in reader.fieldnames or "word" not in reader.fieldnames:
|
||||
raise ParseError(
|
||||
"transcript.csv must have columns: t, word",
|
||||
transcript_path
|
||||
# Clean up context: take text until next marker or double newline
|
||||
context_match = re.match(r"([^\[]*?)(?:\[|\n\n|$)", context_text)
|
||||
context = context_match.group(1).strip() if context_match else ""
|
||||
|
||||
# Truncate context to ~50 chars for display
|
||||
if len(context) > 50:
|
||||
context = context[:47] + "..."
|
||||
|
||||
citations.append(
|
||||
Citation(
|
||||
reference=reference,
|
||||
marker_id=marker_id,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
for line_num, row in enumerate(reader, start=2): # start=2 because line 1 is header
|
||||
try:
|
||||
time = float(row["t"])
|
||||
word = row["word"].strip()
|
||||
timed_words.append(TimedWord(time=time, word=word))
|
||||
except (ValueError, KeyError) as e:
|
||||
raise ParseError(
|
||||
f"Invalid row: {e}",
|
||||
transcript_path,
|
||||
line_num
|
||||
)
|
||||
return citations
|
||||
|
||||
return timed_words
|
||||
|
||||
def save_citations(citations: list[Citation], path: Path) -> None:
|
||||
"""Save citations to a JSON file."""
|
||||
data = [{"reference": c.reference, "context": c.context} for c in citations]
|
||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_citations(path: Path) -> list[Citation]:
|
||||
"""Load citations from a JSON file."""
|
||||
if not path.exists():
|
||||
return []
|
||||
data = _read_json(path)
|
||||
return [
|
||||
Citation(
|
||||
reference=item["reference"],
|
||||
marker_id=f"cite:{item['reference']}",
|
||||
context=item.get("context", ""),
|
||||
)
|
||||
for item in data
|
||||
]
|
||||
|
||||
|
||||
def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||
@@ -108,19 +240,75 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||
raise ParseError("project.json not found", config_path)
|
||||
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
data = _read_json(config_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", config_path)
|
||||
|
||||
# Parse talking head config
|
||||
th_data = data.get("talkinghead", {})
|
||||
th_height, th_height_pct = _parse_dimension(th_data.get("targetheight", 200))
|
||||
talking_head = TalkingHeadConfig(
|
||||
x=th_data.get("x", 100),
|
||||
y=th_data.get("y", 100),
|
||||
target_height=th_height,
|
||||
target_height_percent=th_height_pct,
|
||||
file=th_data.get("file"),
|
||||
# Built-in cutouts — used by vft/vfb/vst/vsb marker shorthand.
|
||||
# Projects can override these by defining cutouts with the same names.
|
||||
cutouts: dict[str, CutoutDefinition] = {
|
||||
# 100 % × 100 % at origin — for fullscreen video (vf* markers)
|
||||
"fullscreen": CutoutDefinition(
|
||||
x=-1,
|
||||
y=-1,
|
||||
height=-1,
|
||||
width=-1,
|
||||
x_percent=0.0,
|
||||
y_percent=0.0,
|
||||
height_percent=1.0,
|
||||
width_percent=1.0,
|
||||
),
|
||||
# 50 % height, square aspect, centred — for square video (vs* markers)
|
||||
"square": CutoutDefinition(
|
||||
x=-1,
|
||||
y=-1,
|
||||
height=-1,
|
||||
width=-1,
|
||||
x_percent=0.25,
|
||||
y_percent=0.25,
|
||||
height_percent=0.5,
|
||||
width_percent=0.0,
|
||||
),
|
||||
}
|
||||
|
||||
# Parse cutouts (named zones for video placement) — project definitions
|
||||
# override the built-ins above.
|
||||
cutouts_data = data.get("cutouts", {})
|
||||
for cutout_name, cutout_data in cutouts_data.items():
|
||||
if "cx" in cutout_data or "cy" in cutout_data:
|
||||
# New center-based model: cx/cy are the center (fractions of frame W/H);
|
||||
# width/height/margin are fractions of min(W,H). width==height → square.
|
||||
w_pct = _parse_percent(cutout_data.get("width", "100%"))
|
||||
h_pct = _parse_percent(cutout_data.get("height", cutout_data.get("width", "100%")))
|
||||
cutouts[cutout_name] = CutoutDefinition(
|
||||
x=-1,
|
||||
y=-1,
|
||||
height=-1,
|
||||
width=-1,
|
||||
cx_percent=_parse_percent(cutout_data.get("cx", "50%")),
|
||||
cy_percent=_parse_percent(cutout_data.get("cy", "50%")),
|
||||
width_percent=w_pct,
|
||||
height_percent=h_pct,
|
||||
margin_percent=_parse_percent(cutout_data.get("margin", "0%")),
|
||||
size_relative_to_min=True,
|
||||
)
|
||||
continue
|
||||
x, x_pct = _parse_dimension(cutout_data.get("x", 0))
|
||||
y, y_pct = _parse_dimension(cutout_data.get("y", 0))
|
||||
height, height_pct = _parse_dimension(cutout_data.get("height", 200))
|
||||
# Width defaults to same as height (square) if not specified
|
||||
width, width_pct = _parse_dimension(
|
||||
cutout_data.get("width", cutout_data.get("height", 200))
|
||||
)
|
||||
cutouts[cutout_name] = CutoutDefinition(
|
||||
x=x,
|
||||
y=y,
|
||||
height=height,
|
||||
width=width,
|
||||
x_percent=x_pct,
|
||||
y_percent=y_pct,
|
||||
height_percent=height_pct,
|
||||
width_percent=width_pct,
|
||||
)
|
||||
|
||||
# Parse resolution
|
||||
@@ -128,15 +316,30 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
|
||||
if not isinstance(resolution, list) or len(resolution) != 2:
|
||||
raise ParseError("resolution must be [width, height]", config_path)
|
||||
|
||||
# Parse default_filters (named filter presets)
|
||||
default_filters: dict[str, list[dict]] = data.get("default_filters", {})
|
||||
|
||||
return ProjectConfig(
|
||||
resolution=tuple(resolution),
|
||||
fps=data.get("fps", 30),
|
||||
talking_head=talking_head,
|
||||
default_slide_type=data.get("defaultSlideType", "square"),
|
||||
cutouts=cutouts,
|
||||
default_filters=default_filters,
|
||||
background=data.get("background", ""),
|
||||
background_video=data.get("background_video", ""), # Deprecated
|
||||
slides_path=data.get("slides", "slides.json"),
|
||||
videos_path=data.get("videos", "videos.json"),
|
||||
audio_path=data.get("audio", "audio.json"),
|
||||
transcript_path=data.get("transcript"),
|
||||
audio_source=data.get("audio_source"),
|
||||
main_video=data.get("main_video"),
|
||||
process_cache=data.get("process_cache"),
|
||||
default_begin=float(data.get("default_begin", 0.0)),
|
||||
default_end_trim=float(data.get("default_end_trim", 0.0)),
|
||||
outro=data.get("outro", []),
|
||||
description=data.get("description", ""),
|
||||
footer=data.get("footer", ""),
|
||||
output_video=data.get("output_video", ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -157,18 +360,41 @@ def _parse_dimension(value: Any) -> tuple[int, float]:
|
||||
return 200, 0.0 # default
|
||||
|
||||
|
||||
def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str, SlideDefinition]:
|
||||
def _parse_percent(value: Any, default: float = 0.0) -> float:
|
||||
"""Parse a percentage into a 0.0-based fraction. '177%' → 1.77; a bare number
|
||||
is taken as an already-computed fraction (1.77 → 1.77). Used by the center-based
|
||||
cutout model where values can exceed 100% (fullscreen width on 16:9)."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
v = value.strip()
|
||||
if v.endswith("%"):
|
||||
return float(v[:-1]) / 100.0
|
||||
return float(v)
|
||||
return float(value)
|
||||
|
||||
|
||||
def parse_slides(
|
||||
project_path: Path, config: ProjectConfig = None
|
||||
) -> dict[str, SlideDefinition]:
|
||||
"""Parse slides.json into slide definitions."""
|
||||
if config and config.slides_path:
|
||||
slides_path = project_path / config.slides_path
|
||||
# Lowercase the path so that a capital-cased project name embedded by
|
||||
# the import stage (e.g. "media/slides/video2/slides.json") resolves
|
||||
# correctly on case-sensitive filesystems (WSL/Linux).
|
||||
local_slides_path = project_path / config.slides_path.lower()
|
||||
else:
|
||||
slides_path = project_path / "slides.json"
|
||||
local_slides_path = project_path / "slides.json"
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
slides_path, _ = resolve_with_cache(local_slides_path, project_path)
|
||||
if not slides_path.exists():
|
||||
raise ParseError(f"slides file not found: {slides_path}", slides_path)
|
||||
raise ParseError(
|
||||
f"slides file not found: {local_slides_path}", local_slides_path
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(slides_path.read_text(encoding="utf-8"))
|
||||
data = _read_json(slides_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", slides_path)
|
||||
|
||||
@@ -176,8 +402,7 @@ def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str,
|
||||
for slide_id, slide_data in data.items():
|
||||
if "image" not in slide_data:
|
||||
raise ParseError(
|
||||
f"Slide '{slide_id}' missing required field 'image'",
|
||||
slides_path
|
||||
f"Slide '{slide_id}' missing required field 'image'", slides_path
|
||||
)
|
||||
slides[slide_id] = SlideDefinition(
|
||||
image=slide_data["image"],
|
||||
@@ -187,32 +412,351 @@ def parse_slides(project_path: Path, config: ProjectConfig = None) -> dict[str,
|
||||
return slides
|
||||
|
||||
|
||||
def parse_videos(project_path: Path) -> dict[str, VideoSource]:
|
||||
"""Parse videos.json into video source definitions."""
|
||||
videos_path = project_path / "videos.json"
|
||||
def parse_audio(
|
||||
project_path: Path, config: Optional[ProjectConfig] = None
|
||||
) -> tuple[dict[str, AudioDefinition], Path]:
|
||||
"""
|
||||
Parse audio.json into audio definitions.
|
||||
|
||||
if not videos_path.exists():
|
||||
raise ParseError("videos.json not found", videos_path)
|
||||
Returns:
|
||||
Tuple of (audio dict, audio_dir) where audio_dir is the directory
|
||||
containing audio.json (for resolving relative file paths).
|
||||
"""
|
||||
if config and config.audio_path:
|
||||
local_audio_path = project_path / config.audio_path
|
||||
else:
|
||||
local_audio_path = project_path / "audio.json"
|
||||
|
||||
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||
audio_dir = local_audio_path.parent
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
audio_path, _ = resolve_with_cache(local_audio_path, project_path)
|
||||
|
||||
# Audio is optional - return empty dict if not found
|
||||
if not audio_path.exists():
|
||||
return {}, audio_dir
|
||||
|
||||
try:
|
||||
data = json.loads(videos_path.read_text(encoding="utf-8"))
|
||||
data = _read_json(audio_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", audio_path)
|
||||
|
||||
audio = {}
|
||||
for audio_id, audio_data in data.items():
|
||||
if "file" not in audio_data:
|
||||
raise ParseError(
|
||||
f"Audio '{audio_id}' missing required field 'file'", audio_path
|
||||
)
|
||||
# Parse overlap if specified (timestamp string like "10s")
|
||||
overlap = None
|
||||
if "overlap" in audio_data and audio_data["overlap"]:
|
||||
overlap = parse_timestamp(audio_data["overlap"])
|
||||
|
||||
raw_duration = audio_data.get("duration")
|
||||
audio[audio_id] = AudioDefinition(
|
||||
file=audio_data["file"],
|
||||
volume=float(audio_data.get("volume", 1.0)),
|
||||
loop=bool(audio_data.get("loop", False)),
|
||||
overlap=overlap,
|
||||
ignore_pauses=bool(audio_data.get("ignore_pauses", False)),
|
||||
duration=float(raw_duration) if raw_duration is not None else None,
|
||||
is_shared=bool(audio_data.get("is_shared", False)),
|
||||
)
|
||||
|
||||
return audio, audio_dir
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> float:
|
||||
"""
|
||||
Parse a timestamp string into seconds.
|
||||
|
||||
Supported formats:
|
||||
- "3.5s" or "3.5" → 3.5 seconds
|
||||
- "2:54" → 2 minutes 54 seconds (174.0)
|
||||
- "1:23:45" → 1 hour 23 minutes 45 seconds
|
||||
- "2:54.5" → 2 minutes 54.5 seconds
|
||||
- "2m:3.5s" → 2 minutes 3.5 seconds
|
||||
|
||||
Returns:
|
||||
Time in seconds as a float.
|
||||
"""
|
||||
if value is None:
|
||||
return 0.0
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return 0.0
|
||||
|
||||
# Remove trailing 's' if present (e.g., "3.5s")
|
||||
if "h" in value:
|
||||
value = value.replace("h", ":")
|
||||
if "m" in value:
|
||||
value = value.replace("m", ":")
|
||||
if value.endswith("s"):
|
||||
value = value[:-1]
|
||||
|
||||
# Check for colon-separated format (MM:SS or HH:MM:SS)
|
||||
if ":" in value:
|
||||
parts = value.split(":")
|
||||
if len(parts) == 2:
|
||||
# MM:SS format
|
||||
minutes, seconds = parts
|
||||
return float(minutes) * 60 + float(seconds)
|
||||
elif len(parts) == 3:
|
||||
# HH:MM:SS format
|
||||
hours, minutes, seconds = parts
|
||||
return float(hours) * 3600 + float(minutes) * 60 + float(seconds)
|
||||
else:
|
||||
raise ParseError(f"Invalid timestamp format: {value}", None)
|
||||
|
||||
# Plain number (seconds)
|
||||
return float(value)
|
||||
|
||||
|
||||
def parse_videos(
|
||||
project_path: Path, config: Optional[ProjectConfig] = None
|
||||
) -> tuple[dict[str, VideoSource], Path]:
|
||||
"""
|
||||
Parse videos.json into video source definitions.
|
||||
|
||||
Filter can be specified as:
|
||||
- A list of filter configs (inline definition)
|
||||
- A string referencing a named preset in config.default_filters
|
||||
|
||||
Trim points can be specified as:
|
||||
- skip/take: raw values in seconds (traditional)
|
||||
- begin/end: timestamp strings like "3.5s", "2:54", "1:23:45" (user-friendly)
|
||||
These are converted to skip/take internally.
|
||||
|
||||
Returns:
|
||||
Tuple of (videos dict, videos_dir) where videos_dir is the directory
|
||||
containing videos.json (for resolving relative file paths).
|
||||
"""
|
||||
if config and config.videos_path:
|
||||
local_videos_path = project_path / config.videos_path
|
||||
else:
|
||||
local_videos_path = project_path / "videos.json"
|
||||
|
||||
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||
videos_dir = local_videos_path.parent
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
videos_path, _ = resolve_with_cache(local_videos_path, project_path)
|
||||
if not videos_path.exists():
|
||||
raise ParseError(
|
||||
f"videos.json not found: {local_videos_path}", local_videos_path
|
||||
)
|
||||
|
||||
try:
|
||||
data = _read_json(videos_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", videos_path)
|
||||
|
||||
# Get default_filters from config for resolving references
|
||||
default_filters = config.default_filters if config else {}
|
||||
|
||||
videos = {}
|
||||
for video_id, video_data in data.items():
|
||||
if "file" not in video_data:
|
||||
|
||||
if "source_file" not in video_data:
|
||||
raise ParseError(
|
||||
f"Video '{video_id}' missing required field 'file'",
|
||||
videos_path
|
||||
)
|
||||
videos[video_id] = VideoSource(
|
||||
file=video_data["file"],
|
||||
preprocess=video_data.get("preprocess", []),
|
||||
output_file=video_data.get("output_file"),
|
||||
f"Video '{video_id}' missing required field 'source_file'", videos_path
|
||||
)
|
||||
|
||||
return videos
|
||||
# Parse attribution if present
|
||||
attribution = None
|
||||
if "attribution" in video_data:
|
||||
attr_data = video_data["attribution"]
|
||||
attribution = Attribution(
|
||||
source=attr_data.get("source", "unknown"),
|
||||
creator=attr_data.get("creator", "Unknown"),
|
||||
url=attr_data.get("url"),
|
||||
)
|
||||
|
||||
# Resolve filter - can be a list or a string reference to default_filters
|
||||
filter_value = video_data.get("filter", [])
|
||||
if isinstance(filter_value, str):
|
||||
# It's a reference to a named filter preset
|
||||
if filter_value not in default_filters:
|
||||
raise ParseError(
|
||||
f"Video '{video_id}' references unknown filter preset '{filter_value}'. "
|
||||
f"Available presets: {list(default_filters.keys())}",
|
||||
videos_path,
|
||||
)
|
||||
filter_list = default_filters[filter_value]
|
||||
else:
|
||||
# It's an inline filter definition
|
||||
filter_list = filter_value
|
||||
|
||||
# Handle skip/take - can use begin/end as user-friendly alternatives
|
||||
skip = float(video_data.get("skip") or 0.0)
|
||||
take = (
|
||||
float(video_data["take"])
|
||||
if video_data.get("take") not in (None, "")
|
||||
else None
|
||||
)
|
||||
|
||||
# Convert begin/end to skip/take if provided
|
||||
if "begin" in video_data and video_data["begin"]:
|
||||
skip = parse_timestamp(video_data["begin"])
|
||||
if "end" in video_data and video_data["end"]:
|
||||
end_time = parse_timestamp(video_data["end"])
|
||||
# take = end - begin (duration from begin to end)
|
||||
take = end_time - skip
|
||||
|
||||
raw_duration = video_data.get("duration")
|
||||
raw_has_audio = video_data.get("has_audio")
|
||||
videos[video_id] = VideoSource(
|
||||
source_file=video_data["source_file"],
|
||||
filter=filter_list,
|
||||
output_file=video_data.get("output_file"),
|
||||
take=take,
|
||||
skip=skip,
|
||||
zoom=video_data.get("zoom", 1.0),
|
||||
cutout=video_data.get("cutout"),
|
||||
always_visible=video_data.get("always_visible", False),
|
||||
is_shared=video_data.get("is_shared", False),
|
||||
pause_narration=float(video_data.get("pause_narration", 0)),
|
||||
attribution=attribution,
|
||||
use_audio_channels=video_data.get("use_audio_channels", "both"),
|
||||
defer_loudnorm=video_data.get("defer_loudnorm", False),
|
||||
volume=float(video_data.get("volume", 1.0)),
|
||||
layer=video_data.get("layer", "above"),
|
||||
duration=float(raw_duration) if raw_duration is not None else None,
|
||||
has_audio=bool(raw_has_audio) if raw_has_audio is not None else None,
|
||||
end_on=video_data.get("end_on"),
|
||||
)
|
||||
|
||||
return videos, videos_dir
|
||||
|
||||
|
||||
def parse_narration(
|
||||
project_path: Path, config: Optional[ProjectConfig] = None
|
||||
) -> tuple[dict[str, VideoSource], Path]:
|
||||
"""
|
||||
Parse narration.json into narration segment definitions.
|
||||
|
||||
Narration segments are stored in media/narration/ and are processed
|
||||
separately from videos. Each segment can have filters, begin/end trim
|
||||
points, and other properties similar to videos.
|
||||
|
||||
Filter can be specified as:
|
||||
- A list of filter configs (inline definition)
|
||||
- A string referencing a named preset in config.default_filters
|
||||
|
||||
Trim points can be specified as:
|
||||
- skip/take: raw values in seconds (traditional)
|
||||
- begin/end: timestamp strings like "3.5s", "2:54", "1:23:45" (user-friendly)
|
||||
These are converted to skip/take internally.
|
||||
|
||||
Returns:
|
||||
Tuple of (narration dict, narration_dir) where narration_dir is the directory
|
||||
containing narration.json (for resolving relative file paths).
|
||||
"""
|
||||
# Narration is always in media/narration/
|
||||
# Keep local directory for file lookups (cache fallback handles resolution)
|
||||
narration_dir = project_path / "media" / "narration"
|
||||
local_narration_path = narration_dir / "narration.json"
|
||||
|
||||
# Try cache fallback for reading JSON
|
||||
narration_path, _ = resolve_with_cache(local_narration_path, project_path)
|
||||
|
||||
# Narration is optional - return empty dict if not found
|
||||
if not narration_path.exists():
|
||||
return {}, narration_dir
|
||||
|
||||
try:
|
||||
data = _read_json(narration_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", narration_path)
|
||||
|
||||
# Get default_filters from config for resolving references
|
||||
default_filters = config.default_filters if config else {}
|
||||
|
||||
narration = {}
|
||||
_narr_video_exts = {".mov", ".mp4", ".webm", ".avi", ".mkv", ".m4v"}
|
||||
for segment_id, segment_data in data.items():
|
||||
if not segment_data.get("source_file"):
|
||||
# source_file can drift out of an entry (case/sync churn between
|
||||
# machines). Recover the way import/prune/trim do: find the raw
|
||||
# recording whose stem matches the segment id, case-insensitively.
|
||||
recovered = None
|
||||
for sub in ("raw_mov", "processed"):
|
||||
sub_dir = narration_dir / sub
|
||||
if not sub_dir.is_dir():
|
||||
continue
|
||||
for f in sorted(sub_dir.iterdir()):
|
||||
if (
|
||||
f.is_file()
|
||||
and f.suffix.lower() in _narr_video_exts
|
||||
and f.stem.lower() == segment_id.lower()
|
||||
):
|
||||
recovered = f"{sub}/{f.name}"
|
||||
break
|
||||
if recovered:
|
||||
break
|
||||
if recovered is None:
|
||||
raise ParseError(
|
||||
f"Narration segment '{segment_id}' missing required field 'source_file' "
|
||||
f"and no matching recording was found in raw_mov/ or processed/. "
|
||||
f"Add a 'source_file' or run 'import' to repair narration.json.",
|
||||
narration_path,
|
||||
)
|
||||
segment_data = {**segment_data, "source_file": recovered}
|
||||
|
||||
# Resolve filter - can be a list or a string reference to default_filters
|
||||
filter_value = segment_data.get("filter", [])
|
||||
if isinstance(filter_value, str):
|
||||
# It's a reference to a named filter preset
|
||||
if filter_value not in default_filters:
|
||||
raise ParseError(
|
||||
f"Narration segment '{segment_id}' references unknown filter preset '{filter_value}'. "
|
||||
f"Available presets: {list(default_filters.keys())}",
|
||||
narration_path,
|
||||
)
|
||||
filter_list = default_filters[filter_value]
|
||||
else:
|
||||
# It's an inline filter definition
|
||||
filter_list = filter_value
|
||||
|
||||
# Handle skip/take - can use begin/end as user-friendly alternatives
|
||||
# Fall back to project-level defaults if no explicit value is set
|
||||
default_begin = config.default_begin if config else 0.0
|
||||
skip = segment_data.get("skip", default_begin)
|
||||
take = segment_data.get("take")
|
||||
|
||||
# Explicit begin/start/end always override defaults
|
||||
if "begin" in segment_data and segment_data["begin"]:
|
||||
skip = parse_timestamp(segment_data["begin"])
|
||||
elif "start" in segment_data and segment_data["start"]:
|
||||
skip = parse_timestamp(segment_data["start"])
|
||||
if "end" in segment_data and segment_data["end"]:
|
||||
end_time = parse_timestamp(segment_data["end"])
|
||||
# take = end - begin (duration from begin to end)
|
||||
take = end_time - skip
|
||||
|
||||
narration[segment_id] = VideoSource(
|
||||
source_file=segment_data["source_file"],
|
||||
filter=filter_list,
|
||||
# New model stores the preprocessed output under "processed_file";
|
||||
# "output_file" is the legacy key. Either maps to output_file, which
|
||||
# render uses (preferring it when it exists, else source_file).
|
||||
output_file=segment_data.get("processed_file")
|
||||
or segment_data.get("output_file"),
|
||||
take=take,
|
||||
skip=skip,
|
||||
zoom=segment_data.get("zoom", 1.0),
|
||||
cutout=segment_data.get("cutout"),
|
||||
always_visible=segment_data.get("always_visible", False),
|
||||
use_audio_channels=segment_data.get("use_audio_channels", "both"),
|
||||
defer_loudnorm=segment_data.get("defer_loudnorm", False),
|
||||
volume=float(segment_data.get("volume", 1.0)),
|
||||
)
|
||||
|
||||
return narration, narration_dir
|
||||
|
||||
|
||||
def get_video_duration(video_path: Path) -> float:
|
||||
@@ -221,10 +765,13 @@ def get_video_duration(video_path: Path) -> float:
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(video_path)
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(video_path),
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
@@ -256,12 +803,14 @@ def parse_video_metadata(metadata_path: Path) -> VideoMetadata:
|
||||
raise ParseError(f"Video metadata not found: {metadata_path}", metadata_path)
|
||||
|
||||
try:
|
||||
data = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
data = _read_json(metadata_path)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ParseError(f"Invalid JSON: {e}", metadata_path)
|
||||
|
||||
if "source_file" not in data:
|
||||
raise ParseError("Video metadata missing required field 'source_file'", metadata_path)
|
||||
raise ParseError(
|
||||
"Video metadata missing required field 'source_file'", metadata_path
|
||||
)
|
||||
|
||||
return VideoMetadata(
|
||||
source_file=data["source_file"],
|
||||
@@ -270,7 +819,9 @@ def parse_video_metadata(metadata_path: Path) -> VideoMetadata:
|
||||
)
|
||||
|
||||
|
||||
def resolve_video_file(project_path: Path, file_ref: str) -> tuple[Path, Optional[VideoMetadata]]:
|
||||
def resolve_video_file(
|
||||
project_path: Path, file_ref: str
|
||||
) -> tuple[Path, Optional[VideoMetadata]]:
|
||||
"""
|
||||
Resolve a video file reference, which can be either:
|
||||
1. A direct path to a video file
|
||||
@@ -300,3 +851,106 @@ def resolve_video_file(project_path: Path, file_ref: str) -> tuple[Path, Optiona
|
||||
|
||||
# Direct video file reference
|
||||
return ref_path, None
|
||||
|
||||
|
||||
def resolve_missing_videos(
|
||||
missing_ids: list[str],
|
||||
project_path: Path,
|
||||
config: Optional[ProjectConfig] = None,
|
||||
) -> dict[str, VideoSource]:
|
||||
"""
|
||||
For video IDs not found in the project's videos.json, look them up in
|
||||
shared_assets/videos.json. When a match is found the entry is written back
|
||||
into the project's videos.json with ``is_shared: true`` so subsequent runs
|
||||
find it without another lookup.
|
||||
|
||||
Returns a dict of newly resolved VideoSource objects (only the ones found).
|
||||
Silently ignores IDs that aren't in the shared library either.
|
||||
"""
|
||||
if not missing_ids:
|
||||
return {}
|
||||
|
||||
# Locate shared_assets
|
||||
shared_dir: Optional[Path] = None
|
||||
if (project_path / "shared_assets").exists():
|
||||
shared_dir = project_path / "shared_assets"
|
||||
elif (project_path.parent / "shared_assets").exists():
|
||||
shared_dir = project_path.parent / "shared_assets"
|
||||
|
||||
if shared_dir is None:
|
||||
return {}
|
||||
|
||||
shared_videos_path = shared_dir / "videos.json"
|
||||
if not shared_videos_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
shared_data = _read_json(shared_videos_path)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
found = {vid_id for vid_id in missing_ids if vid_id in shared_data}
|
||||
if not found:
|
||||
return {}
|
||||
|
||||
# Load the project's videos.json so we can append to it
|
||||
if config and config.videos_path:
|
||||
local_videos_path = project_path / config.videos_path
|
||||
else:
|
||||
local_videos_path = project_path / "videos.json"
|
||||
|
||||
try:
|
||||
local_data = _read_json(local_videos_path) if local_videos_path.exists() else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
local_data = {}
|
||||
|
||||
resolved: dict[str, VideoSource] = {}
|
||||
for video_id in sorted(found):
|
||||
entry = dict(shared_data[video_id])
|
||||
entry["is_shared"] = True
|
||||
|
||||
# Persist into the project's videos.json
|
||||
local_data[video_id] = entry
|
||||
print(f" → Copied shared video '{video_id}' into videos.json (is_shared=true)")
|
||||
|
||||
# Build the in-memory VideoSource
|
||||
attribution = None
|
||||
if "attribution" in entry:
|
||||
attr = entry["attribution"]
|
||||
attribution = Attribution(
|
||||
source=attr.get("source", "unknown"),
|
||||
creator=attr.get("creator", "Unknown"),
|
||||
url=attr.get("url"),
|
||||
)
|
||||
|
||||
raw_duration = entry.get("duration")
|
||||
raw_has_audio = entry.get("has_audio")
|
||||
resolved[video_id] = VideoSource(
|
||||
source_file=entry["source_file"],
|
||||
filter=entry.get("filter", []),
|
||||
output_file=entry.get("output_file"),
|
||||
take=entry.get("take"),
|
||||
skip=float(entry.get("skip", 0.0)),
|
||||
zoom=float(entry.get("zoom", 1.0)),
|
||||
cutout=entry.get("cutout"),
|
||||
always_visible=bool(entry.get("always_visible", False)),
|
||||
is_shared=True,
|
||||
pause_narration=float(entry.get("pause_narration", 0)),
|
||||
attribution=attribution,
|
||||
use_audio_channels=entry.get("use_audio_channels", "both"),
|
||||
defer_loudnorm=bool(entry.get("defer_loudnorm", False)),
|
||||
volume=float(entry.get("volume", 1.0)),
|
||||
layer=entry.get("layer", "above"),
|
||||
duration=float(raw_duration) if raw_duration is not None else None,
|
||||
has_audio=bool(raw_has_audio) if raw_has_audio is not None else None,
|
||||
end_on=entry.get("end_on"),
|
||||
)
|
||||
|
||||
try:
|
||||
with open(local_videos_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(local_data, fh, indent=4)
|
||||
fh.write("\n")
|
||||
except OSError as e:
|
||||
print(f" Warning: could not update videos.json: {e}")
|
||||
|
||||
return resolved
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Pexels video downloader for gnommo shared_assets.
|
||||
|
||||
Configure API key in ~/.gnommo.conf:
|
||||
|
||||
[pexels]
|
||||
api_key = YOUR_KEY_HERE
|
||||
|
||||
Get a free key at https://www.pexels.com/api/
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_pexels_api_key() -> Optional[str]:
|
||||
config_path = Path.home() / ".gnommo.conf"
|
||||
if not config_path.exists():
|
||||
return None
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(config_path)
|
||||
return cfg.get("pexels", "api_key", fallback=None)
|
||||
|
||||
|
||||
def extract_pexels_id(source_file: str) -> Optional[str]:
|
||||
"""Extract the numeric Pexels video ID from a source_file path.
|
||||
|
||||
Handles names like 'pexels/11868263-hd_1920_1080_24fps.mp4'
|
||||
and 'pexels/12136677_1080_1920_30fps.mp4'.
|
||||
"""
|
||||
name = Path(source_file).stem.split("/")[-1]
|
||||
m = re.match(r"^(\d+)", name)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _fetch_video_info(pexels_id: str, api_key: str) -> Optional[dict]:
|
||||
url = f"https://api.pexels.com/videos/videos/{pexels_id}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Authorization": api_key, "User-Agent": "Mozilla/5.0 gnommo/1.0"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" [{pexels_id}] Pexels API error {e.code} — video may have been deleted", flush=True)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" [{pexels_id}] Pexels API error: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def description_from_url(video_url: str) -> str:
|
||||
"""Extract human-readable description from a Pexels video URL slug.
|
||||
|
||||
'https://www.pexels.com/video/abstract-television-noise-11868263/'
|
||||
→ 'Abstract Television Noise'
|
||||
"""
|
||||
m = re.search(r"/video/([a-z0-9][a-z0-9-]+?)-\d+/?$", video_url)
|
||||
if m:
|
||||
return m.group(1).replace("-", " ").title()
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_best_video_file(video_files: list, source_file: str) -> Optional[dict]:
|
||||
"""Select the video_files entry that best matches the hints in source_file."""
|
||||
stem = Path(source_file).stem.split("/")[-1]
|
||||
|
||||
width_hint = height_hint = fps_hint = quality_hint = None
|
||||
m = re.search(r"[_-](\d{3,4})[_-](\d{3,4})[_-](\d+)fps", stem)
|
||||
if m:
|
||||
width_hint = int(m.group(1))
|
||||
height_hint = int(m.group(2))
|
||||
fps_hint = int(m.group(3))
|
||||
for q in ("uhd", "hd", "sd"):
|
||||
if q in stem.lower():
|
||||
quality_hint = q
|
||||
break
|
||||
|
||||
mp4s = [f for f in video_files if f.get("file_type") == "video/mp4"]
|
||||
if not mp4s:
|
||||
mp4s = video_files # fall back to any format
|
||||
|
||||
def score(vf: dict) -> int:
|
||||
s = 0
|
||||
if quality_hint and vf.get("quality", "").lower() == quality_hint:
|
||||
s += 10
|
||||
if width_hint and vf.get("width") == width_hint:
|
||||
s += 5
|
||||
if height_hint and vf.get("height") == height_hint:
|
||||
s += 5
|
||||
if fps_hint and round(float(vf.get("fps") or 0)) == fps_hint:
|
||||
s += 3
|
||||
return s
|
||||
|
||||
return max(mp4s, key=score)
|
||||
|
||||
|
||||
def download_video(
|
||||
source_file: str,
|
||||
shared_assets_dir: Path,
|
||||
api_key: str,
|
||||
) -> Optional[dict]:
|
||||
"""Download one Pexels video to shared_assets_dir/<source_file>.
|
||||
|
||||
Returns a metadata dict {description, duration, has_audio=False} on
|
||||
success, or None on failure.
|
||||
"""
|
||||
pexels_id = extract_pexels_id(source_file)
|
||||
if not pexels_id:
|
||||
print(f" Cannot extract Pexels ID from: {source_file}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
target_path = shared_assets_dir / source_file
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" [{pexels_id}] Fetching video info...", flush=True)
|
||||
info = _fetch_video_info(pexels_id, api_key)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
description = description_from_url(info.get("url", ""))
|
||||
duration = float(info.get("duration") or 0) or None
|
||||
|
||||
video_files = info.get("video_files", [])
|
||||
if not video_files:
|
||||
print(f" [{pexels_id}] No video files in API response", flush=True)
|
||||
return None
|
||||
|
||||
best = _pick_best_video_file(video_files, source_file)
|
||||
if not best:
|
||||
return None
|
||||
|
||||
download_url = best["link"]
|
||||
w, h, fps = best.get("width", "?"), best.get("height", "?"), best.get("fps", "?")
|
||||
q = best.get("quality", "?")
|
||||
label = f'"{description}" — ' if description else ""
|
||||
print(f" [{pexels_id}] {label}{q} {w}x{h} @ {fps}fps", flush=True)
|
||||
print(f" → {target_path}", flush=True)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
download_url, headers={"User-Agent": "Mozilla/5.0 gnommo/1.0"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
total = int(resp.headers.get("Content-Length") or 0)
|
||||
downloaded = 0
|
||||
chunks: list[bytes] = []
|
||||
chunk_size = 1024 * 512 # 512 KB
|
||||
while True:
|
||||
chunk = resp.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total:
|
||||
pct = downloaded * 100 // total
|
||||
mb_done = downloaded / 1024 / 1024
|
||||
mb_total = total / 1024 / 1024
|
||||
print(f" {pct:3d}% {mb_done:.1f}/{mb_total:.1f} MB\r", end="", flush=True)
|
||||
print(f" Done — {downloaded / 1024 / 1024:.1f} MB ", flush=True)
|
||||
target_path.write_bytes(b"".join(chunks))
|
||||
except Exception as e:
|
||||
print(f"\n Download failed: {e}", flush=True)
|
||||
return None
|
||||
|
||||
return {
|
||||
"description": description,
|
||||
"duration": duration,
|
||||
"has_audio": False, # conservative; renderer probes when needed
|
||||
}
|
||||
|
||||
|
||||
def update_videos_json(
|
||||
json_path: Path,
|
||||
video_id: str,
|
||||
metadata: dict,
|
||||
) -> None:
|
||||
"""Write description (and other metadata) into an existing videos.json entry."""
|
||||
if not json_path.exists():
|
||||
return
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
if video_id not in raw:
|
||||
return
|
||||
changed = False
|
||||
for key, value in metadata.items():
|
||||
if value and raw[video_id].get(key) != value:
|
||||
raw[video_id][key] = value
|
||||
changed = True
|
||||
if changed:
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(raw, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def fetch_metadata(pexels_id: str, api_key: str) -> Optional[dict]:
|
||||
"""Fetch only description and duration for a Pexels video (no download)."""
|
||||
info = _fetch_video_info(pexels_id, api_key)
|
||||
if not info:
|
||||
return None
|
||||
return {
|
||||
"description": description_from_url(info.get("url", "")),
|
||||
"duration": float(info.get("duration") or 0) or None,
|
||||
}
|
||||
|
||||
|
||||
def enrich_missing_descriptions(
|
||||
shared_assets_dir: Path,
|
||||
api_key: str,
|
||||
) -> int:
|
||||
"""Fetch descriptions from Pexels API for entries that have a file on disk but no description.
|
||||
|
||||
Scans shared_assets/videos.json for pexels/* entries where:
|
||||
- description is absent or empty
|
||||
- source_file exists on disk (locally or via cache)
|
||||
|
||||
Returns number of entries updated.
|
||||
"""
|
||||
from .cache import resolve_with_cache
|
||||
|
||||
videos_json = shared_assets_dir / "videos.json"
|
||||
if not videos_json.exists():
|
||||
return 0
|
||||
|
||||
with open(videos_json, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
candidates = [
|
||||
(vid_id, entry)
|
||||
for vid_id, entry in raw.items()
|
||||
if vid_id.startswith("pexels/") and not entry.get("description")
|
||||
]
|
||||
|
||||
# Filter to those whose file exists on disk
|
||||
to_enrich = []
|
||||
for vid_id, entry in candidates:
|
||||
sf = entry.get("source_file", "")
|
||||
if not sf:
|
||||
continue
|
||||
path = shared_assets_dir / sf
|
||||
resolved, _ = resolve_with_cache(path, shared_assets_dir)
|
||||
if resolved.exists():
|
||||
pexels_id = extract_pexels_id(sf)
|
||||
if pexels_id:
|
||||
to_enrich.append((vid_id, pexels_id))
|
||||
|
||||
if not to_enrich:
|
||||
return 0
|
||||
|
||||
print(f" Enriching descriptions for {len(to_enrich)} existing pexels video(s)...", flush=True)
|
||||
|
||||
updated = 0
|
||||
for vid_id, pexels_id in to_enrich:
|
||||
meta = fetch_metadata(pexels_id, api_key)
|
||||
if meta and meta.get("description"):
|
||||
print(f" [{pexels_id}] \"{meta['description']}\"", flush=True)
|
||||
update_videos_json(videos_json, vid_id, meta)
|
||||
updated += 1
|
||||
else:
|
||||
print(f" [{pexels_id}] not found or no description — skipped", flush=True)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def _search_videos(
|
||||
query: str, api_key: str, per_page: int = 80, page: int = 1
|
||||
) -> Optional[dict]:
|
||||
"""Call the Pexels video search API and return the raw response."""
|
||||
import urllib.parse
|
||||
|
||||
params = urllib.parse.urlencode({"query": query, "per_page": per_page, "page": page})
|
||||
url = f"https://api.pexels.com/videos/search?{params}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Authorization": api_key, "User-Agent": "Mozilla/5.0 gnommo/1.0"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f" Pexels search error: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def _pick_best_quality(video_files: list) -> Optional[dict]:
|
||||
"""Pick the highest-resolution MP4 from a search result's video_files list."""
|
||||
mp4s = [f for f in video_files if f.get("file_type") == "video/mp4"]
|
||||
if not mp4s:
|
||||
mp4s = video_files
|
||||
if not mp4s:
|
||||
return None
|
||||
return max(mp4s, key=lambda f: f.get("width", 0) * f.get("height", 0))
|
||||
|
||||
|
||||
def _make_source_filename(pexels_id: str, video_file: dict) -> str:
|
||||
"""Build a canonical filename like 12345678_1920_1080_30fps.mp4."""
|
||||
w = video_file.get("width", 0)
|
||||
h = video_file.get("height", 0)
|
||||
fps = round(float(video_file.get("fps") or 0))
|
||||
return f"{pexels_id}_{w}_{h}_{fps}fps.mp4"
|
||||
|
||||
|
||||
def _download_bytes(url: str, target_path: Path) -> bool:
|
||||
"""Stream-download url to target_path with a progress indicator. Returns True on success."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 gnommo/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
total = int(resp.headers.get("Content-Length") or 0)
|
||||
done = 0
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = resp.read(524288) # 512 KB
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
done += len(chunk)
|
||||
if total:
|
||||
pct = done * 100 // total
|
||||
print(
|
||||
f" {pct:3d}% {done/1048576:.1f}/{total/1048576:.1f} MB\r",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
print(f" Done — {done/1048576:.1f} MB ", flush=True)
|
||||
target_path.write_bytes(b"".join(chunks))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"\n Download failed: {e}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def search_and_download(
|
||||
query: str,
|
||||
pexels_dir: Path,
|
||||
shared_videos_json: Path,
|
||||
api_key: str,
|
||||
max_results: int = 200,
|
||||
) -> tuple[int, int]:
|
||||
"""Search Pexels for *query* and download all results to pexels_dir.
|
||||
|
||||
Each video is saved as ``pexels_dir/{pexels_id}_{w}_{h}_{fps}fps.mp4`` and
|
||||
registered in *shared_videos_json* so the renderer can find it.
|
||||
|
||||
Returns (downloaded_count, skipped_count).
|
||||
"""
|
||||
print(f"Searching Pexels for '{query}'...", flush=True)
|
||||
pexels_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing registry so we can skip already-downloaded videos
|
||||
existing: dict = {}
|
||||
if shared_videos_json.exists():
|
||||
with open(shared_videos_json, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
|
||||
downloaded = 0
|
||||
skipped = 0
|
||||
page = 1
|
||||
|
||||
while downloaded + skipped < max_results:
|
||||
per_page = min(80, max_results - downloaded - skipped)
|
||||
result = _search_videos(query, api_key, per_page=per_page, page=page)
|
||||
if not result:
|
||||
break
|
||||
|
||||
videos = result.get("videos", [])
|
||||
if not videos:
|
||||
break
|
||||
|
||||
total_results = result.get("total_results", 0)
|
||||
print(
|
||||
f" Page {page}: {len(videos)} result(s) (Pexels total: {total_results})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
for video in videos:
|
||||
pexels_id = str(video.get("id", ""))
|
||||
video_files = video.get("video_files", [])
|
||||
if not pexels_id or not video_files:
|
||||
continue
|
||||
|
||||
best = _pick_best_quality(video_files)
|
||||
if not best:
|
||||
continue
|
||||
|
||||
filename = _make_source_filename(pexels_id, best)
|
||||
video_id = f"pexels/{Path(filename).stem}"
|
||||
target_path = pexels_dir / filename
|
||||
source_file = f"pexels/{filename}"
|
||||
|
||||
# Skip if already registered or file already on disk
|
||||
if video_id in existing or target_path.exists():
|
||||
if video_id not in existing:
|
||||
# File exists but not registered — register it
|
||||
pass
|
||||
else:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
description = description_from_url(video.get("url", ""))
|
||||
duration = float(video.get("duration") or 0) or None
|
||||
w = best.get("width", "?")
|
||||
h = best.get("height", "?")
|
||||
fps = best.get("fps", "?")
|
||||
q = best.get("quality", "?")
|
||||
label = f'"{description}" — ' if description else ""
|
||||
print(f" [{pexels_id}] {label}{q} {w}x{h} @ {fps}fps", flush=True)
|
||||
print(f" → {target_path}", flush=True)
|
||||
|
||||
if not target_path.exists():
|
||||
if not _download_bytes(best["link"], target_path):
|
||||
continue
|
||||
|
||||
# Register in shared videos.json
|
||||
existing[video_id] = {
|
||||
"source_file": source_file,
|
||||
"description": description,
|
||||
"duration": duration,
|
||||
"has_audio": False,
|
||||
}
|
||||
with open(shared_videos_json, "w", encoding="utf-8") as f:
|
||||
json.dump(existing, f, indent=2, ensure_ascii=False)
|
||||
|
||||
downloaded += 1
|
||||
|
||||
if not result.get("next_page"):
|
||||
break
|
||||
page += 1
|
||||
|
||||
return downloaded, skipped
|
||||
|
||||
|
||||
def find_missing_pexels_videos(
|
||||
manuscript_markers: list[str],
|
||||
videos: dict,
|
||||
shared_assets_dir: Path,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Return [(video_id, source_file)] for pexels videos referenced but not on disk."""
|
||||
from .cache import resolve_with_cache
|
||||
|
||||
_VIDEO_PREFIXES = (
|
||||
"video:", "narration:",
|
||||
"vft:", "vfb:", "vfm:",
|
||||
"vf2t:", "vf2b:", "vf2m:",
|
||||
"vst:", "vsb:", "vsm:",
|
||||
"vftp:", "vfbp:", "vfmp:",
|
||||
"vf2tp:", "vf2bp:", "vf2mp:",
|
||||
"vstp:", "vsbp:", "vsmp:",
|
||||
)
|
||||
|
||||
seen: set[str] = set()
|
||||
missing: list[tuple[str, str]] = []
|
||||
|
||||
for marker in manuscript_markers:
|
||||
prefix = next((p for p in _VIDEO_PREFIXES if marker.startswith(p)), None)
|
||||
if prefix is None:
|
||||
continue
|
||||
video_id = marker[len(prefix):].lower()
|
||||
if video_id in seen or not video_id.startswith("pexels/"):
|
||||
continue
|
||||
seen.add(video_id)
|
||||
|
||||
source_file = videos.get(video_id, None)
|
||||
if source_file is None:
|
||||
# Not in videos.json yet — synthesize expected path from the ID
|
||||
sf = video_id + ".mp4"
|
||||
else:
|
||||
sf = source_file.source_file if hasattr(source_file, "source_file") else source_file
|
||||
|
||||
candidate = shared_assets_dir / sf
|
||||
resolved, _ = resolve_with_cache(candidate, shared_assets_dir)
|
||||
if not resolved.exists():
|
||||
missing.append((video_id, sf))
|
||||
|
||||
return missing
|
||||
+2497
-72
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
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
"""Push project metadata to gnommoweb (the review app).
|
||||
|
||||
Usage:
|
||||
gnommo push -p video1 # push to local gnommoweb
|
||||
gnommo push -p video1 --prod # push to production gnommoweb
|
||||
gnommo push -p short_pixelated_universe # push a short project
|
||||
gnommo push -p myproject --force # force push, overwrite server
|
||||
|
||||
POSTs the project metadata to gnommoweb's POST /api/projects/push.
|
||||
|
||||
Configuration (from .env or environment):
|
||||
GNOMMOWEB_URL Base URL for local dev (e.g. http://localhost:3001)
|
||||
GNOMMOWEB_API_KEY Bearer token for local (CONTENT_API_KEY)
|
||||
GNOMMOWEB_PROD_URL Base URL for production (e.g. https://glitch.university)
|
||||
GNOMMOWEB_PROD_API_KEY Bearer token for production (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 cmd_push(
|
||||
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
|
||||
) -> int:
|
||||
_load_env_file()
|
||||
|
||||
project_file = project_path / "project.json"
|
||||
if not project_file.exists():
|
||||
print(f"Error: {project_file} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(project_file) as f:
|
||||
project = json.load(f)
|
||||
|
||||
project_id = project.get("id")
|
||||
name = project.get("name")
|
||||
if not project_id or not name:
|
||||
print("Error: project.json must have 'id' and 'name' fields.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return _push_gnommoweb(project, project_path, verbose, force, prod)
|
||||
|
||||
|
||||
# ── gnommoweb POST /api/projects/push ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _push_gnommoweb(
|
||||
project: dict, project_path: Path, verbose: bool, force: bool, prod: bool
|
||||
) -> int:
|
||||
# --prod selects the production gnommoweb instance; without it, local dev.
|
||||
if prod:
|
||||
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||
url_var, key_var = "GNOMMOWEB_PROD_URL", "GNOMMOWEB_PROD_API_KEY"
|
||||
else:
|
||||
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||
url_var, key_var = "GNOMMOWEB_URL", "GNOMMOWEB_API_KEY"
|
||||
if not api_url:
|
||||
print(f"Error: {url_var} is not set.", file=sys.stderr)
|
||||
return 1
|
||||
if not api_key:
|
||||
print(f"Error: {key_var} is not set.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if verbose:
|
||||
target = "production" if prod else "local"
|
||||
print(f" → gnommoweb {target}: {api_url}")
|
||||
|
||||
project_id = project["id"]
|
||||
parent_project = project.get("parent_project")
|
||||
|
||||
if parent_project:
|
||||
payload = _build_short_payload(project, project_path, verbose)
|
||||
else:
|
||||
payload = _build_parent_payload(project, project_path, verbose)
|
||||
|
||||
if verbose:
|
||||
kind = "short" if parent_project else "parent video"
|
||||
print(f"Pushing {project_id} ({kind}) to {api_url}")
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{api_url}/api/projects/push",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"✗ Could not connect to {api_url}")
|
||||
return 1
|
||||
|
||||
if not r.ok:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = r.text[:500]
|
||||
print(f"✗ Server returned {r.status_code}: {body}")
|
||||
return 1
|
||||
|
||||
result = r.json()
|
||||
server_updated_at = result.get("server_updated_at")
|
||||
|
||||
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
||||
existing_sync = _read_sync(project_path, prod=prod)
|
||||
_write_sync(
|
||||
project_path,
|
||||
{
|
||||
**existing_sync,
|
||||
"last_pushed_at": now_iso,
|
||||
"server_updated_at": server_updated_at,
|
||||
},
|
||||
prod=prod,
|
||||
)
|
||||
|
||||
asset = result.get("asset", {})
|
||||
if result.get("type") == "short":
|
||||
print(f"✓ {project_id} → gn_asset #{asset.get('id')} [{asset.get('status')}]")
|
||||
if result.get("task_created"):
|
||||
print(f" task #{result['task_id']} created")
|
||||
else:
|
||||
print(f"✓ {project_id} → gn_asset #{asset.get('id')} ({asset.get('name')})")
|
||||
if verbose:
|
||||
script_len = len(asset.get("script") or "")
|
||||
print(
|
||||
f" server.script: {script_len} chars | fps={asset.get('fps')} res={asset.get('resolution')}"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> dict:
|
||||
script_content = None
|
||||
manuscript_str = project.get("manuscript")
|
||||
if manuscript_str:
|
||||
manuscript_path = project_path / manuscript_str
|
||||
if manuscript_path.exists():
|
||||
try:
|
||||
script_content = manuscript_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
script_content = manuscript_path.read_text(encoding="latin-1")
|
||||
print(f" Warning: manuscript is not UTF-8, read as latin-1")
|
||||
print(f" manuscript: {len(script_content)} chars")
|
||||
else:
|
||||
print(f" Warning: manuscript not found: {manuscript_path}")
|
||||
else:
|
||||
if verbose:
|
||||
print(f" no manuscript field in project.json")
|
||||
|
||||
return {
|
||||
"project_id": project["id"],
|
||||
"name": project["name"],
|
||||
"description": project.get("description"),
|
||||
"course": project.get("course"),
|
||||
"coursecode": project.get("coursecode"),
|
||||
"script_content": script_content,
|
||||
"resolution": project.get("resolution"),
|
||||
"fps": project.get("fps"),
|
||||
"duration_seconds": project.get("duration_seconds"),
|
||||
"hook": project.get("hook"),
|
||||
"platform_targets": project.get("platform_targets"),
|
||||
"status": project.get("status"),
|
||||
"youtube_url": project.get("youtube_url"),
|
||||
"shorts": project.get("shorts", []),
|
||||
}
|
||||
|
||||
|
||||
def _build_short_payload(project: dict, project_path: Path, verbose: bool) -> dict:
|
||||
script_content = None
|
||||
script_path_str = project.get("script")
|
||||
if script_path_str:
|
||||
script_path = project_path / script_path_str
|
||||
if script_path.exists():
|
||||
script_content = script_path.read_text()
|
||||
if verbose:
|
||||
print(f" Read script: {script_path} ({len(script_content)} chars)")
|
||||
else:
|
||||
print(f" Warning: script file not found: {script_path}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"project_id": project["id"],
|
||||
"name": project["name"],
|
||||
"description": project.get("description"),
|
||||
"course": project.get("course"),
|
||||
"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"),
|
||||
}
|
||||
+1499
-77
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,493 @@
|
||||
"""events.json + scaffold.json — the timing layer between alignment and render.
|
||||
|
||||
The render stage is split in two:
|
||||
|
||||
build aligns manuscript markers to the transcript, then writes
|
||||
events.json — one editable entry per marker: {type, id, time, mapping}
|
||||
scaffold.json — the compiled timeline (transcription + narration
|
||||
schedule + merged events + total_duration)
|
||||
render loads the scaffold's event times (honouring any hand-edits to
|
||||
events.json) and renders WITHOUT re-aligning.
|
||||
|
||||
events.json is the authoritative, GUI-editable seam: a future gnommoweb
|
||||
review-stage timeline can read it, let the user drag slide-marker / video-start
|
||||
events around, and write it straight back. Because it stores absolute
|
||||
final-timeline times with stable ids, edits always win over the fuzzy aligner.
|
||||
|
||||
mapping values:
|
||||
exact a confident fuzzy match to the spoken words
|
||||
interpolated placed by the aligner's repair/fallback path, or filled in
|
||||
between neighbours because the phrase wasn't found at all
|
||||
manual set by a human / the GUI; never recomputed by build
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .models import CAMERA_PRESETS
|
||||
from .transformer import MarkerTiming, resolve_video_presentation
|
||||
|
||||
# Per-occurrence presentation fields materialized onto video events (atomic events.json,
|
||||
# GUI-ready). Round-tripped as overrides so a stored value drives render over the default.
|
||||
_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take")
|
||||
|
||||
EVENTS_FILE = "events.json"
|
||||
SCAFFOLD_FILE = "scaffold.json"
|
||||
TRANSCRIBED_FILE = "manuscript_transcribed.txt"
|
||||
|
||||
MAPPING_EXACT = "exact"
|
||||
MAPPING_INTERPOLATED = "interpolated"
|
||||
MAPPING_MANUAL = "manual"
|
||||
|
||||
# Confidence at/above this counts as an exact match (mirrors the aligner default).
|
||||
_EXACT_THRESHOLD = 0.6
|
||||
|
||||
|
||||
# ── marker classification ─────────────────────────────────────────────────────
|
||||
|
||||
# Shorthand prefixes that denote a video/narration trigger (mirrors transformer).
|
||||
_VIDEO_MARKER_PREFIXES = (
|
||||
"video:", "narration:",
|
||||
"vft:", "vfb:", "vfm:", "vf2t:", "vf2b:", "vf2m:",
|
||||
"vst:", "vsb:", "vsm:",
|
||||
"vftp:", "vfbp:", "vfmp:", "vf2tp:", "vf2bp:", "vf2mp:",
|
||||
"vstp:", "vsbp:", "vsmp:",
|
||||
)
|
||||
|
||||
|
||||
def _ci_contains(d: dict, key: str) -> bool:
|
||||
"""Case-insensitive membership: the render lowercases video/audio handles
|
||||
(e.g. the marker vst:UnconstrainedLight resolves to videos.json's
|
||||
unconstrainedlight), so classification must match case-insensitively too."""
|
||||
if not d:
|
||||
return False
|
||||
if key in d:
|
||||
return True
|
||||
lk = key.lower()
|
||||
return any(k.lower() == lk for k in d)
|
||||
|
||||
|
||||
def marker_type(marker_id: str, slides: dict, videos: dict, audio: dict) -> str:
|
||||
"""Classify a marker id as slide / video / audio / camera / other.
|
||||
|
||||
Prefix-aware and case-insensitive. Video markers carry a shorthand prefix
|
||||
(vst:, vfb:, video:, …) and their handle is stored lowercased in videos.json,
|
||||
so a marker like `vst:UnconstrainedLight` is a video even though videos.json
|
||||
only has `unconstrainedlight`.
|
||||
"""
|
||||
if _ci_contains(slides, marker_id):
|
||||
return "slide"
|
||||
if marker_id.startswith(_VIDEO_MARKER_PREFIXES) or _ci_contains(videos, marker_id):
|
||||
return "video"
|
||||
if marker_id.startswith("audio:") or _ci_contains(audio, marker_id):
|
||||
return "audio"
|
||||
if marker_id.startswith("A") and len(marker_id) > 1:
|
||||
aid = marker_id[1:]
|
||||
if aid.isdigit() or _ci_contains(audio, aid):
|
||||
return "audio"
|
||||
if _ci_contains(CAMERA_PRESETS, marker_id):
|
||||
return "camera"
|
||||
return "other"
|
||||
|
||||
|
||||
# Pause-variant video marker prefixes (mirrors transformer). These freeze the
|
||||
# narration, so they're what makes narration_time and final_time diverge.
|
||||
_PAUSE_MARKER_PREFIXES = (
|
||||
"vftp:", "vfbp:", "vfmp:", "vf2tp:", "vf2bp:", "vf2mp:",
|
||||
"vstp:", "vsbp:", "vsmp:",
|
||||
)
|
||||
|
||||
|
||||
def _lookup_video(marker_id: str, videos: dict):
|
||||
"""Case-insensitive videos.json lookup for a video marker (prefix stripped)."""
|
||||
if not videos:
|
||||
return None
|
||||
handle = marker_id.split(":", 1)[1].lower() if ":" in marker_id else marker_id.lower()
|
||||
return videos.get(handle) or next(
|
||||
(v for k, v in videos.items() if k.lower() == handle), None
|
||||
)
|
||||
|
||||
|
||||
def _pause_duration(marker_id: str, videos: dict) -> float:
|
||||
"""Seconds a pause-variant video marker freezes the narration for, else 0."""
|
||||
if not marker_id.startswith(_PAUSE_MARKER_PREFIXES):
|
||||
return 0.0
|
||||
handle = marker_id.split(":", 1)[1].lower() if ":" in marker_id else marker_id.lower()
|
||||
vs = None
|
||||
if videos:
|
||||
vs = videos.get(handle) or next(
|
||||
(v for k, v in videos.items() if k.lower() == handle), None
|
||||
)
|
||||
return float(getattr(vs, "pause_narration", 0.0) or 0.0) if vs else 0.0
|
||||
|
||||
|
||||
# ── derive events from aligner output ─────────────────────────────────────────
|
||||
|
||||
def derive_events(
|
||||
marker_timings: list[MarkerTiming],
|
||||
slides: dict,
|
||||
videos: dict,
|
||||
audio: dict,
|
||||
) -> list[dict]:
|
||||
"""Turn ordered MarkerTiming objects into three-clock event dicts.
|
||||
|
||||
Each event carries two clocks plus a tweak:
|
||||
narration_time — aligner-owned position on the narration/transcript timeline
|
||||
(interpolated for markers Whisper couldn't place). Read-only.
|
||||
adjustment — a human tweak in seconds layered on narration_time. 0 here;
|
||||
preserved across rebuilds by merge_events. The ONLY editable knob.
|
||||
final_time — filled by compute_final_times: (narration_time + adjustment)
|
||||
shifted forward by every pause before it. What the viewer sees.
|
||||
Pause-narration videos also carry pause_duration so the offset is self-describing.
|
||||
"""
|
||||
events: list[dict] = []
|
||||
for t in marker_timings:
|
||||
placed = t.timestamp is not None and t.timestamp >= 0
|
||||
after_prev = (t.context or "").startswith("(after previous)")
|
||||
mapping = (
|
||||
MAPPING_EXACT
|
||||
if (placed and t.confidence >= _EXACT_THRESHOLD and not after_prev)
|
||||
else MAPPING_INTERPOLATED
|
||||
)
|
||||
etype = marker_type(t.marker_id, slides, videos, audio)
|
||||
e = {
|
||||
"type": etype,
|
||||
"id": t.marker_id,
|
||||
"narration_time": round(t.timestamp, 3) if placed else None,
|
||||
"adjustment": 0.0,
|
||||
"final_time": None,
|
||||
"mapping": mapping,
|
||||
"confidence": round(t.confidence, 3),
|
||||
"context": (t.context or "")[:80],
|
||||
}
|
||||
# Materialize per-occurrence presentation onto video events so events.json is
|
||||
# atomic (each occurrence self-contained, GUI-editable) rather than depending on
|
||||
# the handle's videos.json entry. Resolved from the shorthand prefix + any prior
|
||||
# override the marker carried.
|
||||
if etype == "video":
|
||||
vs = _lookup_video(t.marker_id, videos)
|
||||
if vs is not None:
|
||||
pres = resolve_video_presentation(
|
||||
t.marker_id,
|
||||
vs,
|
||||
t.overrides,
|
||||
default_end_on=(
|
||||
None if t.marker_id.startswith("narration:") else "next_video"
|
||||
),
|
||||
)
|
||||
e["handle"] = pres["handle"]
|
||||
e["cutout"] = pres["cutout"]
|
||||
e["layer"] = pres["layer"]
|
||||
e["end_on"] = pres["end_on"]
|
||||
if pres["take"] is not None:
|
||||
e["take"] = pres["take"]
|
||||
pd = _pause_duration(t.marker_id, videos)
|
||||
if pd:
|
||||
e["pause_duration"] = pd
|
||||
events.append(e)
|
||||
|
||||
_interpolate_narration(events)
|
||||
return events
|
||||
|
||||
|
||||
def _interpolate_narration(events: list[dict]) -> None:
|
||||
"""Fill `narration_time: None` entries by linear interpolation between placed
|
||||
neighbours. Head/tail runs spread at +1s steps from the nearest known time (or
|
||||
0.0). Mutates events in place."""
|
||||
n = len(events)
|
||||
i = 0
|
||||
while i < n:
|
||||
if events[i]["narration_time"] is not None:
|
||||
i += 1
|
||||
continue
|
||||
j = i
|
||||
while j < n and events[j]["narration_time"] is None:
|
||||
j += 1
|
||||
prev_time = events[i - 1]["narration_time"] if i > 0 else None
|
||||
next_time = events[j]["narration_time"] if j < n else None
|
||||
gap = j - i
|
||||
if prev_time is not None and next_time is not None:
|
||||
step = (next_time - prev_time) / (gap + 1)
|
||||
for k in range(gap):
|
||||
events[i + k]["narration_time"] = round(prev_time + step * (k + 1), 3)
|
||||
elif prev_time is not None: # trailing run
|
||||
for k in range(gap):
|
||||
events[i + k]["narration_time"] = round(prev_time + 1.0 * (k + 1), 3)
|
||||
elif next_time is not None: # leading run
|
||||
base = max(0.0, next_time - gap)
|
||||
for k in range(gap):
|
||||
events[i + k]["narration_time"] = round(base + 1.0 * k, 3)
|
||||
else: # nothing placed at all
|
||||
for k in range(gap):
|
||||
events[i + k]["narration_time"] = round(1.0 * (i + k), 3)
|
||||
i = j
|
||||
|
||||
|
||||
# ── final_time = narration + adjustment + pause offsets ───────────────────────
|
||||
|
||||
def compute_final_times(events: list[dict]) -> None:
|
||||
"""Fill each event's final_time from its (narration_time + adjustment), shifted
|
||||
forward by every pause whose own (narration + adjustment) is at or before it.
|
||||
|
||||
This is the single, clean definition of the narration→final mapping; the render
|
||||
is fed the same effective narration times so its output matches these values.
|
||||
Mutates events in place.
|
||||
"""
|
||||
def eff(e):
|
||||
n = e.get("narration_time")
|
||||
return None if n is None else n + e.get("adjustment", 0.0)
|
||||
|
||||
pauses = [
|
||||
(eff(e), e.get("pause_duration", 0.0), id(e))
|
||||
for e in events
|
||||
if e.get("pause_duration") and eff(e) is not None
|
||||
]
|
||||
for e in events:
|
||||
ee = eff(e)
|
||||
if ee is None:
|
||||
e["final_time"] = None
|
||||
continue
|
||||
shift = sum(dur for (pn, dur, oid) in pauses if pn <= ee and oid != id(e))
|
||||
e["final_time"] = round(ee + shift, 3)
|
||||
|
||||
|
||||
# ── re-interpolation on rebuild ───────────────────────────────────────────────
|
||||
|
||||
def reinterpolate_events(events: list[dict]) -> None:
|
||||
"""Recompute every `interpolated` event's narration_time between its anchors.
|
||||
|
||||
Anchors are the events the aligner placed (mapping `exact`). Interpolated events
|
||||
carry no real spoken cue, so their narration_time is only ever a guess spread
|
||||
between anchors — recomputed each build. Adjustments are untouched (they layer
|
||||
on top). Mutates events in place; call compute_final_times afterwards.
|
||||
"""
|
||||
for e in events:
|
||||
if e.get("mapping") == MAPPING_INTERPOLATED:
|
||||
e["narration_time"] = None
|
||||
_interpolate_narration(events)
|
||||
|
||||
|
||||
# ── merge: human tweaks survive re-align ──────────────────────────────────────
|
||||
|
||||
def pair_events_by_ordinal(fresh: list[dict], existing: Optional[list[dict]]):
|
||||
"""Yield (fresh_event, matching_existing_event_or_None) pairs.
|
||||
|
||||
Both lists are in deterministic manuscript order, so the Nth occurrence of a
|
||||
given id in `fresh` corresponds to the Nth occurrence in `existing`. Keying by
|
||||
id alone would collapse legitimately-repeated markers (e.g. the same video
|
||||
reused at two points in the script) onto a single old entry and cross-
|
||||
contaminate their timings/tweaks.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
buckets: dict = defaultdict(list)
|
||||
for e in existing or []:
|
||||
buckets[e.get("id")].append(e)
|
||||
counters: dict = defaultdict(int)
|
||||
for e in fresh:
|
||||
mid = e.get("id")
|
||||
idx = counters[mid]
|
||||
counters[mid] += 1
|
||||
bucket = buckets.get(mid, [])
|
||||
yield e, (bucket[idx] if idx < len(bucket) else None)
|
||||
|
||||
|
||||
def merge_events(fresh: list[dict], existing: Optional[list[dict]]) -> list[dict]:
|
||||
"""Carry each id's `adjustment` (the human tweak) from the existing events.json
|
||||
onto the freshly-derived events. narration_time and mapping come from `fresh`
|
||||
(aligner-owned); the tweak is relative so it stays meaningful even when the
|
||||
alignment shifts. New ids are added; ids gone from the manuscript are dropped.
|
||||
|
||||
Matching is by ordinal (Nth occurrence to Nth occurrence), not by id, so a
|
||||
marker reused several times keeps each occurrence's own tweak.
|
||||
"""
|
||||
if not existing:
|
||||
return fresh
|
||||
for e, old in pair_events_by_ordinal(fresh, existing):
|
||||
if old is not None and old.get("adjustment"):
|
||||
e["adjustment"] = old["adjustment"]
|
||||
return fresh
|
||||
|
||||
|
||||
# ── events <-> marker timings ─────────────────────────────────────────────────
|
||||
|
||||
def events_to_marker_timings(events: list[dict]) -> list[MarkerTiming]:
|
||||
"""Reconstruct MarkerTiming objects for build_render_plan override.
|
||||
|
||||
Emits the EFFECTIVE narration time (narration_time + adjustment). The render's
|
||||
pause pass then shifts these into final time, reproducing each event's stored
|
||||
final_time exactly — so events.json is the single source of the render's timing.
|
||||
"""
|
||||
timings: list[MarkerTiming] = []
|
||||
for e in events:
|
||||
n = e.get("narration_time")
|
||||
eff = (n + e.get("adjustment", 0.0)) if n is not None else -1.0
|
||||
# Carry any stored presentation as overrides so render honors the atomic event
|
||||
# (e.g. a GUI edit) over the shorthand/videos.json default. Absent on old events.
|
||||
overrides = {k: e[k] for k in _PRESENTATION_KEYS if k in e} or None
|
||||
timings.append(
|
||||
MarkerTiming(
|
||||
marker_id=e["id"],
|
||||
timestamp=eff,
|
||||
context=e.get("context", ""),
|
||||
confidence=float(e.get("confidence", 1.0)),
|
||||
overrides=overrides,
|
||||
)
|
||||
)
|
||||
return timings
|
||||
|
||||
|
||||
# ── serialization ─────────────────────────────────────────────────────────────
|
||||
|
||||
def read_events(project_path: Path) -> Optional[list[dict]]:
|
||||
p = project_path / EVENTS_FILE
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def write_events(project_path: Path, events: list[dict]) -> Path:
|
||||
p = project_path / EVENTS_FILE
|
||||
p.write_text(json.dumps(events, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def read_scaffold(project_path: Path) -> Optional[dict]:
|
||||
p = project_path / SCAFFOLD_FILE
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def write_scaffold(
|
||||
project_path: Path,
|
||||
events: list[dict],
|
||||
transcription: list,
|
||||
narration_schedule: list,
|
||||
total_duration: float,
|
||||
) -> Path:
|
||||
"""Write the compiled timeline document render consumes.
|
||||
|
||||
Stores times + ids (not heavy slide/video objects — those are re-resolved
|
||||
from disk at render). Includes the transcription so the future timeline GUI
|
||||
can show the spoken words under the draggable markers.
|
||||
"""
|
||||
scaffold = {
|
||||
"version": 1,
|
||||
"total_duration": round(total_duration, 3),
|
||||
"narration": [
|
||||
{
|
||||
"seg_id": s.seg_id,
|
||||
"source_path": str(s.source_path),
|
||||
"skip": round(s.skip, 3),
|
||||
"take": (round(s.take, 3) if s.take is not None else None),
|
||||
"duration": round(s.duration, 3),
|
||||
"offset": round(s.offset, 3),
|
||||
}
|
||||
for s in (narration_schedule or [])
|
||||
],
|
||||
"events": events,
|
||||
"transcription": [
|
||||
{"word": w.word, "start": round(w.start, 3), "end": round(w.end, 3)}
|
||||
for w in (transcription or [])
|
||||
],
|
||||
}
|
||||
p = project_path / SCAFFOLD_FILE
|
||||
p.write_text(json.dumps(scaffold, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
# ── transcribed manuscript (for diffing against the script) ───────────────────
|
||||
|
||||
def _w_start(w) -> float:
|
||||
return w["start"] if isinstance(w, dict) else w.start
|
||||
|
||||
|
||||
def _w_word(w) -> str:
|
||||
return w["word"] if isinstance(w, dict) else w.word
|
||||
|
||||
|
||||
def _fmt_marker(e: dict) -> str:
|
||||
"""Render a marker line, mirroring manuscript.txt so the two files diff cleanly."""
|
||||
mid = e["id"]
|
||||
t = e.get("type")
|
||||
if t == "video":
|
||||
return f"[video:{mid}]"
|
||||
if t == "audio":
|
||||
return f"[audio:{mid}]"
|
||||
return f"[{mid}]" # slide / camera / other
|
||||
|
||||
|
||||
def build_transcribed_manuscript(events: list[dict], transcription: list) -> str:
|
||||
"""Interleave the aligned markers into the ACTUAL spoken transcript.
|
||||
|
||||
Walks the transcript along the timeline and drops each marker (at its aligned
|
||||
time) on its own line followed by the words spoken until the next marker —
|
||||
the same shape as manuscript.txt, but with what was really said. Diff the two
|
||||
to see exactly where the recording drifted from the script.
|
||||
"""
|
||||
# The transcript is the NARRATION timeline, so place markers by their effective
|
||||
# narration time (narration_time + adjustment), not final_time.
|
||||
def _eff(e):
|
||||
nt = e.get("narration_time")
|
||||
return None if nt is None else nt + e.get("adjustment", 0.0)
|
||||
|
||||
evs = sorted(
|
||||
[(_eff(e), e) for e in events if _eff(e) is not None],
|
||||
key=lambda pair: pair[0],
|
||||
)
|
||||
words = transcription or []
|
||||
n = len(words)
|
||||
|
||||
lines: list[str] = [
|
||||
"# Auto-generated from the spoken transcript with aligned markers.",
|
||||
"# Diff against manuscript.txt to see where the recording drifts from the script.",
|
||||
"",
|
||||
]
|
||||
buf: list[str] = []
|
||||
|
||||
def _flush() -> None:
|
||||
if buf:
|
||||
lines.append(" ".join(buf))
|
||||
buf.clear()
|
||||
|
||||
wi = 0
|
||||
for t, e in evs:
|
||||
while wi < n and _w_start(words[wi]) < t:
|
||||
buf.append(_w_word(words[wi]))
|
||||
wi += 1
|
||||
_flush()
|
||||
if lines and lines[-1] != "":
|
||||
lines.append("")
|
||||
lines.append(_fmt_marker(e))
|
||||
while wi < n:
|
||||
buf.append(_w_word(words[wi]))
|
||||
wi += 1
|
||||
_flush()
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def write_transcribed_manuscript(
|
||||
project_path: Path, events: list[dict], transcription: list
|
||||
) -> Path:
|
||||
p = project_path / TRANSCRIBED_FILE
|
||||
p.write_text(build_transcribed_manuscript(events, transcription), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def mapping_summary(events: list[dict]) -> dict:
|
||||
"""Count events by mapping for a concise build/render summary."""
|
||||
out = {MAPPING_EXACT: 0, MAPPING_INTERPOLATED: 0, MAPPING_MANUAL: 0}
|
||||
for e in events:
|
||||
out[e.get("mapping", MAPPING_INTERPOLATED)] = out.get(e.get("mapping", MAPPING_INTERPOLATED), 0) + 1
|
||||
return out
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""Persistent per-stage completion tracking.
|
||||
|
||||
Each pipeline stage (preprocess, trim, render) records a fingerprint of
|
||||
its inputs in ``.gnommo_state.json`` when it completes successfully. On the next
|
||||
run a stage can ask whether its inputs are unchanged (and its output still
|
||||
present) and skip the work — the same staleness intelligence that ``all``'s
|
||||
in-memory cascade provides, but persisted so it also applies to stages run on
|
||||
their own.
|
||||
|
||||
Fingerprinting is hybrid:
|
||||
- small text manifests (narration.json, videos.json, manuscript.txt,
|
||||
project.json, slides.json, audio.json, transcripts) are hashed (sha256) so a
|
||||
``touch`` or a git checkout that only rewrites mtimes doesn't force a
|
||||
needless rerun;
|
||||
- large media (processed narration segments, source videos and
|
||||
images) use mtime+size, which is cheap and good enough to detect real edits.
|
||||
|
||||
The state file is purely an optimization: any read/parse/write failure degrades
|
||||
to "not current" (rerun) and never raises, so a corrupt or missing state file
|
||||
can't break a build.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional, Union
|
||||
|
||||
STATE_FILENAME = ".gnommo_state.json"
|
||||
STATE_VERSION = 1
|
||||
|
||||
# Fingerprint modes
|
||||
HASH = "hash" # sha256 of file contents — for small text manifests
|
||||
META = "meta" # mtime_ns + size — for large media
|
||||
|
||||
# An input descriptor is a (label, path, mode) triple.
|
||||
InputSpec = tuple[str, Path, str]
|
||||
|
||||
|
||||
def _state_path(project_path: Path) -> Path:
|
||||
return project_path / STATE_FILENAME
|
||||
|
||||
|
||||
def _empty_state() -> dict:
|
||||
return {"version": STATE_VERSION, "stages": {}}
|
||||
|
||||
|
||||
def load_state(project_path: Path) -> dict:
|
||||
"""Load the state file, returning an empty skeleton on any problem."""
|
||||
path = _state_path(project_path)
|
||||
if not path.exists():
|
||||
return _empty_state()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return _empty_state()
|
||||
if not isinstance(data, dict):
|
||||
return _empty_state()
|
||||
data.setdefault("version", STATE_VERSION)
|
||||
if not isinstance(data.get("stages"), dict):
|
||||
data["stages"] = {}
|
||||
return data
|
||||
|
||||
|
||||
def save_state(project_path: Path, state: dict) -> None:
|
||||
"""Write the state file. Never raises — state is best-effort."""
|
||||
try:
|
||||
_state_path(project_path).write_text(
|
||||
json.dumps(state, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def fingerprint_path(path: Union[str, Path], mode: str) -> Optional[str]:
|
||||
"""Return a fingerprint for a single file, or None if it can't be read."""
|
||||
p = Path(path)
|
||||
try:
|
||||
if mode == HASH:
|
||||
h = hashlib.sha256()
|
||||
with open(p, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return f"sha256:{h.hexdigest()}"
|
||||
st = p.stat()
|
||||
return f"meta:{st.st_mtime_ns}:{st.st_size}"
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def compute(inputs: Iterable[InputSpec]) -> dict:
|
||||
"""Build a {label: fingerprint} map from (label, path, mode) triples.
|
||||
|
||||
A missing file yields a null fingerprint, so a file appearing or disappearing
|
||||
counts as a change.
|
||||
"""
|
||||
return {label: fingerprint_path(path, mode) for label, path, mode in inputs}
|
||||
|
||||
|
||||
def get_stage(project_path: Path, stage_key: str) -> dict:
|
||||
"""Return the recorded record for a stage (``{}`` if none)."""
|
||||
return load_state(project_path).get("stages", {}).get(stage_key, {})
|
||||
|
||||
|
||||
def get_items(project_path: Path, stage_key: str) -> dict:
|
||||
"""Return the per-item fingerprint map recorded for a stage (``{}`` if none)."""
|
||||
items = get_stage(project_path, stage_key).get("items")
|
||||
return items if isinstance(items, dict) else {}
|
||||
|
||||
|
||||
def is_current(
|
||||
project_path: Path,
|
||||
stage_key: str,
|
||||
inputs: dict,
|
||||
outputs: Iterable[Union[str, Path]] = (),
|
||||
) -> bool:
|
||||
"""True iff the recorded input fingerprint matches ``inputs`` exactly and
|
||||
every output in ``outputs`` exists on disk."""
|
||||
for out in outputs:
|
||||
if not Path(out).exists():
|
||||
return False
|
||||
recorded = get_stage(project_path, stage_key).get("inputs")
|
||||
return recorded == inputs
|
||||
|
||||
|
||||
def record(project_path: Path, stage_key: str, inputs: dict) -> None:
|
||||
"""Persist a stage-level input fingerprint, marking the stage complete."""
|
||||
state = load_state(project_path)
|
||||
state.setdefault("stages", {})[stage_key] = {
|
||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"inputs": inputs,
|
||||
}
|
||||
save_state(project_path, state)
|
||||
|
||||
|
||||
def record_items(project_path: Path, stage_key: str, items: dict) -> None:
|
||||
"""Merge per-item fingerprints into a stage's record (for multi-segment
|
||||
stages like preprocess/trim). Existing items for other keys are preserved."""
|
||||
state = load_state(project_path)
|
||||
stage = state.setdefault("stages", {}).setdefault(stage_key, {})
|
||||
stage["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
merged = stage.get("items")
|
||||
if not isinstance(merged, dict):
|
||||
merged = {}
|
||||
merged.update(items)
|
||||
stage["items"] = merged
|
||||
save_state(project_path, state)
|
||||
+23
-10
@@ -5,12 +5,15 @@ import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import resolve_with_cache
|
||||
from .errors import GnommoError
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscribedWord:
|
||||
"""A word with its timestamp from transcription."""
|
||||
|
||||
word: str
|
||||
start: float
|
||||
end: float
|
||||
@@ -18,6 +21,7 @@ class TranscribedWord:
|
||||
|
||||
class TranscriptionError(GnommoError):
|
||||
"""Error during transcription."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -57,28 +61,38 @@ def transcribe_video(video_path: Path, model: str = "base") -> list[TranscribedW
|
||||
|
||||
for segment in result.get("segments", []):
|
||||
for word_info in segment.get("words", []):
|
||||
words.append(TranscribedWord(
|
||||
words.append(
|
||||
TranscribedWord(
|
||||
word=word_info["word"].strip(),
|
||||
start=word_info["start"],
|
||||
end=word_info["end"],
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
return words
|
||||
|
||||
|
||||
def save_transcript(words: list[TranscribedWord], output_path: Path) -> None:
|
||||
"""Save transcribed words to a JSON file."""
|
||||
data = [
|
||||
{"word": w.word, "start": w.start, "end": w.end}
|
||||
for w in words
|
||||
]
|
||||
data = [{"word": w.word, "start": w.start, "end": w.end} for w in words]
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def load_transcript(transcript_path: Path) -> list[TranscribedWord]:
|
||||
"""Load transcribed words from a JSON file."""
|
||||
def load_transcript(
|
||||
transcript_path: Path, project_path: Optional[Path] = None
|
||||
) -> list[TranscribedWord]:
|
||||
"""Load transcribed words from a JSON file.
|
||||
|
||||
Args:
|
||||
transcript_path: Path to the transcript JSON file
|
||||
project_path: Optional project path for cache fallback
|
||||
"""
|
||||
# Try cache fallback if project_path provided
|
||||
if project_path:
|
||||
transcript_path, _ = resolve_with_cache(transcript_path, project_path)
|
||||
|
||||
if not transcript_path.exists():
|
||||
raise TranscriptionError(f"Transcript file not found: {transcript_path}")
|
||||
|
||||
@@ -86,6 +100,5 @@ def load_transcript(transcript_path: Path) -> list[TranscribedWord]:
|
||||
data = json.load(f)
|
||||
|
||||
return [
|
||||
TranscribedWord(word=w["word"], start=w["start"], end=w["end"])
|
||||
for w in data
|
||||
TranscribedWord(word=w["word"], start=w["start"], end=w["end"]) for w in data
|
||||
]
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""SSH-based file transfer for gnommo projects (commit / up / down).
|
||||
|
||||
Workflow:
|
||||
gnommo -p video1 commit -m "re-recorded slides 7-15"
|
||||
gnommo -p video1 up # push to rendering server
|
||||
gnommo -p video1 down # pull from rendering server
|
||||
|
||||
Design:
|
||||
- commit appends a timestamped entry to commits.log
|
||||
- up checks server commits.log for newer entry (aborts if found), then
|
||||
rsyncs the WHOLE project tree, excluding only the large/derived files
|
||||
in _SYNC_EXCLUDES. Everything else — manuscript, slides, narration
|
||||
raw_mov/, per-segment transcripts, events.json, audio, videos, keynote
|
||||
— is carried automatically, so a new kind of input file can never be
|
||||
silently left behind.
|
||||
- down rsyncs the WHOLE project tree back, with the same _SYNC_EXCLUDES.
|
||||
|
||||
Sync model: move everything, exclude a small denylist. The exclusions are large
|
||||
derived artifacts each side regenerates or ships on its own (rendered output,
|
||||
preprocessed segments, downscales, chunk scratch), so they never travel over SSH.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_COMMITS_LOG = "commits.log"
|
||||
|
||||
# Files/dirs NEVER transferred in either direction. Everything else syncs, so new
|
||||
# artifacts (transcripts, manuscript_transcribed.txt, events.json, scaffold.json, …)
|
||||
# are carried automatically without touching this list. These are large, derived,
|
||||
# or machine-local outputs that each side regenerates or ships itself:
|
||||
# - out/ rendered video (produced on the rig; `handoff` ships it for review)
|
||||
# - processed/ heavy preprocessed *_processed.mov segments (rig regenerates)
|
||||
# - intermediate/ ffmpeg work files
|
||||
# - low/ proxy/ derived downscales
|
||||
# - **/chunks/ per-chunk preprocess scratch
|
||||
# Note: events.json / scaffold.json are deliberately NOT excluded — they must reach
|
||||
# the rig so `down` + `render` is all that's needed there, and render never rewrites
|
||||
# them, so they stay identical on both ends.
|
||||
_SYNC_EXCLUDES = [
|
||||
"out/",
|
||||
"media/narration/processed/",
|
||||
"media/narration/old/",
|
||||
"media/narration/raw_mov/old/",
|
||||
"media/narration/intermediate/",
|
||||
"media/videos/intermediate/",
|
||||
"media/narration/low/",
|
||||
"media/videos/low/",
|
||||
"media/narration/proxy/",
|
||||
"media/videos/proxy/",
|
||||
"**/chunks/",
|
||||
"*.tmp",
|
||||
".*", # rsync in-progress temp files (.filename.XXXXXX) and .DS_Store
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared assets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_shared_assets_root(project_path: Path) -> Optional[Path]:
|
||||
"""Return the local shared_assets directory, or None if not found."""
|
||||
candidate = project_path.parent / "shared_assets"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
try:
|
||||
from .cache import load_assets_config
|
||||
assets = load_assets_config()
|
||||
if assets:
|
||||
candidate = assets / "shared_assets"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commit log helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_log_lines(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return [l for l in path.read_text(encoding="utf-8").splitlines() if l.strip()]
|
||||
|
||||
|
||||
def _last_timestamp(lines: list[str]) -> Optional[str]:
|
||||
for line in reversed(lines):
|
||||
ts = line.split(" | ")[0].strip()
|
||||
if ts:
|
||||
return ts
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_commit(project_path: Path, message: str) -> int:
|
||||
log_path = project_path / _COMMITS_LOG
|
||||
ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(f"{ts} | {message}\n")
|
||||
print(f"[{ts}] {message}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_up(project_path: Path, verbose: bool, dry_run: bool) -> int:
|
||||
from .cache import load_server_config
|
||||
|
||||
server = load_server_config()
|
||||
if server is None:
|
||||
_print_server_error()
|
||||
return 1
|
||||
|
||||
# Require a local commit
|
||||
log_path = project_path / _COMMITS_LOG
|
||||
local_lines = _read_log_lines(log_path)
|
||||
if not local_lines:
|
||||
print(f"Error: No commits. Run: gnommo -p {project_path.name} commit -m 'message'")
|
||||
return 1
|
||||
local_last = _last_timestamp(local_lines)
|
||||
|
||||
# Fetch server's commits.log and compare
|
||||
remote_project = f"{server['path']}/{project_path.name}"
|
||||
ssh_cmd = ["ssh", "-p", server["port"], f"{server['user']}@{server['host']}"]
|
||||
result = subprocess.run(
|
||||
[*ssh_cmd, f"cat {remote_project}/{_COMMITS_LOG} 2>/dev/null"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
server_lines = [l for l in result.stdout.splitlines() if l.strip()]
|
||||
server_last = _last_timestamp(server_lines)
|
||||
|
||||
if server_last and server_last > local_last:
|
||||
print(f"Error: Server has a more recent commit — pull first.")
|
||||
print(f" Local: {local_last}")
|
||||
print(f" Server: {server_last}")
|
||||
print(f" Run: gnommo -p {project_path.name} down")
|
||||
return 1
|
||||
|
||||
print(f"Pushing: {project_path.name} (whole tree minus excludes, last commit: {local_last})")
|
||||
|
||||
shared_root = _find_shared_assets_root(project_path)
|
||||
remote_shared = f"{server['path']}/shared_assets"
|
||||
|
||||
# Pass 1: project files — whole tree, denylist excludes.
|
||||
rsync_cmd = [
|
||||
"rsync", "-av", "--progress",
|
||||
"-e", f"ssh -p {server['port']}",
|
||||
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
|
||||
f"{project_path}/",
|
||||
f"{server['user']}@{server['host']}:{remote_project}/",
|
||||
]
|
||||
# Pass 2: shared assets — whole tree, same excludes.
|
||||
rsync_shared = [
|
||||
"rsync", "-av", "--progress",
|
||||
"-e", f"ssh -p {server['port']}",
|
||||
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
|
||||
f"{shared_root}/" if shared_root else "",
|
||||
f"{server['user']}@{server['host']}:{remote_shared}/",
|
||||
]
|
||||
|
||||
if dry_run:
|
||||
dry_project = rsync_cmd[:1] + ["--dry-run"] + rsync_cmd[1:]
|
||||
print("\n[DRY RUN] Would push project:")
|
||||
print(f" {' '.join(dry_project)}")
|
||||
if shared_root:
|
||||
dry_shared = rsync_shared[:1] + ["--dry-run"] + rsync_shared[1:]
|
||||
print("\n[DRY RUN] Would push shared assets:")
|
||||
print(f" {' '.join(dry_shared)}")
|
||||
return 0
|
||||
|
||||
if verbose:
|
||||
print(f" {' '.join(rsync_cmd)}")
|
||||
|
||||
# Pass 1: project files
|
||||
subprocess.run([*ssh_cmd, f"mkdir -p {remote_project}"], check=True)
|
||||
result = subprocess.run(rsync_cmd)
|
||||
if result.returncode != 0:
|
||||
print(f"Error: rsync failed for project files (code {result.returncode})")
|
||||
return 1
|
||||
|
||||
# Pass 2: shared assets
|
||||
if shared_root:
|
||||
print("\nPushing shared assets...")
|
||||
subprocess.run([*ssh_cmd, f"mkdir -p {remote_shared}"], check=True)
|
||||
result = subprocess.run(rsync_shared)
|
||||
if result.returncode != 0:
|
||||
print(f"Error: rsync failed for shared assets (code {result.returncode})")
|
||||
return 1
|
||||
|
||||
print("\nDone.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_down(project_path: Path, verbose: bool, dry_run: bool) -> int:
|
||||
from .cache import load_server_config
|
||||
|
||||
server = load_server_config()
|
||||
if server is None:
|
||||
_print_server_error()
|
||||
return 1
|
||||
|
||||
remote_project = f"{server['path']}/{project_path.name}"
|
||||
|
||||
# Verify the project exists on the server
|
||||
ssh_cmd = ["ssh", "-p", server["port"], f"{server['user']}@{server['host']}"]
|
||||
result = subprocess.run(
|
||||
[*ssh_cmd, f"test -d {remote_project} && echo ok"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.stdout.strip() != "ok":
|
||||
print(f"Error: Project not found on server: {remote_project}")
|
||||
return 1
|
||||
|
||||
remote_log_result = subprocess.run(
|
||||
[*ssh_cmd, f"cat {remote_project}/{_COMMITS_LOG} 2>/dev/null"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
server_lines = [l for l in remote_log_result.stdout.splitlines() if l.strip()]
|
||||
server_last = _last_timestamp(server_lines)
|
||||
|
||||
print(f"Pulling: {project_path.name} (server last commit: {server_last or 'none'})")
|
||||
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rsync_cmd = [
|
||||
"rsync", "-av", "--progress",
|
||||
"-e", f"ssh -p {server['port']}",
|
||||
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
|
||||
f"{server['user']}@{server['host']}:{remote_project}/",
|
||||
f"{project_path}/",
|
||||
]
|
||||
|
||||
remote_shared = f"{server['path']}/shared_assets"
|
||||
local_shared = project_path.parent / "shared_assets"
|
||||
rsync_shared_cmd = [
|
||||
"rsync", "-av", "--progress",
|
||||
"-e", f"ssh -p {server['port']}",
|
||||
*[f"--exclude={p}" for p in _SYNC_EXCLUDES],
|
||||
f"{server['user']}@{server['host']}:{remote_shared}/",
|
||||
f"{local_shared}/",
|
||||
]
|
||||
|
||||
if dry_run:
|
||||
rsync_cmd.insert(1, "--dry-run")
|
||||
print("\n[DRY RUN] Would pull project:")
|
||||
print(f" {' '.join(rsync_cmd)}")
|
||||
print("\n[DRY RUN] Would pull shared assets:")
|
||||
print(f" {' '.join(rsync_shared_cmd)}")
|
||||
return 0
|
||||
|
||||
if verbose:
|
||||
print(f" {' '.join(rsync_cmd)}")
|
||||
|
||||
# Pass 1: project files
|
||||
result = subprocess.run(rsync_cmd)
|
||||
if result.returncode != 0:
|
||||
print(f"Error: rsync failed for project files (code {result.returncode})")
|
||||
return 1
|
||||
|
||||
# Pass 2: shared assets (only if server has any)
|
||||
check = subprocess.run(
|
||||
[*ssh_cmd, f"test -d {remote_shared} && echo ok"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if check.stdout.strip() == "ok":
|
||||
print("\nPulling shared assets...")
|
||||
local_shared.mkdir(parents=True, exist_ok=True)
|
||||
result = subprocess.run(rsync_shared_cmd)
|
||||
if result.returncode != 0:
|
||||
print(f"Error: rsync failed for shared assets (code {result.returncode})")
|
||||
return 1
|
||||
|
||||
print("\nDone.")
|
||||
return 0
|
||||
|
||||
|
||||
def _print_server_error():
|
||||
print("Error: Server not configured. Add to ~/.gnommo.conf:")
|
||||
print(" [server]")
|
||||
print(" host = 192.168.1.100")
|
||||
print(" user = username")
|
||||
print(" path = /gnommo/project")
|
||||
print(" port = 22")
|
||||
+1594
-53
File diff suppressed because it is too large
Load Diff
+286
-61
@@ -2,8 +2,16 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import resolve_with_cache
|
||||
from .errors import ValidationError, ValidationIssue
|
||||
from .models import ProjectConfig, SlideDefinition, VideoSource, SLIDE_LAYOUTS
|
||||
from .parser import _read_json, resolve_missing_videos
|
||||
from .models import (
|
||||
ProjectConfig,
|
||||
SlideDefinition,
|
||||
VideoSource,
|
||||
SLIDE_LAYOUTS,
|
||||
CAMERA_PRESETS,
|
||||
)
|
||||
|
||||
|
||||
def validate_project(
|
||||
@@ -12,10 +20,13 @@ def validate_project(
|
||||
config: ProjectConfig,
|
||||
slides: dict[str, SlideDefinition],
|
||||
videos: dict[str, VideoSource],
|
||||
videos_dir: Path,
|
||||
malformed_markers: list[tuple[int, str]] = None,
|
||||
) -> None:
|
||||
audio: dict = None,
|
||||
) -> list[ValidationIssue]:
|
||||
"""
|
||||
Validate all parsed project data. Raises ValidationError if any issues found.
|
||||
Returns a list of warnings (non-fatal issues).
|
||||
|
||||
Checks:
|
||||
- All slide markers in manuscript exist in slides.json
|
||||
@@ -24,109 +35,323 @@ def validate_project(
|
||||
- Background video exists (if specified)
|
||||
- Slide types are valid
|
||||
- No malformed markers in manuscript
|
||||
- All audio: markers in manuscript exist in audio.json
|
||||
"""
|
||||
issues: list[ValidationIssue] = []
|
||||
warnings: list[ValidationIssue] = []
|
||||
|
||||
# Collect video IDs actually referenced in the manuscript (for file-existence checks)
|
||||
_VIDEO_PREFIXES = {
|
||||
"video:": 6,
|
||||
"vft:": 4, "vfb:": 4, "vfm:": 4,
|
||||
"vf2t:": 5, "vf2b:": 5, "vf2m:": 5,
|
||||
"vst:": 4, "vsb:": 4, "vsm:": 4,
|
||||
"vftp:": 5, "vfbp:": 5, "vfmp:": 5,
|
||||
"vf2tp:": 6, "vf2bp:": 6, "vf2mp:": 6,
|
||||
"vstp:": 5, "vsbp:": 5, "vsmp:": 5,
|
||||
}
|
||||
referenced_video_ids: set[str] = set()
|
||||
for marker in manuscript_markers:
|
||||
prefix = next((p for p in _VIDEO_PREFIXES if marker.startswith(p)), None)
|
||||
if prefix is not None:
|
||||
referenced_video_ids.add(marker[_VIDEO_PREFIXES[prefix]:].lower())
|
||||
elif marker.startswith("narration:"):
|
||||
referenced_video_ids.add(marker[10:].lower())
|
||||
|
||||
# (Key-reuse across cutout/layer is legal: presentation is resolved per-occurrence
|
||||
# from the shorthand prefix now — see transformer.resolve_video_presentation — so
|
||||
# one handle can appear as vst: (above) and vsb: (below) without colliding.)
|
||||
|
||||
# Check for malformed markers first (these are likely typos)
|
||||
if malformed_markers:
|
||||
for line_num, marker_text in malformed_markers:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Malformed marker: {marker_text}",
|
||||
project_path / "manuscript.txt",
|
||||
line_num
|
||||
))
|
||||
line_num,
|
||||
)
|
||||
)
|
||||
|
||||
# Check all manuscript markers have corresponding slides
|
||||
# Check all manuscript markers have corresponding slides or videos
|
||||
for marker in manuscript_markers:
|
||||
# Skip camera effect markers (Zoom0, TiltLeft, Reset, etc.)
|
||||
if marker in CAMERA_PRESETS:
|
||||
continue
|
||||
# Skip audio markers (start with 'A' followed by audio id, e.g., Awoosh)
|
||||
if marker.startswith("A") and len(marker) > 1 and marker[1:].isalnum():
|
||||
continue
|
||||
# Skip audio: prefix markers (e.g., audio:woosh)
|
||||
if marker.startswith("audio:"):
|
||||
continue
|
||||
# Validate video trigger markers — both legacy [video:xxx] and
|
||||
# shorthand [vft:xxx] / [vfb:xxx] / [vst:xxx] / [vsb:xxx].
|
||||
matched_prefix = next(
|
||||
(p for p in _VIDEO_PREFIXES if marker.startswith(p)), None
|
||||
)
|
||||
if matched_prefix is not None:
|
||||
video_id = marker[_VIDEO_PREFIXES[matched_prefix] :].lower()
|
||||
if video_id not in videos:
|
||||
hint = ""
|
||||
if "." in video_id:
|
||||
base_name = video_id.rsplit(".", 1)[0]
|
||||
if base_name in videos:
|
||||
hint = f" (Did you mean [{matched_prefix}{base_name}]? Don't include file extensions in markers)"
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Video marker [{marker}] referenced in manuscript but '{video_id}' not defined in videos.json{hint} — using PlaceholderVideo instead",
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
else:
|
||||
vs = videos[video_id]
|
||||
if not vs.cutout or vs.cutout not in config.cutouts:
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"[{marker}] video '{video_id}' has no valid cutout in videos.json — "
|
||||
f"run 'gnommo import' to project values, or set cutout manually.",
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Validate narration trigger markers (narration:xxx) - continuous videos
|
||||
if marker.startswith("narration:"):
|
||||
video_id = marker[10:].lower() # Remove 'narration:' prefix
|
||||
if video_id not in videos:
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Narration marker [{marker}] referenced in manuscript but '{video_id}' not defined in videos.json — using PlaceholderVideo instead",
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
else:
|
||||
vs = videos[video_id]
|
||||
if not vs.cutout or vs.cutout not in config.cutouts:
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"[{marker}] video '{video_id}' has no valid cutout in videos.json — "
|
||||
f"run 'gnommo import' to project values, or set cutout manually.",
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Segment markers are structural annotations, not slide references
|
||||
if marker.startswith("segment:"):
|
||||
continue
|
||||
|
||||
# Bare narrator cues (teleprompter hints, not pipeline markers)
|
||||
if marker in ("pause", "stop"):
|
||||
continue
|
||||
|
||||
# Unknown namespaced markers (e.g. [background:xxx]) — not supported, ignore with warning
|
||||
if ":" in marker:
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Unknown marker type [{marker}] — ignoring (no support for '{marker.split(':', 1)[0]}:' markers)",
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if marker not in slides:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Slide marker [{marker}] referenced in manuscript but not defined in slides.json",
|
||||
project_path / "manuscript.txt"
|
||||
))
|
||||
project_path / "manuscript.txt",
|
||||
)
|
||||
)
|
||||
|
||||
# Check all slide images exist
|
||||
# Slides are in the same directory as the slides.json file
|
||||
slides_json_path = project_path / config.slides_path
|
||||
# Slides are in the same directory as the slides.json file.
|
||||
# Lowercase the configured path so capital-cased project names (e.g.
|
||||
# "media/slides/Video2/slides.json") resolve on case-sensitive filesystems.
|
||||
slides_json_path = project_path / config.slides_path.lower()
|
||||
slides_dir = slides_json_path.parent
|
||||
|
||||
for slide_id, slide_def in slides.items():
|
||||
image_path = slides_dir / slide_def.image
|
||||
image_path, _ = resolve_with_cache(image_path, project_path)
|
||||
if not image_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
f"Slide image not found: {slide_def.image}",
|
||||
slides_json_path
|
||||
))
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Slide image not found: {slide_def.image}", slides_json_path
|
||||
)
|
||||
)
|
||||
|
||||
# Check slide type is valid
|
||||
if slide_def.type not in SLIDE_LAYOUTS:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Unknown slide type '{slide_def.type}' for slide {slide_id}. "
|
||||
f"Valid types: {list(SLIDE_LAYOUTS.keys())}",
|
||||
project_path / "slides.json"
|
||||
))
|
||||
project_path / "slides.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check all video files exist (paths relative to videos_dir or shared_assets)
|
||||
videos_json_path = project_path / config.videos_path
|
||||
|
||||
# Find shared_assets directory
|
||||
shared_assets_dir = None
|
||||
if (project_path / "shared_assets").exists():
|
||||
shared_assets_dir = project_path / "shared_assets"
|
||||
elif (project_path.parent / "shared_assets").exists():
|
||||
shared_assets_dir = project_path.parent / "shared_assets"
|
||||
|
||||
# Check all video files exist
|
||||
for video_id, video_source in videos.items():
|
||||
video_path = project_path / video_source.file
|
||||
if not video_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
f"Video file not found: {video_source.file}",
|
||||
project_path / "videos.json"
|
||||
))
|
||||
# Only check files for videos actually used in this manuscript
|
||||
if video_id not in referenced_video_ids:
|
||||
continue
|
||||
|
||||
# Check preprocessed output exists if preprocessing is defined
|
||||
if video_source.preprocess and video_source.output_file:
|
||||
output_path = project_path / video_source.output_file
|
||||
# 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():
|
||||
sf = video_source.source_file
|
||||
hint = (
|
||||
" — run 'gnommo pexels' to download"
|
||||
if sf.startswith("pexels/")
|
||||
else " — falling back to PlaceholderVideo"
|
||||
)
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Video file not found: {sf}{hint}",
|
||||
videos_json_path,
|
||||
)
|
||||
)
|
||||
|
||||
# Check preprocessed output exists if filters are defined
|
||||
if video_source.filter and video_source.output_file:
|
||||
output_path = base_dir / video_source.output_file
|
||||
output_path, _ = resolve_with_cache(output_path, project_path)
|
||||
if not output_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Preprocessed output not found: {video_source.output_file}. "
|
||||
f"Run with -a preprocess first.",
|
||||
project_path / "videos.json"
|
||||
))
|
||||
videos_json_path,
|
||||
)
|
||||
)
|
||||
|
||||
# Check background exists (image or video)
|
||||
# Try 'background' first, fall back to deprecated 'background_video'
|
||||
bg_file = config.background or config.background_video
|
||||
if bg_file:
|
||||
# Check in project folder first, then parent (for shared_assets)
|
||||
bg_path = project_path / bg_file
|
||||
# Check background exists — must be a handle in shared_assets/videos.json
|
||||
bg_handle = config.background
|
||||
if bg_handle:
|
||||
shared_assets_dir = project_path.parent / "shared_assets"
|
||||
videos_json_path_bg = shared_assets_dir / "videos.json"
|
||||
if not videos_json_path_bg.exists():
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"shared_assets/videos.json not found (needed for background handle '{bg_handle}')",
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
else:
|
||||
bg_videos = _read_json(videos_json_path_bg)
|
||||
if bg_handle not in bg_videos:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Background handle '{bg_handle}' not found in shared_assets/videos.json",
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
else:
|
||||
bg_path = shared_assets_dir / bg_videos[bg_handle]["source_file"]
|
||||
bg_path, _ = resolve_with_cache(bg_path, project_path)
|
||||
if not bg_path.exists():
|
||||
# Try parent directory (shared_assets at repo root)
|
||||
bg_path = project_path.parent / bg_file
|
||||
if not bg_path.exists():
|
||||
issues.append(ValidationIssue(
|
||||
f"Background not found: {bg_file}",
|
||||
project_path / "project.json"
|
||||
))
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Background file not found: {bg_path} (from handle '{bg_handle}')",
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check we have at least one video source
|
||||
if not videos:
|
||||
issues.append(ValidationIssue(
|
||||
"No video sources defined in videos.json",
|
||||
project_path / "videos.json"
|
||||
))
|
||||
# Check videos.json exists (empty is fine — project may not need triggered videos)
|
||||
if not (project_path / config.videos_path).exists():
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
"videos.json not found — run 'gnommo import' to create it",
|
||||
project_path / "videos.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check resolution is reasonable
|
||||
width, height = config.resolution
|
||||
if width < 100 or height < 100:
|
||||
issues.append(ValidationIssue(
|
||||
f"Resolution too small: {width}x{height}",
|
||||
project_path / "project.json"
|
||||
))
|
||||
if width < 50 or height < 50:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Resolution too small: {width}x{height}", project_path / "project.json"
|
||||
)
|
||||
)
|
||||
|
||||
if width > 7680 or height > 4320:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Resolution too large: {width}x{height} (max 8K)",
|
||||
project_path / "project.json"
|
||||
))
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check FPS is reasonable
|
||||
if config.fps < 1 or config.fps > 120:
|
||||
issues.append(ValidationIssue(
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
f"Invalid FPS: {config.fps} (must be 1-120)",
|
||||
project_path / "project.json"
|
||||
))
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check outro videos exist in videos.json or shared_assets
|
||||
if config.outro:
|
||||
missing_outro = [vid_id for vid_id in config.outro if vid_id not in videos]
|
||||
if missing_outro:
|
||||
found = resolve_missing_videos(missing_outro, project_path, config)
|
||||
still_missing = [vid_id for vid_id in missing_outro if vid_id not in found]
|
||||
for vid_id in still_missing:
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Outro video '{vid_id}' not found in videos.json or shared_assets — will be skipped at render",
|
||||
project_path / "project.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Check all audio: markers in manuscript exist in audio.json
|
||||
if audio is not None:
|
||||
seen_audio: set[str] = set()
|
||||
manuscript_path = project_path / "manuscript.txt"
|
||||
for marker in manuscript_markers:
|
||||
audio_id = None
|
||||
if marker.startswith("audio:"):
|
||||
audio_id = marker[6:]
|
||||
elif marker.startswith("A") and len(marker) > 1 and marker[1:].isalnum():
|
||||
audio_id = marker[1:]
|
||||
if audio_id and audio_id not in seen_audio:
|
||||
seen_audio.add(audio_id)
|
||||
if audio_id not in audio:
|
||||
warnings.append(
|
||||
ValidationIssue(
|
||||
f"Audio marker [{marker}] referenced in manuscript but '{audio_id}' not defined in audio.json — will be silent at render",
|
||||
manuscript_path,
|
||||
)
|
||||
)
|
||||
|
||||
# If any issues, raise ValidationError
|
||||
if issues:
|
||||
raise ValidationError(issues)
|
||||
|
||||
return warnings
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
./gnommo.sh -p video0 grade --stage key
|
||||
./gnommo.sh -p video1 grade --stage key
|
||||
./gnommo.sh -p video2 grade --stage key
|
||||
./gnommo.sh -p video3 grade --stage key
|
||||
./gnommo.sh -p video4 grade --stage key
|
||||
./gnommo.sh -p video5 grade --stage key
|
||||
./gnommo.sh -p video6 grade --stage key
|
||||
|
||||
|
||||
./gnommo.sh -p video0 grade --pick key_5
|
||||
./gnommo.sh -p video1 grade --pick key_5
|
||||
./gnommo.sh -p video2 grade --pick key_5
|
||||
./gnommo.sh -p video3 grade --pick key_5
|
||||
./gnommo.sh -p video4 grade --pick key_5
|
||||
./gnommo.sh -p video5 grade --pick key_5
|
||||
./gnommo.sh -p video6 grade --pick key_5
|
||||
|
||||
./gnommo.sh -p video0 grade --stage grade
|
||||
./gnommo.sh -p video1 grade --stage grade
|
||||
./gnommo.sh -p video2 grade --stage grade
|
||||
./gnommo.sh -p video3 grade --stage grade
|
||||
./gnommo.sh -p video4 grade --stage grade
|
||||
./gnommo.sh -p video5 grade --stage grade
|
||||
./gnommo.sh -p video6 grade --stage grade
|
||||
|
||||
./gnommo.sh -p video0 grade --pick grade_5
|
||||
./gnommo.sh -p video1 grade --pick grade_5
|
||||
./gnommo.sh -p video2 grade --pick grade_5
|
||||
./gnommo.sh -p video3 grade --pick grade_5
|
||||
./gnommo.sh -p video4 grade --pick grade_5
|
||||
./gnommo.sh -p video5 grade --pick grade_5
|
||||
./gnommo.sh -p video6 grade --pick grade_5
|
||||
@@ -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()
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
|
||||
|
||||
./gnommo.sh -p video1 all
|
||||
./gnommo.sh -p video2 all
|
||||
./gnommo.sh -p video3 all
|
||||
./gnommo.sh -p video4 all
|
||||
./gnommo.sh -p video5 all
|
||||
./gnommo.sh -p video6 all
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
./gnommo.sh -p video1 render --force --prod
|
||||
./gnommo.sh -p video2 render --force --prod
|
||||
./gnommo.sh -p video3 render --force --prod
|
||||
./gnommo.sh -p video4 render --force --prod
|
||||
./gnommo.sh -p video5 render --force --prod
|
||||
#./gnommo.sh -p video6 render --force --prod
|
||||
|
||||
@@ -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": "-10%",
|
||||
"y": "40%",
|
||||
"height": "80%"
|
||||
},
|
||||
"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,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan-level validation for chunking v2 (docs/chunking_v2.md).
|
||||
|
||||
Verifies the transformer now INCLUDES clips that span a chunk boundary and seeks
|
||||
into them (skip_override / src_offset), instead of the v1 behaviour that dropped
|
||||
them. This is a pure plan-level check — the ffmpeg concat-seam (frame alignment via
|
||||
-c copy) still needs a real render on the rig to confirm.
|
||||
|
||||
Run: ./venv/bin/python tests/test_chunking_v2.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from gnommo.transformer import (
|
||||
MarkerTiming,
|
||||
_extract_audio_events,
|
||||
_extract_video_events,
|
||||
AUDIO_OFFSET_SECONDS,
|
||||
)
|
||||
from gnommo.models import AudioDefinition, VideoSource, CutoutDefinition, SlideDefinition
|
||||
|
||||
_fails = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
print(f" {'PASS' if cond else 'FAIL'} {name}" + (f" — {detail}" if detail and not cond else ""))
|
||||
if not cond:
|
||||
_fails.append(name)
|
||||
|
||||
|
||||
# ── audio ────────────────────────────────────────────────────────────────────
|
||||
def test_audio():
|
||||
print("audio events:")
|
||||
audio = {
|
||||
"music": AudioDefinition(file="music.mp3", loop=True, duration=90.0),
|
||||
"sfx": AudioDefinition(file="sfx.wav", loop=False, duration=500.0),
|
||||
"blip": AudioDefinition(file="blip.wav", loop=False, duration=50.0),
|
||||
}
|
||||
# music triggers at t=0, sfx at t=10, blip at t=10
|
||||
markers = [
|
||||
MarkerTiming(marker_id="Amusic", timestamp=0.0, context="", confidence=1.0),
|
||||
MarkerTiming(marker_id="Asfx", timestamp=10.0, context="", confidence=1.0),
|
||||
MarkerTiming(marker_id="Ablip", timestamp=10.0, context="", confidence=1.0),
|
||||
]
|
||||
|
||||
# Chunk window [300, 600): all three started earlier.
|
||||
evs = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=(300.0, 600.0))}
|
||||
|
||||
# Looping music: included, resumes at loop phase. astart = max(0, 0-1)=0; into=300;
|
||||
# phase = 300 % 90 = 30.
|
||||
check("looping music spanning boundary is INCLUDED (v1 dropped it)", "music" in evs)
|
||||
if "music" in evs:
|
||||
m = evs["music"]
|
||||
check("music clamped to window start", abs(m.start_time - 300.0) < 1e-6, f"start={m.start_time}")
|
||||
check("music seeks to loop phase 30.0", abs(m.src_offset - 30.0) < 1e-6, f"src_offset={m.src_offset}")
|
||||
|
||||
# One-shot still playing at the window: included, linear seek.
|
||||
# astart = max(0,10-1)=9; aend=9+500=509 > 300 → spans. into=300-9=291.
|
||||
check("one-shot still playing is INCLUDED", "sfx" in evs)
|
||||
if "sfx" in evs:
|
||||
s = evs["sfx"]
|
||||
check("sfx linear seek 291.0", abs(s.src_offset - 291.0) < 1e-6, f"src_offset={s.src_offset}")
|
||||
|
||||
# One-shot that ended before the window: excluded (aend=9+50=59 < 300).
|
||||
check("one-shot ended before window is EXCLUDED", "blip" not in evs)
|
||||
|
||||
# Full render (no range): everything from the start, no seek.
|
||||
full = {e.audio_id: e for e in _extract_audio_events(markers, audio, time_range=None)}
|
||||
check("full render includes music with no seek", "music" in full and full["music"].src_offset == 0.0)
|
||||
|
||||
|
||||
# ── video ────────────────────────────────────────────────────────────────────
|
||||
def test_video():
|
||||
print("video events:")
|
||||
cutouts = {"fullscreen": CutoutDefinition(x=0, y=0, height=1080, width=1920)}
|
||||
videos = {
|
||||
"bg": VideoSource(
|
||||
source_file="bg.mp4", cutout="fullscreen", layer="below",
|
||||
duration=90.0, skip=0.0, end_on="next_video",
|
||||
)
|
||||
}
|
||||
slides = {f"S{i}": SlideDefinition(image=f"S{i}.png", type="slide") for i in range(1, 11)}
|
||||
markers = [MarkerTiming(marker_id=f"S{i}", timestamp=(i - 1) * 60.0, context="", confidence=1.0)
|
||||
for i in range(1, 11)]
|
||||
# Background overlay starts at slide-7 time (360) and, as the only video with
|
||||
# end_on next_video, runs to total_duration (600).
|
||||
markers.append(MarkerTiming(marker_id="vfm:bg", timestamp=360.0, context="", confidence=1.0))
|
||||
|
||||
total = 600.0
|
||||
# Chunk window that STARTS AFTER the video began: [420, 600) (slides 8-10).
|
||||
evs, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(420.0, 600.0))
|
||||
bg = next((e for e in evs if e.video_id == "bg"), None)
|
||||
|
||||
check("spanning background video is INCLUDED in the later chunk (v1 dropped it)", bg is not None)
|
||||
if bg is not None:
|
||||
check("bg clamped to window start", abs(bg.start_time - 420.0) < 1e-6, f"start={bg.start_time}")
|
||||
# into = 420-360 = 60; playable = 90-0 = 90; 60 < 90 → linear seek 60.
|
||||
check("bg linear seek 60.0 (first play-through)", abs((bg.skip_override or 0) - 60.0) < 1e-6,
|
||||
f"skip_override={bg.skip_override}")
|
||||
|
||||
# Window starting deep enough that the 90s clip has looped once: [480, 600).
|
||||
# into = 480-360 = 120; 120 >= 90 → phase = 120 % 90 = 30.
|
||||
evs2, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=(480.0, 600.0))
|
||||
bg2 = next((e for e in evs2 if e.video_id == "bg"), None)
|
||||
check("looped background resumes at phase 30.0", bg2 is not None and abs((bg2.skip_override or 0) - 30.0) < 1e-6,
|
||||
f"skip_override={getattr(bg2, 'skip_override', None)}")
|
||||
|
||||
# Full render: no seek.
|
||||
evs3, _ = _extract_video_events(markers, videos, cutouts, slides, total, time_range=None)
|
||||
bg3 = next((e for e in evs3 if e.video_id == "bg"), None)
|
||||
check("full render includes bg with no seek", bg3 is not None and bg3.skip_override is None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_audio()
|
||||
test_video()
|
||||
print()
|
||||
if _fails:
|
||||
print(f"FAILED: {len(_fails)} check(s): {', '.join(_fails)}")
|
||||
sys.exit(1)
|
||||
print("All chunking-v2 plan-level checks passed.")
|
||||
@@ -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 "========================================"
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
./gnommo.sh -p video0 import
|
||||
./gnommo.sh -p video1 import
|
||||
./gnommo.sh -p video2 import
|
||||
./gnommo.sh -p video3 import
|
||||
./gnommo.sh -p video4 import
|
||||
./gnommo.sh -p video5 import
|
||||
./gnommo.sh -p video6 import
|
||||
|
||||
./gnommo.sh -p video0 prune
|
||||
./gnommo.sh -p video1 prune
|
||||
./gnommo.sh -p video2 prune
|
||||
./gnommo.sh -p video3 prune
|
||||
./gnommo.sh -p video4 prune
|
||||
./gnommo.sh -p video5 prune
|
||||
./gnommo.sh -p video6 prune
|
||||
|
||||
|
||||
./gnommo.sh -p video0 up
|
||||
./gnommo.sh -p video1 up
|
||||
./gnommo.sh -p video2 up
|
||||
./gnommo.sh -p video3 up
|
||||
./gnommo.sh -p video4 up
|
||||
./gnommo.sh -p video5 up
|
||||
./gnommo.sh -p video6 up
|
||||
|
||||
Reference in New Issue
Block a user