Adding support for handoff

This commit is contained in:
2026-07-15 10:25:02 +02:00
parent c6efbaf2ed
commit c30c0f1c5e
3 changed files with 136 additions and 299 deletions
+52 -18
View File
@@ -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/<id>)
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/<output_video>
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:
+54 -74
View File
@@ -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
+30 -207
View File
@@ -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", {})