Adding push and pull commands

This commit is contained in:
2026-03-14 12:28:52 +01:00
parent b21ca6b394
commit b6bc5a0463
8 changed files with 277 additions and 79 deletions
+31
View File
@@ -0,0 +1,31 @@
[S1]
What if the universe isnt continuous?
What if it only looks smooth… because weve never zoomed in the right way?
[S2]
At Glitch University, our first public course asks a strange question:
Is the universe fundamentally pixelated?
Blocky?
Like Minecraft - just with absurdly tiny blocks?
[S3]
This question has been around forever.
But it's always been filed under "too weird to bother."
That's about to change.
[S4]
Explore the tech-tree.
Level up.
Run experiments on real data from space.
We're committed to scientific rigour.
Falsifiability. Truth-seeking.
And not being a complete bore.
[S5]
Dont enroll now.
Enroll later.
[S6]
Glitch University
Later is now.
@@ -0,0 +1,26 @@
{
"S1": {
"image": "GlitchTrailer.001.png",
"type": "fullscreen"
},
"S2": {
"image": "GlitchTrailer.002.png",
"type": "fullscreen"
},
"S3": {
"image": "GlitchTrailer.003.png",
"type": "fullscreen"
},
"S4": {
"image": "GlitchTrailer.004.png",
"type": "fullscreen"
},
"S5": {
"image": "GlitchTrailer.005.png",
"type": "fullscreen"
},
"S6": {
"image": "GlitchTrailer.006.png",
"type": "fullscreen"
}
}
+105
View File
@@ -0,0 +1,105 @@
{
"id": "GlitchTrailer",
"coursecode": "TRAILER",
"name": "Glitch University trailer",
"description": "Welcome to Glitch University.",
"hook": null,
"platform_targets": ["youtube"],
"status": "scripted",
"youtube_url": null,
"resolution": [1960, 1080],
"fps": 30,
"duration_seconds": null,
"default_filters": {
"audioonly": [
{
"type": "audio_normalize",
"normalize": true,
"target_lufs": -14,
"target_lra": 11,
"target_tp": -1.5
}
],
"talkinghead": [
{
"type": "audio_normalize",
"highpass": 100,
"room_eq": true,
"room_eq_freq": 300,
"room_eq_gain": -4,
"room_eq_width": 1.5,
"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": 4,
"attack": 5,
"release": 50,
"makeup": 2,
"normalize": true,
"target_lufs": -16,
"target_lra": 11,
"target_tp": -1.5
},
{
"type": "color_grade",
"saturation": 1.15,
"contrast": 1.05,
"bm": -0.1,
"rm": 0.04
},
{
"type": "gnommokey",
"screen_color": [
81,
137,
65
],
"screen_gain": 175,
"screen_balance": 58,
"despill_bias": [
217,
240,
255
],
"despill_strength": 5.0,
"edge_erode": 1.0,
"clip_black": 0,
"clip_white": 100
},
{
"type": "mask",
"left": 0.05,
"right": 0.1,
"top": 0.1,
"bottom": 0.0
}
]
},
"cutouts": {
"talkinghead": {
"x": "-23%",
"y": "10%",
"height": "90%"
},
"square": {
"x": "46.5%",
"y": "4.5%",
"width": "50%",
"height": "90%"
},
"fullscreen": {
"x": "0%",
"y": "0%",
"height": "100%"
}
},
"manuscript": "manuscript.txt",
"shorts": [],
"output_video": "out/final.mp4"
}
+1 -1
View File
@@ -9,6 +9,7 @@
"defaultSlideType": "fullscreen", "defaultSlideType": "fullscreen",
"keynote_file": "media/example.key", "keynote_file": "media/example.key",
"transcript": "media/videos/talking_head.transcript.json", "transcript": "media/videos/talking_head.transcript.json",
"narration": "media/narration/narration.json",
"background": "shared_assets/solarpunk.png", "background": "shared_assets/solarpunk.png",
"videos": "media/videos/videos.json", "videos": "media/videos/videos.json",
"slides": "media/slides/Example/slides.json", "slides": "media/slides/Example/slides.json",
@@ -71,7 +72,6 @@
} }
] ]
}, },
"main_video": ["talking_head_S1", "talking_head_S3"],
"cutouts": { "cutouts": {
"talkinghead": { "talkinghead": {
"x": "-10%", "x": "-10%",
+17 -13
View File
@@ -33,7 +33,11 @@ except ImportError:
print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr) print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr)
sys.exit(1) sys.exit(1)
SYNC_FILE = ".gnommo_sync.json" 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(): def _load_env_file():
@@ -52,16 +56,16 @@ def _load_env_file():
os.environ[key] = value os.environ[key] = value
def _read_sync(project_path: Path) -> dict: def _read_sync(project_path: Path, prod: bool = False) -> dict:
sync_file = project_path / SYNC_FILE sync_file = project_path / _sync_file(prod)
if sync_file.exists(): if sync_file.exists():
with open(sync_file) as f: with open(sync_file) as f:
return json.load(f) return json.load(f)
return {} return {}
def _write_sync(project_path: Path, data: dict): def _write_sync(project_path: Path, data: dict, prod: bool = False):
with open(project_path / SYNC_FILE, "w") as f: with open(project_path / _sync_file(prod), "w") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@@ -127,16 +131,16 @@ def cmd_handoff(project_path: Path, verbose: bool = False, file_override: str |
headers={"Authorization": f"Bearer {api_key}"}, headers={"Authorization": f"Bearer {api_key}"},
timeout=None, # large files may take a while timeout=None, # large files may take a while
) )
r.raise_for_status()
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
print(f"Error: Could not connect to {api_url}", file=sys.stderr) print(f" Could not connect to {api_url}")
return 1 return 1
except requests.exceptions.HTTPError as e:
print(f"Error: Server returned {e.response.status_code}", file=sys.stderr) if not r.ok:
try: try:
print(f" {e.response.json()}", file=sys.stderr) body = r.json()
except Exception: except Exception:
pass body = r.text[:500]
print(f"✗ Server returned {r.status_code}: {body}")
return 1 return 1
result = r.json() result = r.json()
@@ -145,13 +149,13 @@ def cmd_handoff(project_path: Path, verbose: bool = False, file_override: str |
# ── Write sync state ─────────────────────────────────────────────────────── # ── Write sync state ───────────────────────────────────────────────────────
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds") now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
existing_sync = _read_sync(project_path) existing_sync = _read_sync(project_path, prod)
_write_sync(project_path, { _write_sync(project_path, {
**existing_sync, **existing_sync,
"last_handoff_at": now_iso, "last_handoff_at": now_iso,
"video_version": video_version, "video_version": video_version,
"server_updated_at": result.get("asset", {}).get("updated_at", existing_sync.get("server_updated_at")), "server_updated_at": result.get("asset", {}).get("updated_at", existing_sync.get("server_updated_at")),
}) }, prod)
print(f"{project_id} → v{video_version} [processed]") print(f"{project_id} → v{video_version} [processed]")
if video_url: if video_url:
+32
View File
@@ -1025,6 +1025,38 @@ def apply_combined_video_filters_chunked(
f" Completed chunk {i+1}/{len(chunk_tasks)} ({completed}/{len(chunk_tasks)} done)" f" Completed chunk {i+1}/{len(chunk_tasks)} ({completed}/{len(chunk_tasks)} done)"
) )
# Concatenate chunks into final output
concat_list = scratch_dir / "concat.txt"
with open(concat_list, "w") as cf:
for chunk_path in chunk_files:
cf.write(f"file '{chunk_path.resolve()}'\n")
if verbose:
print(f" Concatenating {len(chunk_files)} chunks → {output_path.name}")
concat_cmd = [
"ffmpeg", "-y",
"-f", "concat",
"-safe", "0",
"-i", str(concat_list),
"-c", "copy",
str(output_path),
]
concat_result = run_ffmpeg_with_progress(concat_cmd, duration, "Concatenating")
if concat_result.returncode != 0:
raise PreprocessError(
"Chunk concatenation failed",
filter_type="concat",
command=" ".join(concat_cmd),
stderr=concat_result.stderr,
)
# Clean up chunk files and concat list
for chunk_path in chunk_files:
if chunk_path.exists():
chunk_path.unlink()
concat_list.unlink(missing_ok=True)
# Remove chunks directory if empty # Remove chunks directory if empty
try: try:
scratch_dir.rmdir() scratch_dir.rmdir()
+22 -14
View File
@@ -32,7 +32,11 @@ except ImportError:
print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr) print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr)
sys.exit(1) sys.exit(1)
SYNC_FILE = ".gnommo_sync.json" 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(): def _load_env_file():
@@ -51,16 +55,16 @@ def _load_env_file():
os.environ[key] = value os.environ[key] = value
def _read_sync(project_path: Path) -> dict: def _read_sync(project_path: Path, prod: bool = False) -> dict:
sync_file = project_path / SYNC_FILE sync_file = project_path / _sync_file(prod)
if sync_file.exists(): if sync_file.exists():
with open(sync_file) as f: with open(sync_file) as f:
return json.load(f) return json.load(f)
return {} return {}
def _write_sync(project_path: Path, data: dict): def _write_sync(project_path: Path, data: dict, prod: bool = False):
with open(project_path / SYNC_FILE, "w") as f: with open(project_path / _sync_file(prod), "w") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@@ -106,7 +110,7 @@ def cmd_pull(project_path: Path, verbose: bool = False, force: bool = False, pro
# ── Conflict check ──────────────────────────────────────────────────────── # ── Conflict check ────────────────────────────────────────────────────────
if not force: if not force:
sync = _read_sync(project_path) sync = _read_sync(project_path, prod)
last_pushed_at = _parse_ts(sync.get("last_pushed_at")) last_pushed_at = _parse_ts(sync.get("last_pushed_at"))
local_mtime = datetime.fromtimestamp( local_mtime = datetime.fromtimestamp(
project_file.stat().st_mtime, tz=timezone.utc project_file.stat().st_mtime, tz=timezone.utc
@@ -134,15 +138,19 @@ def cmd_pull(project_path: Path, verbose: bool = False, force: bool = False, pro
headers={"Authorization": f"Bearer {api_key}"}, headers={"Authorization": f"Bearer {api_key}"},
timeout=30, timeout=30,
) )
r.raise_for_status()
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
print(f"Error: Could not connect to {api_url}", file=sys.stderr) print(f" Could not connect to {api_url}")
return 1 return 1
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404: if not r.ok:
print(f"Error: Project '{project_id}' not found on server. Push it first.", file=sys.stderr) if r.status_code == 404:
print(f"✗ Project '{project_id}' not found on server. Push it first.")
else: else:
print(f"Error: Server returned {e.response.status_code}", file=sys.stderr) try:
body = r.json()
except Exception:
body = r.text[:500]
print(f"✗ Server returned {r.status_code}: {body}")
return 1 return 1
server = r.json() server = r.json()
@@ -167,13 +175,13 @@ def cmd_pull(project_path: Path, verbose: bool = False, force: bool = False, pro
f.write("\n") f.write("\n")
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds") now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
existing_sync = _read_sync(project_path) existing_sync = _read_sync(project_path, prod)
_write_sync(project_path, { _write_sync(project_path, {
**existing_sync, **existing_sync,
"last_pulled_at": now_iso, "last_pulled_at": now_iso,
"server_updated_at": server_updated_at, "server_updated_at": server_updated_at,
"last_pushed_at": existing_sync.get("last_pushed_at"), "last_pushed_at": existing_sync.get("last_pushed_at"),
}) }, prod)
return 0 return 0
+43 -51
View File
@@ -45,7 +45,11 @@ except ImportError:
print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr) print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr)
sys.exit(1) sys.exit(1)
SYNC_FILE = ".gnommo_sync.json" 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(): def _load_env_file():
@@ -64,16 +68,16 @@ def _load_env_file():
os.environ[key] = value os.environ[key] = value
def _read_sync(project_path: Path) -> dict: def _read_sync(project_path: Path, prod: bool = False) -> dict:
sync_file = project_path / SYNC_FILE sync_file = project_path / _sync_file(prod)
if sync_file.exists(): if sync_file.exists():
with open(sync_file) as f: with open(sync_file) as f:
return json.load(f) return json.load(f)
return {} return {}
def _write_sync(project_path: Path, data: dict): def _write_sync(project_path: Path, data: dict, prod: bool = False):
with open(project_path / SYNC_FILE, "w") as f: with open(project_path / _sync_file(prod), "w") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@@ -120,34 +124,6 @@ def cmd_push(project_path: Path, verbose: bool = False, force: bool = False, pro
parent_project = project.get("parent_project") parent_project = project.get("parent_project")
# ── Conflict check ────────────────────────────────────────────────────────
if not force:
sync = _read_sync(project_path)
recorded_server_ts = _parse_ts(sync.get("server_updated_at"))
if recorded_server_ts:
try:
r_check = requests.get(
f"{api_url}/api/projects/{project_id}",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if r_check.status_code == 200:
current_server_ts = _parse_ts(r_check.json().get("updated_at"))
if current_server_ts and current_server_ts > recorded_server_ts:
print(
f"⚠ Server has changes since your last sync "
f"({current_server_ts.strftime('%Y-%m-%d %H:%M')} > "
f"{recorded_server_ts.strftime('%Y-%m-%d %H:%M')}).",
file=sys.stderr,
)
print(
" Pull first with `gnommo pull -p` or use `gnommo push -p --force`.",
file=sys.stderr,
)
return 1
except requests.exceptions.ConnectionError:
pass
# ── Build payload ───────────────────────────────────────────────────────── # ── Build payload ─────────────────────────────────────────────────────────
if parent_project: if parent_project:
payload = _build_short_payload(project, project_path, verbose) payload = _build_short_payload(project, project_path, verbose)
@@ -166,16 +142,16 @@ def cmd_push(project_path: Path, verbose: bool = False, force: bool = False, pro
headers={"Authorization": f"Bearer {api_key}"}, headers={"Authorization": f"Bearer {api_key}"},
timeout=30, timeout=30,
) )
r.raise_for_status()
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
print(f"Error: Could not connect to {api_url}", file=sys.stderr) print(f" Could not connect to {api_url}")
return 1 return 1
except requests.exceptions.HTTPError as e:
print(f"Error: Server returned {e.response.status_code}", file=sys.stderr) if not r.ok:
try: try:
print(f" {e.response.json()}", file=sys.stderr) body = r.json()
except Exception: except Exception:
pass body = r.text[:500]
print(f"✗ Server returned {r.status_code}: {body}")
return 1 return 1
result = r.json() result = r.json()
@@ -183,12 +159,12 @@ def cmd_push(project_path: Path, verbose: bool = False, force: bool = False, pro
# ── Write sync state ────────────────────────────────────────────────────── # ── Write sync state ──────────────────────────────────────────────────────
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds") now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
existing_sync = _read_sync(project_path) existing_sync = _read_sync(project_path, prod)
_write_sync(project_path, { _write_sync(project_path, {
**existing_sync, **existing_sync,
"last_pushed_at": now_iso, "last_pushed_at": now_iso,
"server_updated_at": server_updated_at, "server_updated_at": server_updated_at,
}) }, prod)
# ── Print summary ───────────────────────────────────────────────────────── # ── Print summary ─────────────────────────────────────────────────────────
asset = result.get("asset", {}) asset = result.get("asset", {})
@@ -198,6 +174,9 @@ def cmd_push(project_path: Path, verbose: bool = False, force: bool = False, pro
print(f" task #{result['task_id']} created") print(f" task #{result['task_id']} created")
else: else:
print(f"{project_id} → gn_asset #{asset.get('id')} ({asset.get('name')})") 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 return 0
@@ -209,19 +188,32 @@ def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> d
if manuscript_str: if manuscript_str:
manuscript_path = project_path / manuscript_str manuscript_path = project_path / manuscript_str
if manuscript_path.exists(): if manuscript_path.exists():
script_content = manuscript_path.read_text() try:
if verbose: script_content = manuscript_path.read_text(encoding="utf-8")
print(f" Read manuscript: {manuscript_path} ({len(script_content)} chars)") 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: else:
print(f" Warning: manuscript file not found: {manuscript_path}", file=sys.stderr) print(f" Warning: manuscript not found: {manuscript_path}")
else:
if verbose:
print(f" no manuscript field in project.json")
return { return {
"project_id": project["id"], "project_id": project["id"],
"name": project["name"], "name": project["name"],
"description": project.get("description"), "description": project.get("description"),
"coursecode": project.get("coursecode"), "coursecode": project.get("coursecode"),
"script_content": script_content, "script_content": script_content,
"shorts": project.get("shorts", []), # list of slugs, not objects "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", []),
} }