diff --git a/GlitchTrailer/manuscript.txt b/GlitchTrailer/manuscript.txt new file mode 100644 index 0000000..9129741 --- /dev/null +++ b/GlitchTrailer/manuscript.txt @@ -0,0 +1,31 @@ +[S1] +What if the universe isn’t continuous? +What if it only looks smooth… because we’ve never zoomed in the right way? + +[S2] +At Glitch University, our first public course asks a strange question: +Is the universe fundamentally pixelated? +Blocky? +Like Minecraft - just with absurdly tiny blocks? + +[S3] +This question has been around forever. +But it's always been filed under "too weird to bother." +That's about to change. + +[S4] +Explore the tech-tree. +Level up. +Run experiments on real data from space. +We're committed to scientific rigour. +Falsifiability. Truth-seeking. +And not being a complete bore. + + +[S5] +Don’t enroll now. +Enroll later. + +[S6] +Glitch University +Later is now. diff --git a/GlitchTrailer/media/slides/GlitchTrailer/slides.json b/GlitchTrailer/media/slides/GlitchTrailer/slides.json new file mode 100644 index 0000000..f353e05 --- /dev/null +++ b/GlitchTrailer/media/slides/GlitchTrailer/slides.json @@ -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" + } +} \ No newline at end of file diff --git a/GlitchTrailer/project.json b/GlitchTrailer/project.json new file mode 100644 index 0000000..eecabfd --- /dev/null +++ b/GlitchTrailer/project.json @@ -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" +} diff --git a/example/project.json b/example/project.json index f3bd989..eeb35be 100644 --- a/example/project.json +++ b/example/project.json @@ -9,6 +9,7 @@ "defaultSlideType": "fullscreen", "keynote_file": "media/example.key", "transcript": "media/videos/talking_head.transcript.json", + "narration": "media/narration/narration.json", "background": "shared_assets/solarpunk.png", "videos": "media/videos/videos.json", "slides": "media/slides/Example/slides.json", @@ -71,7 +72,6 @@ } ] }, - "main_video": ["talking_head_S1", "talking_head_S3"], "cutouts": { "talkinghead": { "x": "-10%", diff --git a/gnommo/handoff.py b/gnommo/handoff.py index 20c0ec6..07e148a 100644 --- a/gnommo/handoff.py +++ b/gnommo/handoff.py @@ -33,7 +33,11 @@ except ImportError: print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr) 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(): @@ -52,16 +56,16 @@ def _load_env_file(): os.environ[key] = value -def _read_sync(project_path: Path) -> dict: - sync_file = project_path / SYNC_FILE +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): - with open(project_path / SYNC_FILE, "w") as f: +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) @@ -127,16 +131,16 @@ def cmd_handoff(project_path: Path, verbose: bool = False, file_override: str | headers={"Authorization": f"Bearer {api_key}"}, timeout=None, # large files may take a while ) - r.raise_for_status() except requests.exceptions.ConnectionError: - print(f"Error: Could not connect to {api_url}", file=sys.stderr) + print(f"✗ Could not connect to {api_url}") 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: - print(f" {e.response.json()}", file=sys.stderr) + body = r.json() except Exception: - pass + body = r.text[:500] + print(f"✗ Server returned {r.status_code}: {body}") return 1 result = r.json() @@ -145,13 +149,13 @@ def cmd_handoff(project_path: Path, verbose: bool = False, file_override: str | # ── Write sync state ─────────────────────────────────────────────────────── 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, { **existing_sync, "last_handoff_at": now_iso, "video_version": video_version, "server_updated_at": result.get("asset", {}).get("updated_at", existing_sync.get("server_updated_at")), - }) + }, prod) print(f"✓ {project_id} → v{video_version} [processed]") if video_url: diff --git a/gnommo/preprocessor.py b/gnommo/preprocessor.py index 282e185..3fa4437 100644 --- a/gnommo/preprocessor.py +++ b/gnommo/preprocessor.py @@ -1025,6 +1025,38 @@ def apply_combined_video_filters_chunked( 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 try: scratch_dir.rmdir() diff --git a/gnommo/pull.py b/gnommo/pull.py index 2cfbbb8..9f713b7 100644 --- a/gnommo/pull.py +++ b/gnommo/pull.py @@ -32,7 +32,11 @@ except ImportError: print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr) 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(): @@ -51,16 +55,16 @@ def _load_env_file(): os.environ[key] = value -def _read_sync(project_path: Path) -> dict: - sync_file = project_path / SYNC_FILE +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): - with open(project_path / SYNC_FILE, "w") as f: +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) @@ -106,7 +110,7 @@ def cmd_pull(project_path: Path, verbose: bool = False, force: bool = False, pro # ── Conflict check ──────────────────────────────────────────────────────── if not force: - sync = _read_sync(project_path) + 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 @@ -134,15 +138,19 @@ def cmd_pull(project_path: Path, verbose: bool = False, force: bool = False, pro headers={"Authorization": f"Bearer {api_key}"}, timeout=30, ) - r.raise_for_status() except requests.exceptions.ConnectionError: - print(f"Error: Could not connect to {api_url}", file=sys.stderr) + print(f"✗ Could not connect to {api_url}") return 1 - except requests.exceptions.HTTPError as e: - if e.response.status_code == 404: - print(f"Error: Project '{project_id}' not found on server. Push it first.", file=sys.stderr) + + if not r.ok: + if r.status_code == 404: + print(f"✗ Project '{project_id}' not found on server. Push it first.") 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 server = r.json() @@ -167,13 +175,13 @@ def cmd_pull(project_path: Path, verbose: bool = False, force: bool = False, pro f.write("\n") 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, { **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 diff --git a/gnommo/push.py b/gnommo/push.py index 458379a..eed377a 100644 --- a/gnommo/push.py +++ b/gnommo/push.py @@ -45,7 +45,11 @@ except ImportError: print("Error: 'requests' package is required. Run: pip install requests", file=sys.stderr) 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(): @@ -64,16 +68,16 @@ def _load_env_file(): os.environ[key] = value -def _read_sync(project_path: Path) -> dict: - sync_file = project_path / SYNC_FILE +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): - with open(project_path / SYNC_FILE, "w") as f: +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) @@ -120,34 +124,6 @@ def cmd_push(project_path: Path, verbose: bool = False, force: bool = False, pro parent_project = project.get("parent_project") - # ── Conflict check ──────────────────────────────────────────────────────── - if not force: - sync = _read_sync(project_path) - recorded_server_ts = _parse_ts(sync.get("server_updated_at")) - if recorded_server_ts: - try: - r_check = requests.get( - f"{api_url}/api/projects/{project_id}", - headers={"Authorization": f"Bearer {api_key}"}, - timeout=10, - ) - if r_check.status_code == 200: - current_server_ts = _parse_ts(r_check.json().get("updated_at")) - if current_server_ts and current_server_ts > recorded_server_ts: - print( - f"⚠ Server has changes since your last sync " - f"({current_server_ts.strftime('%Y-%m-%d %H:%M')} > " - f"{recorded_server_ts.strftime('%Y-%m-%d %H:%M')}).", - file=sys.stderr, - ) - print( - " Pull first with `gnommo pull -p` or use `gnommo push -p --force`.", - file=sys.stderr, - ) - return 1 - except requests.exceptions.ConnectionError: - pass - # ── Build payload ───────────────────────────────────────────────────────── if parent_project: payload = _build_short_payload(project, project_path, verbose) @@ -166,16 +142,16 @@ def cmd_push(project_path: Path, verbose: bool = False, force: bool = False, pro headers={"Authorization": f"Bearer {api_key}"}, timeout=30, ) - r.raise_for_status() except requests.exceptions.ConnectionError: - print(f"Error: Could not connect to {api_url}", file=sys.stderr) + print(f"✗ Could not connect to {api_url}") 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: - print(f" {e.response.json()}", file=sys.stderr) + body = r.json() except Exception: - pass + body = r.text[:500] + print(f"✗ Server returned {r.status_code}: {body}") return 1 result = r.json() @@ -183,12 +159,12 @@ def cmd_push(project_path: Path, verbose: bool = False, force: bool = False, pro # ── Write sync state ────────────────────────────────────────────────────── 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, { **existing_sync, "last_pushed_at": now_iso, "server_updated_at": server_updated_at, - }) + }, prod) # ── Print summary ───────────────────────────────────────────────────────── 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") 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 @@ -209,19 +188,32 @@ def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> d if manuscript_str: manuscript_path = project_path / manuscript_str if manuscript_path.exists(): - script_content = manuscript_path.read_text() - if verbose: - print(f" Read manuscript: {manuscript_path} ({len(script_content)} chars)") + 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 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 { - "project_id": project["id"], - "name": project["name"], - "description": project.get("description"), - "coursecode": project.get("coursecode"), - "script_content": script_content, - "shorts": project.get("shorts", []), # list of slugs, not objects + "project_id": project["id"], + "name": project["name"], + "description": project.get("description"), + "coursecode": project.get("coursecode"), + "script_content": script_content, + "resolution": project.get("resolution"), + "fps": project.get("fps"), + "duration_seconds": project.get("duration_seconds"), + "hook": project.get("hook"), + "platform_targets": project.get("platform_targets"), + "status": project.get("status"), + "youtube_url": project.get("youtube_url"), + "shorts": project.get("shorts", []), }