diff --git a/gnommo/cli.py b/gnommo/cli.py index cb44f99..90b82ea 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -65,6 +65,16 @@ Examples: gnommo -p video1 up Push manifest files to rendering server gnommo -p video1 up --dry-run Preview which files would be pushed gnommo -p video1 down Pull files from rendering server to local + gnommo -p video1 push Push project metadata to the local gnommoweb server + gnommo -p video1 push --prod Push project metadata to production gnommoweb (glitch.university) + gnommo -p video1 push --force Force push, overwriting the server copy + gnommo -p video1 pull Pull (fetch) project metadata from the local server + gnommo -p video1 pull --prod Pull project metadata from production + gnommo -p video1 pull --force Force pull, overwriting the local copy + gnommo -p video1 handoff --prod Upload the rendered video for online review (glitch.university/review/) + gnommo -p video1 handoff Upload the rendered video to the local server + gnommo -p video1 handoff --file X Upload a specific video file instead of out/ + Note: 'push' sends metadata (script/slides/etc); 'handoff' uploads the actual video file. gnommo -p video1 extract-audio --combined Extract audio from narration_combined.mov gnommo -p video1 extract-audio --combined --channel left Extract left channel only gnommo -p video1 extract-audio --segment seg01 Extract from a specific segment @@ -4131,21 +4141,34 @@ def cmd_render( audio, audio_dir = parse_audio(project_path, config) # Load whisper transcription JSON - # Check for narration_combined in videos.json (new workflow) or multi-segment in config (legacy) + # Resolve the combined narration skeleton. The .mov file — not the videos.json + # entry — is the source of truth: stitch run without the external drive leaves + # the file in media/videos/ even when the videos.json entry is absent (e.g. a + # metadata pull overwrote it). Legacy multi-segment projects are handled below. combined_path = videos_dir / "narration_combined.mov" resolved_combined = _resolve_narration_combined(project_path, videos_dir, config) - if resolved_combined and resolved_combined != combined_path: - # File lives on external disk — point the VideoSource at the absolute path so - # the renderer doesn't re-resolve it via the local (missing) videos_dir. - if "narration_combined" in videos: + narration_json = project_path / "media" / "narration" / "narration.json" + _narr_segments = _read_json(narration_json) if narration_json.exists() else {} + if resolved_combined and resolved_combined.exists(): + # File is available (locally or via process cache). Ensure a videos.json + # entry exists — synthesizing one when stitch's entry was lost — then use it. + if "narration_combined" not in videos: + from .models import VideoSource + + _first_seg = next(iter(_narr_segments.values()), {}) + _seg_cutout = ( + _first_seg.get("cutout") if isinstance(_first_seg, dict) else None + ) + videos["narration_combined"] = VideoSource( + source_file="narration_combined.mov", + cutout=_seg_cutout or "talkinghead", + always_visible=True, + volume=1.0, + ) + if resolved_combined != combined_path: + # File lives on external disk — point the VideoSource at the absolute + # path so the renderer doesn't re-resolve it via the local videos_dir. videos["narration_combined"].source_file = str(resolved_combined) - if ( - "narration_combined" in videos - and resolved_combined - and resolved_combined.exists() - ): - # New workflow: narration_combined was created by 'gnommo concat' and is in videos.json - # This entry has the correct volume setting from videos.json transcript_path = resolved_combined.with_suffix(".transcript.json") config.main_video = "narration_combined" if verbose: @@ -4188,10 +4211,21 @@ def cmd_render( if verbose: print(f" Using combined narration: {combined_path.name}") - else: - # Check if narration.json exists with segments (new workflow) - if so, require narration_combined - narration_json = project_path / "media" / "narration" / "narration.json" - if narration_json.exists() and _read_json(narration_json): + elif _narr_segments: + # narration.json has segments, but the combined .mov could not be found. + # Distinguish "stitched, file unreachable" from "never stitched" so the + # hint is actionable instead of always blaming videos.json. + if "narration_combined" in videos: + print( + f"Error: narration_combined.mov could not be found.", file=sys.stderr + ) + print( + f"videos.json references narration_combined, but the file is not on disk " + f"(checked local media/videos and the process cache).", + file=sys.stderr, + ) + print(_narration_combined_hint(project_path, config), file=sys.stderr) + else: print( f"Error: narration_combined not found in videos.json", file=sys.stderr ) @@ -4203,8 +4237,8 @@ def cmd_render( f"Run 'gnommo -p {project_path.name} stitch' first.", file=sys.stderr, ) - return 1 - + return 1 + else: # Single video - look for .transcript.json next to the narration video result = _find_narration_video(config, videos) if result: diff --git a/gnommo/handoff.py b/gnommo/handoff.py index 1c4373a..3b72bf8 100644 --- a/gnommo/handoff.py +++ b/gnommo/handoff.py @@ -1,27 +1,27 @@ -"""Hand off a finished video to MinIO storage via gnommoeditor (prod) or gnommoweb (local). +"""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 - gnommo handoff -p short_pixelated_universe + 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 (production): - - Uploads the video to MinIO via POST /api/assets/upload on gnommoeditor - - Updates .gnommo_sync.prod.json with asset URL - -On success (local): - - Uploads via POST /api/projects/:handle/handoff on gnommoweb - - Updates .gnommo_sync.json with new video_version +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): - GNOMMOEDITOR_URL Base URL for production (e.g. https://editor.glitch.university) - GNOMMOWEB_URL Base URL for local dev (e.g. http://localhost:3001) - GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb) + 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 @@ -85,24 +85,26 @@ def cmd_handoff( ) -> 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("GNOMMOEDITOR_URL", "").rstrip("/") - if not api_url: - print("Error: GNOMMOEDITOR_URL is not set.", file=sys.stderr) - return 1 + 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", "") - 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 + 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 (gnommoeditor)" if prod else "local" - print(f" → {target}: {api_url}") + target = "production" if prod else "local" + print(f" → gnommoweb {target}: {api_url}") project_file = project_path / "project.json" if not project_file.exists(): @@ -145,24 +147,16 @@ def cmd_handoff( 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. try: - if prod: - # gnommoeditor: POST /api/assets/upload — field name is 'file', no auth - with open(video_path, "rb") as vf: - r = requests.post( - f"{api_url}/api/assets/upload", - files={"file": (video_path.name, vf, _mime_type(video_path))}, - timeout=None, - ) - else: - # gnommoweb: POST /api/projects/:id/handoff - with open(video_path, "rb") as vf: - r = requests.post( - f"{api_url}/api/projects/{project_id}/handoff", - files={"video": (video_path.name, vf, _mime_type(video_path))}, - headers={"Authorization": f"Bearer {api_key}"}, - timeout=None, - ) + with open(video_path, "rb") as vf: + r = requests.post( + f"{api_url}/api/projects/{project_id}/handoff", + files={"video": (video_path.name, vf, _mime_type(video_path))}, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=None, + ) except requests.exceptions.ConnectionError: print(f"✗ Could not connect to {api_url}") return 1 @@ -178,40 +172,26 @@ def cmd_handoff( 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) - - if prod: - # gnommoeditor response: { asset: { id, url, minio_object_key, ... } } - asset = result.get("asset", {}) - asset_url = asset.get("url", "") - _write_sync( - project_path, - {**existing_sync, "last_handoff_at": now_iso, "asset_url": asset_url}, - prod, - ) - print(f"✓ {project_id} → uploaded [asset #{asset.get('id')}]") - if asset_url: - print(f" {asset_url}") - else: - # gnommoweb response: { video_version, video_url, asset: { updated_at } } - 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} [processed]") - if video_url: - print(f" {video_url}") + 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 diff --git a/gnommo/push.py b/gnommo/push.py index 08de2b0..78109bb 100644 --- a/gnommo/push.py +++ b/gnommo/push.py @@ -1,19 +1,18 @@ -"""Push project metadata to gnommoeditor (prod) or gnommoweb (local). +"""Push project metadata to gnommoweb (the review app). Usage: - gnommo push -p video1 # push parent video project + 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 -Reads project.json and companion JSON files, then POSTs to: - Production: POST /api/ingest (gnommoeditor, uses INGEST_API_KEY) - Local: POST /api/projects/push (gnommoweb, uses GNOMMOWEB_API_KEY) +POSTs the project metadata to gnommoweb's POST /api/projects/push. Configuration (from .env or environment): - GNOMMOEDITOR_URL Base URL for production (e.g. https://editor.glitch.university) - INGEST_API_KEY Bearer token for gnommoeditor ingest endpoint - GNOMMOWEB_URL Base URL for local dev (e.g. http://localhost:3001) - GNOMMOWEB_API_KEY Bearer token for local gnommoweb + 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 @@ -68,130 +67,6 @@ def _write_sync(project_path: Path, data: dict, prod: bool = False): json.dump(data, f, indent=2) -def _load_json_file(path: Path, label: str, verbose: bool) -> dict | list | None: - """Load a JSON file, returning None if it doesn't exist.""" - if not path.exists(): - if verbose: - print(f" {label}: not found at {path}") - return None - try: - with open(path) as f: - return json.load(f) - except json.JSONDecodeError as e: - print(f" Warning: could not parse {label} ({path}): {e}", file=sys.stderr) - return None - - -def _load_text_file(path: Path, label: str) -> str | None: - """Load a text file, returning None if it doesn't exist.""" - if not path.exists(): - return None - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="latin-1") - - -def _parse_seconds(value) -> float | None: - """Convert a time value like '30s', '1:30', or 30 into a plain float of seconds.""" - if value is None: - return None - if isinstance(value, (int, float)): - return float(value) - value = str(value).strip() - if value.endswith("s"): - value = value[:-1] - if ":" in value: - parts = value.split(":") - if len(parts) == 2: - return float(parts[0]) * 60 + float(parts[1]) - elif len(parts) == 3: - return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2]) - return float(value) - - -def _sanitize_time_fields(data: dict | None, fields: list[str]) -> dict | None: - """Return a copy of dict with the given fields converted to plain floats.""" - if not data: - return data - result = dict(data) - for field in fields: - if field in result and result[field] is not None: - try: - result[field] = _parse_seconds(result[field]) - except (ValueError, TypeError): - pass # leave invalid values for the server to reject with a clear error - return result - - -def _build_ingest_payload(project: dict, project_path: Path, verbose: bool) -> dict: - """Build the rich ingest payload for gnommoeditor POST /api/ingest.""" - - # ── slides ──────────────────────────────────────────────────────────────── - slides_path_str = project.get("slides", "slides.json") - slides_path = project_path / slides_path_str - slides = _load_json_file(slides_path, "slides", verbose) - if slides and verbose: - print(f" slides: {len(slides)} entries") - - # ── manuscript ──────────────────────────────────────────────────────────── - manuscript_path_str = project.get("manuscript", "manuscript.txt") - manuscript_path = project_path / manuscript_path_str - manuscript = _load_text_file(manuscript_path, "manuscript") - if manuscript: - print(f" manuscript: {len(manuscript)} chars") - elif verbose: - print(f" manuscript: not found at {manuscript_path}") - - # ── narration ───────────────────────────────────────────────────────────── - narration_path_str = project.get("narration", "narration.json") - narration_path = project_path / narration_path_str - narration = _load_json_file(narration_path, "narration", verbose) - - # ── audio ───────────────────────────────────────────────────────────────── - audio_path_str = project.get("audio_tracks", "audio.json") - audio_path = project_path / audio_path_str - audio = _load_json_file(audio_path, "audio", verbose) - - # ── videos ──────────────────────────────────────────────────────────────── - videos_path_str = project.get("videos", "videos.json") - videos_path = project_path / videos_path_str - videos = _load_json_file(videos_path, "videos", verbose) - - # ── citations ───────────────────────────────────────────────────────────── - citations_path = project_path / "citations.json" - citations = _load_json_file(citations_path, "citations", verbose) - - # Sanitize time fields — convert "30s", "1:30" etc. to plain floats - _VIDEO_TIME_FIELDS = ["duration", "pause_narration", "skip", "take"] - _NARRATION_TIME_FIELDS = ["skip", "take"] - _AUDIO_TIME_FIELDS = ["overlap", "duration"] - - if videos: - videos = { - k: _sanitize_time_fields(v, _VIDEO_TIME_FIELDS) for k, v in videos.items() - } - if narration: - narration = { - k: _sanitize_time_fields(v, _NARRATION_TIME_FIELDS) - for k, v in narration.items() - } - if audio: - audio = { - k: _sanitize_time_fields(v, _AUDIO_TIME_FIELDS) for k, v in audio.items() - } - - return { - "project": project, - "slides": slides, - "manuscript": manuscript, - "narration": narration, - "audio": audio, - "videos": videos, - "citations": citations, - } - - def cmd_push( project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False ) -> int: @@ -211,86 +86,34 @@ def cmd_push( 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: - return _push_prod(project, project_path, verbose) + 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: - return _push_local(project, project_path, verbose, force) - - -# ── Production: gnommoeditor POST /api/ingest ───────────────────────────────── - - -def _push_prod(project: dict, project_path: Path, verbose: bool) -> int: - api_url = os.environ.get("GNOMMOEDITOR_URL", "").rstrip("/") - api_key = os.environ.get("INGEST_API_KEY", "") + 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("Error: GNOMMOEDITOR_URL is not set.", file=sys.stderr) + print(f"Error: {url_var} is not set.", file=sys.stderr) return 1 if not api_key: - print("Error: INGEST_API_KEY is not set.", file=sys.stderr) - return 1 - - project_id = project["id"] - payload = _build_ingest_payload(project, project_path, verbose) - - # Attach sync state so the server can record it - sync = _read_sync(project_path, prod=True) - if sync: - payload["sync"] = sync - - print(f" → {api_url}/api/ingest") - - try: - r = requests.post( - f"{api_url}/api/ingest", - 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() - video_id = result.get("video_id") - slides_upserted = result.get("slides_upserted", 0) - - # Update sync state - now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds") - existing_sync = _read_sync(project_path, prod=True) - _write_sync( - project_path, - {**existing_sync, "last_pushed_at": now_iso}, - prod=True, - ) - - print(f"✓ {project_id} → video #{video_id} ({slides_upserted} slides)") - return 0 - - -# ── Local dev: gnommoweb POST /api/projects/push ────────────────────────────── - - -def _push_local(project: dict, project_path: Path, verbose: bool, force: bool) -> int: - 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) + print(f"Error: {key_var} is not set.", file=sys.stderr) return 1 if verbose: - print(f" → local: {api_url}") + target = "production" if prod else "local" + print(f" → gnommoweb {target}: {api_url}") project_id = project["id"] parent_project = project.get("parent_project") @@ -327,7 +150,7 @@ def _push_local(project: dict, project_path: Path, verbose: bool, force: bool) - 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=False) + existing_sync = _read_sync(project_path, prod=prod) _write_sync( project_path, { @@ -335,7 +158,7 @@ def _push_local(project: dict, project_path: Path, verbose: bool, force: bool) - "last_pushed_at": now_iso, "server_updated_at": server_updated_at, }, - prod=False, + prod=prod, ) asset = result.get("asset", {})