Adding support for handoff
This commit is contained in:
+50
-16
@@ -65,6 +65,16 @@ Examples:
|
|||||||
gnommo -p video1 up Push manifest files to rendering server
|
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 up --dry-run Preview which files would be pushed
|
||||||
gnommo -p video1 down Pull files from rendering server to local
|
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 Extract audio from narration_combined.mov
|
||||||
gnommo -p video1 extract-audio --combined --channel left Extract left channel only
|
gnommo -p video1 extract-audio --combined --channel left Extract left channel only
|
||||||
gnommo -p video1 extract-audio --segment seg01 Extract from a specific segment
|
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)
|
audio, audio_dir = parse_audio(project_path, config)
|
||||||
|
|
||||||
# Load whisper transcription JSON
|
# 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"
|
combined_path = videos_dir / "narration_combined.mov"
|
||||||
resolved_combined = _resolve_narration_combined(project_path, videos_dir, config)
|
resolved_combined = _resolve_narration_combined(project_path, videos_dir, config)
|
||||||
if resolved_combined and resolved_combined != combined_path:
|
narration_json = project_path / "media" / "narration" / "narration.json"
|
||||||
# File lives on external disk — point the VideoSource at the absolute path so
|
_narr_segments = _read_json(narration_json) if narration_json.exists() else {}
|
||||||
# the renderer doesn't re-resolve it via the local (missing) videos_dir.
|
if resolved_combined and resolved_combined.exists():
|
||||||
if "narration_combined" in videos:
|
# 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)
|
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")
|
transcript_path = resolved_combined.with_suffix(".transcript.json")
|
||||||
config.main_video = "narration_combined"
|
config.main_video = "narration_combined"
|
||||||
if verbose:
|
if verbose:
|
||||||
@@ -4188,10 +4211,21 @@ def cmd_render(
|
|||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f" Using combined narration: {combined_path.name}")
|
print(f" Using combined narration: {combined_path.name}")
|
||||||
|
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:
|
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):
|
|
||||||
print(
|
print(
|
||||||
f"Error: narration_combined not found in videos.json", file=sys.stderr
|
f"Error: narration_combined not found in videos.json", file=sys.stderr
|
||||||
)
|
)
|
||||||
@@ -4204,7 +4238,7 @@ def cmd_render(
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
else:
|
||||||
# Single video - look for .transcript.json next to the narration video
|
# Single video - look for .transcript.json next to the narration video
|
||||||
result = _find_narration_video(config, videos)
|
result = _find_narration_video(config, videos)
|
||||||
if result:
|
if result:
|
||||||
|
|||||||
+26
-46
@@ -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.
|
Works for any gnommo project type: parent videos and shorts alike.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
gnommo handoff -p video1
|
gnommo handoff -p video1 # → local gnommoweb
|
||||||
gnommo handoff -p short_pixelated_universe
|
gnommo handoff -p video1 --prod # → production gnommoweb (glitch.university)
|
||||||
gnommo handoff -p video1 --file /path/to/render.mp4
|
gnommo handoff -p video1 --file /path/to/render.mp4
|
||||||
|
|
||||||
Reads project.json for the 'output_video' field (path relative to the
|
Reads project.json for the 'output_video' field (path relative to the
|
||||||
project directory). Override with --file.
|
project directory). Override with --file.
|
||||||
|
|
||||||
On success (production):
|
On success (both local and --prod):
|
||||||
- Uploads the video to MinIO via POST /api/assets/upload on gnommoeditor
|
- Uploads the video via POST /api/projects/:id/handoff on gnommoweb, which
|
||||||
- Updates .gnommo_sync.prod.json with asset URL
|
stores it in MinIO and bumps the project's video_version (so it shows up on
|
||||||
|
the review page).
|
||||||
On success (local):
|
- Updates .gnommo_sync.json (local) / .gnommo_sync.prod.json (--prod) with the
|
||||||
- Uploads via POST /api/projects/:handle/handoff on gnommoweb
|
new video_version.
|
||||||
- Updates .gnommo_sync.json with new video_version
|
|
||||||
|
|
||||||
Configuration (from .env or environment):
|
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_URL Base URL for local dev (e.g. http://localhost:3001)
|
||||||
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb)
|
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 json
|
||||||
@@ -85,24 +85,26 @@ def cmd_handoff(
|
|||||||
) -> int:
|
) -> int:
|
||||||
_load_env_file()
|
_load_env_file()
|
||||||
|
|
||||||
|
# Handoff always targets gnommoweb (the review app). --prod selects the
|
||||||
|
# production instance; without it, the local dev server.
|
||||||
if prod:
|
if prod:
|
||||||
api_url = os.environ.get("GNOMMOEDITOR_URL", "").rstrip("/")
|
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
|
||||||
if not api_url:
|
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
|
||||||
print("Error: GNOMMOEDITOR_URL is not set.", file=sys.stderr)
|
url_var, key_var = "GNOMMOWEB_PROD_URL", "GNOMMOWEB_PROD_API_KEY"
|
||||||
return 1
|
|
||||||
else:
|
else:
|
||||||
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||||
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||||
|
url_var, key_var = "GNOMMOWEB_URL", "GNOMMOWEB_API_KEY"
|
||||||
if not api_url:
|
if not api_url:
|
||||||
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
|
print(f"Error: {url_var} is not set.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
if not api_key:
|
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
|
return 1
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
target = "production (gnommoeditor)" if prod else "local"
|
target = "production" if prod else "local"
|
||||||
print(f" → {target}: {api_url}")
|
print(f" → gnommoweb {target}: {api_url}")
|
||||||
|
|
||||||
project_file = project_path / "project.json"
|
project_file = project_path / "project.json"
|
||||||
if not project_file.exists():
|
if not project_file.exists():
|
||||||
@@ -145,17 +147,9 @@ def cmd_handoff(
|
|||||||
print(f" File: {video_path} ({file_size_mb:.1f} MB)")
|
print(f" File: {video_path} ({file_size_mb:.1f} MB)")
|
||||||
|
|
||||||
# ── Upload ─────────────────────────────────────────────────────────────────
|
# ── 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:
|
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:
|
with open(video_path, "rb") as vf:
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{api_url}/api/projects/{project_id}/handoff",
|
f"{api_url}/api/projects/{project_id}/handoff",
|
||||||
@@ -178,23 +172,9 @@ def cmd_handoff(
|
|||||||
result = r.json()
|
result = r.json()
|
||||||
|
|
||||||
# ── Write sync state ───────────────────────────────────────────────────────
|
# ── Write sync state ───────────────────────────────────────────────────────
|
||||||
|
# gnommoweb response: { video_version, video_url, asset: { updated_at } }
|
||||||
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, prod)
|
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_version = result.get("video_version", "?")
|
||||||
video_url = result.get("video_url", "")
|
video_url = result.get("video_url", "")
|
||||||
_write_sync(
|
_write_sync(
|
||||||
@@ -209,7 +189,7 @@ def cmd_handoff(
|
|||||||
},
|
},
|
||||||
prod,
|
prod,
|
||||||
)
|
)
|
||||||
print(f"✓ {project_id} → v{video_version} [processed]")
|
print(f"✓ {project_id} → v{video_version} [uploaded]")
|
||||||
if video_url:
|
if video_url:
|
||||||
print(f" {video_url}")
|
print(f" {video_url}")
|
||||||
|
|
||||||
|
|||||||
+27
-204
@@ -1,19 +1,18 @@
|
|||||||
"""Push project metadata to gnommoeditor (prod) or gnommoweb (local).
|
"""Push project metadata to gnommoweb (the review app).
|
||||||
|
|
||||||
Usage:
|
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 short_pixelated_universe # push a short project
|
||||||
gnommo push -p myproject --force # force push, overwrite server
|
gnommo push -p myproject --force # force push, overwrite server
|
||||||
|
|
||||||
Reads project.json and companion JSON files, then POSTs to:
|
POSTs the project metadata to gnommoweb's POST /api/projects/push.
|
||||||
Production: POST /api/ingest (gnommoeditor, uses INGEST_API_KEY)
|
|
||||||
Local: POST /api/projects/push (gnommoweb, uses GNOMMOWEB_API_KEY)
|
|
||||||
|
|
||||||
Configuration (from .env or environment):
|
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_URL Base URL for local dev (e.g. http://localhost:3001)
|
||||||
GNOMMOWEB_API_KEY Bearer token for local gnommoweb
|
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 json
|
||||||
@@ -68,130 +67,6 @@ def _write_sync(project_path: Path, data: dict, prod: bool = False):
|
|||||||
json.dump(data, f, indent=2)
|
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(
|
def cmd_push(
|
||||||
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
|
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -211,86 +86,34 @@ def cmd_push(
|
|||||||
print("Error: project.json must have 'id' and 'name' fields.", file=sys.stderr)
|
print("Error: project.json must have 'id' and 'name' fields.", file=sys.stderr)
|
||||||
return 1
|
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:
|
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:
|
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", "")
|
|
||||||
if not api_url:
|
|
||||||
print("Error: GNOMMOEDITOR_URL 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_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
|
||||||
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
|
||||||
|
url_var, key_var = "GNOMMOWEB_URL", "GNOMMOWEB_API_KEY"
|
||||||
if not api_url:
|
if not api_url:
|
||||||
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
|
print(f"Error: {url_var} is not set.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
if not api_key:
|
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
|
return 1
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f" → local: {api_url}")
|
target = "production" if prod else "local"
|
||||||
|
print(f" → gnommoweb {target}: {api_url}")
|
||||||
|
|
||||||
project_id = project["id"]
|
project_id = project["id"]
|
||||||
parent_project = project.get("parent_project")
|
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")
|
server_updated_at = result.get("server_updated_at")
|
||||||
|
|
||||||
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, prod=False)
|
existing_sync = _read_sync(project_path, prod=prod)
|
||||||
_write_sync(
|
_write_sync(
|
||||||
project_path,
|
project_path,
|
||||||
{
|
{
|
||||||
@@ -335,7 +158,7 @@ def _push_local(project: dict, project_path: Path, verbose: bool, force: bool) -
|
|||||||
"last_pushed_at": now_iso,
|
"last_pushed_at": now_iso,
|
||||||
"server_updated_at": server_updated_at,
|
"server_updated_at": server_updated_at,
|
||||||
},
|
},
|
||||||
prod=False,
|
prod=prod,
|
||||||
)
|
)
|
||||||
|
|
||||||
asset = result.get("asset", {})
|
asset = result.get("asset", {})
|
||||||
|
|||||||
Reference in New Issue
Block a user