6638 lines
256 KiB
Python
6638 lines
256 KiB
Python
"""CLI entry point for GnommoEditor."""
|
||
|
||
import argparse
|
||
import json
|
||
from logging import config
|
||
import re
|
||
import time
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from gnommo.parser import _read_json
|
||
from . import __version__
|
||
from .errors import GnommoError, ParseError, ValidationError, RenderError
|
||
from .cache import get_cache_info, resolve_with_cache
|
||
from typing import Optional, Union
|
||
|
||
|
||
class NotImplementedException(GnommoError):
|
||
"""Feature not yet implemented."""
|
||
|
||
pass
|
||
|
||
|
||
# Repo-root filter_defaults.json: default filter chains (talkinghead, etc.) used
|
||
# when scaffolding a new project and no sibling project.json is available to copy
|
||
# from. Lives next to .env in the gnommo root, not inside the package.
|
||
FILTER_DEFAULTS_PATH = Path(__file__).parent.parent / "filter_defaults.json"
|
||
|
||
|
||
def load_filter_defaults() -> dict:
|
||
"""Load the default filter chains from filter_defaults.json (repo root).
|
||
|
||
Returns a dict mapping filter-set name (e.g. "talkinghead") to its filter
|
||
list. Returns {} if the file is missing or malformed.
|
||
"""
|
||
try:
|
||
return json.loads(FILTER_DEFAULTS_PATH.read_text(encoding="utf-8"))
|
||
except FileNotFoundError:
|
||
print(f" WARNING: {FILTER_DEFAULTS_PATH.name} not found in gnommo root")
|
||
return {}
|
||
except json.JSONDecodeError as e:
|
||
print(f" WARNING: {FILTER_DEFAULTS_PATH.name} is not valid JSON: {e}")
|
||
return {}
|
||
|
||
|
||
def main() -> int:
|
||
"""Main entry point."""
|
||
parser = argparse.ArgumentParser(
|
||
prog="gnommo",
|
||
description="GnommoEditor - A code-first video editing pipeline",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
gnommo -p video1 render Render the full project
|
||
gnommo -p video1 render --slides S1:S10 Render only slides S1-S10
|
||
gnommo -p video1 render --slides S10: Render from S10 to end
|
||
gnommo -p video1 validate Validate only
|
||
gnommo -p video1 import Generate slides.json from images
|
||
gnommo -p video1 pre Preprocess videos (chroma key, etc.)
|
||
gnommo -p video1 clear Delete preprocessed outputs so preprocess re-runs them
|
||
gnommo -p video1 prune Remove unused entries from videos.json/audio.json/narration.json
|
||
gnommo -p video1 prune --dry-run Preview which manifest entries would be removed
|
||
gnommo -p video1 trim Auto-detect silence and set skip/take in narration.json
|
||
gnommo -p video1 trim --force Redo trim even for segments that already have skip/take
|
||
gnommo -p video1 trim --threshold -25 Raise threshold to ignore clothing/room noise
|
||
gnommo -p video1 trim -v Show detected silence periods for debugging
|
||
gnommo -p video1 transcode Transcode narration folder to H.265 (1st pass, before preprocess)
|
||
gnommo -p video1 transcode --replace Delete originals after successful transcode
|
||
gnommo -p video1 transcode --crf 28 Lower quality / smaller files (default CRF: 23)
|
||
gnommo -p video1 transcode --processed Compress _processed.mov files to HEVC+alpha (2nd pass, after preprocess)
|
||
gnommo -p video1 transcode --processed --alpha-quality 0.5 More aggressive alpha compression
|
||
gnommo -p video1 transcode --processed --dry-run Preview what would be compressed
|
||
gnommo -p video1 transcode --force Re-transcode even if output already exists
|
||
gnommo -p video0 new Create a new project with standard folder structure
|
||
gnommo -p video1 all Full pipeline: import → preprocess → trim → render → push → handoff → up
|
||
gnommo -p video1 render --dry-run Show FFmpeg command without running
|
||
gnommo -p video1 grade Preview the talkinghead filter on a few seconds of raw_mov
|
||
gnommo -p video1 grade --set screen_gain=200 Preview with a gnommokey override
|
||
gnommo -p video1 grade --stage key Auto-tune the matte key → candidate + manifest (then --pick key_1)
|
||
gnommo -p video1 grade --stage despill Sweep spill_suppress 0.7–1.5 → stills to pick from
|
||
gnommo -p video1 grade --stage grade Sweep centered grade (5=camera vibrance, <5 paler, >5 more saturated)
|
||
gnommo -p video1 grade --pick despill_5 Apply a chosen candidate to project.json
|
||
gnommo -p video1 description Generate YouTube description file
|
||
gnommo -p video1 archive Copy project to connected external drive
|
||
gnommo -p video1 load Copy project from external drive to local
|
||
gnommo -p video1 commit -m "msg" Record a commit to commits.log (required before up)
|
||
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.
|
||
""",
|
||
)
|
||
parser.add_argument(
|
||
"--version",
|
||
action="version",
|
||
version=f"%(prog)s {__version__}",
|
||
)
|
||
|
||
# Required arguments
|
||
parser.add_argument(
|
||
"-p",
|
||
"--project",
|
||
type=str,
|
||
default=None,
|
||
help="Project directory (required for all actions except 'pexels --search')",
|
||
)
|
||
parser.add_argument(
|
||
"action",
|
||
type=str,
|
||
nargs="?",
|
||
default="render",
|
||
choices=[
|
||
"validate",
|
||
"preprocess",
|
||
"pre",
|
||
"trim",
|
||
"build",
|
||
"render",
|
||
"grade",
|
||
"all",
|
||
"align",
|
||
"auto",
|
||
"import",
|
||
"description",
|
||
"archive",
|
||
"load",
|
||
"commit",
|
||
"up",
|
||
"down",
|
||
"push",
|
||
"pull",
|
||
"handoff",
|
||
"transcode",
|
||
"pexels",
|
||
"clear",
|
||
"prune",
|
||
"new",
|
||
],
|
||
help="Action to perform (default: render)",
|
||
)
|
||
|
||
# Optional arguments
|
||
parser.add_argument(
|
||
"-v",
|
||
"--verbose",
|
||
action="store_true",
|
||
help="Verbose output",
|
||
)
|
||
parser.add_argument(
|
||
"-f",
|
||
"--force",
|
||
action="store_true",
|
||
help="Force overwrite existing files",
|
||
)
|
||
parser.add_argument(
|
||
"--dry-run",
|
||
action="store_true",
|
||
help="Show what would be done without executing",
|
||
)
|
||
parser.add_argument(
|
||
"--realign",
|
||
action="store_true",
|
||
help="(build re-aligns by default now) Explicitly re-align manuscript markers "
|
||
"to the transcript, recomputing narration_time. Your `adjustment` offsets are "
|
||
"always carried forward.",
|
||
)
|
||
parser.add_argument(
|
||
"--no-realign",
|
||
action="store_true",
|
||
help="For build: DON'T re-align — keep events.json's stored times verbatim "
|
||
"(freeze the timing layer, e.g. to preserve direct narration_time edits).",
|
||
)
|
||
parser.add_argument(
|
||
"--slides",
|
||
type=str,
|
||
help="Render only a range of slides (e.g., S1:S10, S5:, S10:S20)",
|
||
)
|
||
parser.add_argument(
|
||
"--chunk-slides",
|
||
type=int,
|
||
default=0,
|
||
dest="chunk_slides",
|
||
help="Split render into chunks of N slides each and concatenate (overrides render_chunk_slides in .gnommo.conf)",
|
||
)
|
||
parser.add_argument(
|
||
"-n",
|
||
"--chunk-debug",
|
||
type=int,
|
||
default=0,
|
||
dest="chunk_debug_slides",
|
||
metavar="N",
|
||
help="Debug: render the video in chunks of N slides each as SEPARATE files "
|
||
"on disk (suffixed with slide range, e.g. video1_S1_S4.mp4), without "
|
||
"concatenating — isolates where long-range renders OOM/fail.",
|
||
)
|
||
parser.add_argument(
|
||
"--res",
|
||
type=str,
|
||
choices=["full", "low", "tiny"],
|
||
default="full",
|
||
help="Resolution: 'full' (project res), 'low' (490x270), 'tiny' (320x180 ultrafast)",
|
||
)
|
||
parser.add_argument(
|
||
"-w",
|
||
"--workers",
|
||
type=int,
|
||
default=2,
|
||
help="Number of parallel workers for preprocessing (default: 2)",
|
||
)
|
||
parser.add_argument(
|
||
"--file",
|
||
default=None,
|
||
help="For handoff: path to video file (overrides output_video in project.json)",
|
||
)
|
||
parser.add_argument(
|
||
"--prod",
|
||
action="store_true",
|
||
help="Target production server (GNOMMOWEB_PROD_URL / GNOMMOWEB_PROD_API_KEY)",
|
||
)
|
||
parser.add_argument(
|
||
"--threshold",
|
||
type=float,
|
||
default=-40.0,
|
||
help="For trim: silence threshold in dB (default: -40). Raise (e.g. -25) to ignore clothing/room noise.",
|
||
)
|
||
parser.add_argument(
|
||
"--model",
|
||
type=str,
|
||
default="base",
|
||
help="For trim: Whisper model for transcription (tiny/base/small/medium/large, default: base)",
|
||
)
|
||
parser.add_argument(
|
||
"--crf",
|
||
type=int,
|
||
default=23,
|
||
help="For transcode: H.265 quality (CRF, default: 23; lower=better quality, larger file)",
|
||
)
|
||
parser.add_argument(
|
||
"--replace",
|
||
action="store_true",
|
||
help="For transcode: delete original files after successful transcode",
|
||
)
|
||
parser.add_argument(
|
||
"--processed",
|
||
action="store_true",
|
||
help="For transcode: compress _processed.mov files (with alpha) using HEVC+alpha instead of narration files",
|
||
)
|
||
parser.add_argument(
|
||
"--alpha-quality",
|
||
type=float,
|
||
default=1.0,
|
||
dest="alpha_quality",
|
||
help="For transcode --processed: HEVC alpha quality 0.0-1.0 (default: 0.75; lower=smaller file)",
|
||
)
|
||
parser.add_argument(
|
||
"-m",
|
||
"--message",
|
||
type=str,
|
||
default=None,
|
||
help="For commit: commit message",
|
||
)
|
||
parser.add_argument(
|
||
"--search",
|
||
type=str,
|
||
default=None,
|
||
metavar="QUERY",
|
||
help="For pexels: search Pexels for QUERY and download all results to the assets disk",
|
||
)
|
||
parser.add_argument(
|
||
"--max",
|
||
type=int,
|
||
default=200,
|
||
dest="search_max",
|
||
help="For pexels --search: maximum number of videos to download (default: 200)",
|
||
)
|
||
parser.add_argument(
|
||
"--ss",
|
||
type=float,
|
||
default=None,
|
||
dest="grade_ss",
|
||
help="For grade: seconds to seek into the raw clip before sampling (default: 5)",
|
||
)
|
||
parser.add_argument(
|
||
"--dur",
|
||
type=float,
|
||
default=3.0,
|
||
dest="grade_dur",
|
||
help="For grade: duration in seconds of the preview clip (default: 3)",
|
||
)
|
||
parser.add_argument(
|
||
"--set",
|
||
action="append",
|
||
default=None,
|
||
dest="grade_set",
|
||
metavar="KEY=VALUE",
|
||
help="For grade: override a gnommokey field (repeatable), e.g. --set screen_gain=200",
|
||
)
|
||
parser.add_argument(
|
||
"--stage",
|
||
type=str,
|
||
default=None,
|
||
dest="grade_stage",
|
||
choices=["key", "despill", "grade"],
|
||
help="For grade: generate deterministic candidate stills + manifest for one stage "
|
||
"(key=auto matte, despill=spill sweep, grade=paleness sweep)",
|
||
)
|
||
parser.add_argument(
|
||
"--pick",
|
||
type=str,
|
||
default=None,
|
||
dest="grade_pick",
|
||
metavar="ID",
|
||
help="For grade: apply a candidate from a stage manifest, e.g. --pick despill_5 "
|
||
"(or --stage X --pick best)",
|
||
)
|
||
parser.add_argument(
|
||
"--ffmpeg-log",
|
||
type=str,
|
||
default=None,
|
||
dest="ffmpeg_log",
|
||
choices=["quiet", "error", "warning", "info", "verbose", "debug", "trace"],
|
||
help="Stream FFmpeg output at this -loglevel (e.g. 'verbose', 'debug') instead of the quiet progress bar",
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
# FFmpeg log verbosity for every ffmpeg run: an explicit --ffmpeg-log level
|
||
# wins; otherwise -v turns on verbose streaming.
|
||
from .preprocessor import set_ffmpeg_verbose, set_ffmpeg_loglevel
|
||
if args.ffmpeg_log:
|
||
set_ffmpeg_loglevel(args.ffmpeg_log)
|
||
elif args.verbose:
|
||
set_ffmpeg_verbose(True)
|
||
|
||
# Resolve project path (optional for 'pexels --search')
|
||
action = args.action
|
||
if args.project is None:
|
||
if action == "pexels" and args.search:
|
||
project_path = Path.cwd() # placeholder; cmd_pexels won't use it in search mode
|
||
elif action == "auto":
|
||
project_path = Path.cwd() # auto scans this root for video* projects
|
||
else:
|
||
parser.error("argument -p/--project is required")
|
||
return 1
|
||
else:
|
||
project_path = Path(args.project)
|
||
if not project_path.is_absolute():
|
||
project_path = Path.cwd() / project_path
|
||
|
||
try:
|
||
# Handle actions
|
||
|
||
if action == "import":
|
||
return cmd_import(project_path, args.force, args.verbose)
|
||
elif action == "validate":
|
||
return cmd_validate(project_path, args.verbose)
|
||
elif action == "new":
|
||
return cmd_new(project_path, args.verbose)
|
||
elif action == "clear":
|
||
return cmd_clear(project_path, args.verbose)
|
||
elif action == "prune":
|
||
return cmd_prune(project_path, args.verbose, args.dry_run)
|
||
elif action in ("preprocess", "pre"):
|
||
return cmd_preprocess(
|
||
project_path,
|
||
args.verbose,
|
||
args.dry_run,
|
||
args.force,
|
||
args.workers,
|
||
args.res,
|
||
)
|
||
elif action == "trim":
|
||
return cmd_trim(
|
||
project_path, args.verbose, args.force, args.threshold, args.res, args.model
|
||
)
|
||
elif action == "transcode":
|
||
return cmd_transcode(
|
||
project_path,
|
||
args.verbose,
|
||
args.dry_run,
|
||
args.replace,
|
||
args.crf,
|
||
args.force,
|
||
args.processed,
|
||
args.alpha_quality,
|
||
)
|
||
elif action == "build":
|
||
return cmd_build(
|
||
project_path,
|
||
args.verbose,
|
||
args.dry_run,
|
||
args.slides,
|
||
args.res,
|
||
args.force,
|
||
# build re-aligns by default (keeping your adjustments); --no-realign
|
||
# freezes events.json's stored times instead.
|
||
realign=not args.no_realign,
|
||
)
|
||
elif action == "render":
|
||
return cmd_render(
|
||
project_path,
|
||
args.verbose,
|
||
args.dry_run,
|
||
args.slides,
|
||
args.res,
|
||
args.force,
|
||
chunk_slides=args.chunk_slides,
|
||
chunk_debug_slides=args.chunk_debug_slides,
|
||
realign=args.realign,
|
||
)
|
||
elif action == "grade":
|
||
return cmd_grade(
|
||
project_path,
|
||
args.verbose,
|
||
file=args.file,
|
||
ss=args.grade_ss,
|
||
dur=args.grade_dur,
|
||
overrides=args.grade_set,
|
||
stage=args.grade_stage,
|
||
pick=args.grade_pick,
|
||
)
|
||
elif action == "align":
|
||
return cmd_align(project_path, args.verbose)
|
||
elif action == "all":
|
||
return cmd_all(
|
||
project_path, args.verbose, args.dry_run, args.res, args.force
|
||
)
|
||
elif action == "auto":
|
||
return cmd_auto(project_path, args.verbose, args.dry_run, args.res)
|
||
elif action == "description":
|
||
return cmd_description(project_path, args.verbose)
|
||
elif action == "archive":
|
||
return cmd_archive(project_path, args.verbose, args.dry_run)
|
||
elif action == "load":
|
||
return cmd_load(project_path, args.verbose, args.dry_run)
|
||
elif action == "commit":
|
||
from .transfer import cmd_commit
|
||
if not args.message:
|
||
print("Error: -m 'message' is required for commit.")
|
||
return 1
|
||
return cmd_commit(project_path, args.message)
|
||
elif action == "up":
|
||
from .transfer import cmd_up
|
||
return cmd_up(project_path, args.verbose, args.dry_run)
|
||
elif action == "down":
|
||
from .transfer import cmd_down
|
||
return cmd_down(project_path, args.verbose, args.dry_run)
|
||
elif action == "push":
|
||
from .push import cmd_push
|
||
|
||
return cmd_push(project_path, args.verbose, args.force, args.prod)
|
||
elif action == "pull":
|
||
from .pull import cmd_pull
|
||
|
||
return cmd_pull(project_path, args.verbose, args.force, args.prod)
|
||
elif action == "handoff":
|
||
from .handoff import cmd_handoff
|
||
|
||
return cmd_handoff(
|
||
project_path, args.verbose, args.file, args.prod, args.res
|
||
)
|
||
elif action == "pexels":
|
||
return cmd_pexels(project_path, args.verbose, args.search, args.search_max)
|
||
|
||
except GnommoError as e:
|
||
print(f"Error: {e}", file=sys.stderr)
|
||
return 1
|
||
except KeyboardInterrupt:
|
||
print("\nAborted.", file=sys.stderr)
|
||
return 130
|
||
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# Import Command
|
||
# =============================================================================
|
||
|
||
|
||
def _prompter_break_line(text: str, max_words: int = 10) -> list[str]:
|
||
"""Break a long line into short breath-sized chunks for teleprompter display.
|
||
|
||
Priority order for break points:
|
||
1. Sentence end (. ? !) — always break here
|
||
2. Em/en-dash ( — / - ) — break if ≥ 4 words accumulated
|
||
3. Semicolon (;) — break if ≥ 4 words accumulated
|
||
4. Comma (,) — break if ≥ 5 words accumulated
|
||
5. Word ceiling (max_words) — force break at word boundary
|
||
|
||
Each output line is stripped and non-empty.
|
||
"""
|
||
import re
|
||
|
||
words = text.split()
|
||
if len(words) <= max_words:
|
||
return [text.strip()] if text.strip() else []
|
||
|
||
lines: list[str] = []
|
||
current: list[str] = []
|
||
|
||
# Dash detection: standalone dash token, or word that is entirely dashes/hyphens
|
||
_DASH_RE = re.compile(r"^[-–—]+$")
|
||
_SENTENCE_END_RE = re.compile(r'[.?!]["\']?$')
|
||
_SOFT_END_COMMA = re.compile(r",$")
|
||
_SOFT_END_SEMI = re.compile(r";$")
|
||
|
||
def flush(force: bool = False) -> None:
|
||
"""Flush current words to a line.
|
||
|
||
If the accumulated chunk is very short (≤ 2 words) and this isn't a
|
||
sentence-end forced flush, hold it — it will merge into the next line.
|
||
This prevents orphaned 1-2-word lines from soft breaks.
|
||
"""
|
||
if not force and len(current) <= 2:
|
||
return # too short to stand alone — let more words join
|
||
chunk = " ".join(current).strip()
|
||
if chunk:
|
||
lines.append(chunk)
|
||
current.clear()
|
||
|
||
i = 0
|
||
while i < len(words):
|
||
word = words[i]
|
||
n = len(current)
|
||
|
||
# Peek ahead: is the next token a standalone dash?
|
||
next_is_dash = (i + 1 < len(words)) and _DASH_RE.match(words[i + 1])
|
||
|
||
if _DASH_RE.match(word):
|
||
# Standalone dash: attach to current line then break.
|
||
current.append(word)
|
||
if n > 0:
|
||
flush()
|
||
elif _SENTENCE_END_RE.search(word):
|
||
current.append(word)
|
||
flush(force=True) # sentence end always flushes regardless of length
|
||
elif _SOFT_END_SEMI.search(word) and n >= 4:
|
||
current.append(word)
|
||
flush()
|
||
elif _SOFT_END_COMMA.search(word) and n >= 5:
|
||
current.append(word)
|
||
flush()
|
||
elif next_is_dash and n >= 5:
|
||
flush()
|
||
current.append(word)
|
||
elif n >= max_words:
|
||
flush(force=True) # word ceiling: must break even if line is short
|
||
current.append(word)
|
||
else:
|
||
current.append(word)
|
||
|
||
i += 1
|
||
|
||
flush()
|
||
return lines
|
||
|
||
|
||
def _export_prompter_manuscript(
|
||
manuscript_path: Path,
|
||
verbose: bool,
|
||
prompter_wpm: int = 130,
|
||
max_words: int = 10,
|
||
) -> None:
|
||
"""Generate manuscript_prompter.txt from manuscript.txt.
|
||
|
||
Strips all bracketed markers except [S*] and [cue:*], auto-breaks long
|
||
lines into breath-sized chunks, and converts [pause:Xs] markers into blank
|
||
lines calibrated to the teleprompter scroll speed.
|
||
|
||
Calibration: the scroll speed is assumed to match reading speed (prompter_wpm).
|
||
At that speed each line takes (60 * max_words / prompter_wpm) seconds, so a
|
||
[pause:Xs] marker becomes round(X * prompter_wpm / (60 * max_words)) blank lines,
|
||
preceded by a visible label line ("· · · 8s · · ·").
|
||
"""
|
||
import re
|
||
|
||
output_path = manuscript_path.parent / "manuscript_prompter.txt"
|
||
text = manuscript_path.read_text(encoding="utf-8")
|
||
|
||
_MARKER_RE = re.compile(r"\[[^\]]+\]")
|
||
_KEEP_RE = re.compile(r"^\[S[^\]]*\]$|^\[cue:[^\]]*\]$")
|
||
_SEGMENT_RE = re.compile(r"^\[segment:[^\]]*\]$")
|
||
_PAUSE_RE = re.compile(r"^\[pause:(\d+(?:\.\d+)?)s?\]$")
|
||
_BLOCK_BREAK = "\x00BLOCK\x00"
|
||
|
||
def _pause_lines(seconds: float) -> list[str]:
|
||
"""Convert a pause duration into a label + blank lines for the teleprompter."""
|
||
n_blanks = max(1, round(seconds * prompter_wpm / (60 * max_words)))
|
||
label = f"· · · {seconds:g}s · · ·"
|
||
return [label] + [""] * n_blanks
|
||
|
||
# Step 1: strip non-display markers, collect cleaned lines
|
||
cleaned_lines: list[str] = []
|
||
for line in text.splitlines():
|
||
stripped = line.strip()
|
||
if _MARKER_RE.fullmatch(stripped):
|
||
if _SEGMENT_RE.match(stripped):
|
||
cleaned_lines.append(_BLOCK_BREAK)
|
||
elif _KEEP_RE.match(stripped):
|
||
cleaned_lines.append(stripped)
|
||
else:
|
||
m = _PAUSE_RE.match(stripped)
|
||
if m:
|
||
cleaned_lines.extend(_pause_lines(float(m.group(1))))
|
||
# else: drop marker-only lines entirely
|
||
else:
|
||
def _replace(m: re.Match) -> str:
|
||
# Handle inline [pause:Xs] — convert to label inline
|
||
if _PAUSE_RE.match(m.group(0)):
|
||
sec = float(_PAUSE_RE.match(m.group(0)).group(1))
|
||
return f"· · · {sec:g}s · · ·"
|
||
return m.group(0) if _KEEP_RE.match(m.group(0)) else ""
|
||
cleaned = _MARKER_RE.sub(_replace, line).strip()
|
||
if cleaned:
|
||
cleaned_lines.append(cleaned)
|
||
else:
|
||
cleaned_lines.append("") # preserve intentional blank lines
|
||
|
||
# Step 2: auto-break long text lines; pass markers, blanks, and pause labels through
|
||
_PAUSE_LABEL_RE = re.compile(r"^· · · .+ · · ·$")
|
||
broken: list[str] = []
|
||
for line in cleaned_lines:
|
||
stripped = line.strip()
|
||
if not stripped or stripped == _BLOCK_BREAK or _KEEP_RE.match(stripped) or _PAUSE_LABEL_RE.match(stripped):
|
||
broken.append(line)
|
||
else:
|
||
broken.extend(_prompter_break_line(stripped))
|
||
|
||
# Step 3: merge each slide marker onto the first word of its slide,
|
||
# then lay out the final lines.
|
||
# - [S*] markers are prepended inline to the next text line
|
||
# - Block-break sentinels expand to a double blank (Elgato block boundary)
|
||
# - Ordinary consecutive blank lines are collapsed to one
|
||
merged: list[str] = []
|
||
pending_marker: str = ""
|
||
pending_breaks: int = 0 # block-breaks that arrived while a marker was pending
|
||
for line in broken:
|
||
stripped = line.strip()
|
||
if stripped and _KEEP_RE.match(stripped):
|
||
pending_marker = stripped # hold until the next text line
|
||
elif pending_marker:
|
||
if stripped and stripped != _BLOCK_BREAK:
|
||
# Flush any buffered block-breaks first, then attach marker to text
|
||
for _ in range(pending_breaks):
|
||
merged.append(_BLOCK_BREAK)
|
||
pending_breaks = 0
|
||
merged.append(f"{pending_marker} {stripped}")
|
||
pending_marker = ""
|
||
elif stripped == _BLOCK_BREAK:
|
||
# Buffer the block-break — emit it after the marker attaches
|
||
pending_breaks += 1
|
||
else:
|
||
# Ordinary blank: carry forward (swallow into the marker's gap)
|
||
pass
|
||
else:
|
||
merged.append(line)
|
||
if pending_marker:
|
||
for _ in range(pending_breaks):
|
||
merged.append(_BLOCK_BREAK)
|
||
merged.append(pending_marker)
|
||
|
||
result: list[str] = []
|
||
prev_blank_count = 0
|
||
|
||
for line in merged:
|
||
if line == _BLOCK_BREAK:
|
||
needed = 2 - prev_blank_count
|
||
result.extend([""] * max(needed, 0))
|
||
prev_blank_count = 2
|
||
continue
|
||
|
||
is_blank = not line.strip()
|
||
|
||
if is_blank:
|
||
if prev_blank_count >= 1:
|
||
continue # collapse ordinary consecutive blanks
|
||
result.append("")
|
||
prev_blank_count += 1
|
||
else:
|
||
result.append(line)
|
||
prev_blank_count = 0
|
||
|
||
output_path.write_text("\n".join(result).strip() + "\n", encoding="utf-8")
|
||
print(f" Prompter manuscript → {output_path.name}")
|
||
|
||
|
||
def cmd_import(project_path: Path, force: bool, verbose: bool) -> int:
|
||
"""Import assets and generate metadata JSON files."""
|
||
from .parser import parse_project_config, _read_json
|
||
|
||
print(f"Importing assets for: {project_path.name}")
|
||
|
||
if not project_path.exists():
|
||
print(f"Error: Project directory not found: {project_path}", file=sys.stderr)
|
||
return 1
|
||
|
||
# Load project config if it exists (for videos_path and default_filters)
|
||
config = None
|
||
if (project_path / "project.json").exists():
|
||
config = parse_project_config(project_path)
|
||
|
||
# Import videos from media/videos directory
|
||
if config and config.videos_path:
|
||
videos_json_path = project_path / config.videos_path
|
||
videos_dir = videos_json_path.parent
|
||
else:
|
||
videos_dir = project_path / "media" / "videos"
|
||
|
||
if videos_dir.exists():
|
||
_import_videos(videos_dir, config, verbose)
|
||
|
||
# Import narration segments from media/narration directory
|
||
narration_dir = project_path / "media" / "narration"
|
||
if narration_dir.exists():
|
||
_import_narration_segments(narration_dir, config, verbose)
|
||
|
||
# Import presenter notes from Keynote file (also exports slide PNGs)
|
||
keynote_files = list(project_path.glob("*.key"))
|
||
if keynote_files:
|
||
keynote_file = keynote_files[0] # Use first .key file found
|
||
if len(keynote_files) > 1:
|
||
print(f" Warning: Multiple .key files found, using {keynote_file.name}")
|
||
_import_presenter_notes(project_path, keynote_file, verbose, config)
|
||
elif verbose:
|
||
print(" No .key file found, skipping presenter notes import")
|
||
|
||
# Generate slides.json for this project's slide export dir only. Slides always
|
||
# export to media/slides/<project>/ (consistent across projects); sibling folders
|
||
# like spec/, prompt/, render/ are auxiliary AI-workflow files, not slide images,
|
||
# so scanning them just produced spurious "No image files" warnings.
|
||
project_slides_dir = project_path / "media" / "slides" / project_path.name.lower()
|
||
if project_slides_dir.is_dir():
|
||
_generate_slides_json(project_slides_dir, verbose)
|
||
|
||
# Import shared assets (pexels, etc.) from shared_assets directory
|
||
# Look for shared_assets relative to project or in parent directories
|
||
shared_assets_dir = _find_shared_assets(project_path)
|
||
if shared_assets_dir:
|
||
_import_shared_assets(shared_assets_dir, verbose)
|
||
_import_shared_audio(shared_assets_dir, project_path, config, verbose)
|
||
_sync_shared_videos_to_local(project_path, config, shared_assets_dir, verbose)
|
||
|
||
# Probe and cache audio file durations into audio.json
|
||
_probe_audio_durations(project_path, config, force, verbose, shared_assets_dir)
|
||
|
||
# Probe and cache video metadata (duration, has_audio) into videos.json
|
||
_probe_video_metadata(project_path, config, shared_assets_dir, force, verbose)
|
||
|
||
# ETL: if a manuscript exists, project shorthand marker semantics (cutout/layer)
|
||
# into videos.json so the render stage is always data-driven from the manuscript.
|
||
# Run AFTER sync so newly-added shared videos are already present when we write
|
||
# their cutout/layer. Also warn about any referenced video that is still missing.
|
||
manuscript_path = project_path / "manuscript.txt"
|
||
if manuscript_path.exists() and config:
|
||
from .parser import parse_manuscript
|
||
from .transformer import _SHORTHAND_PREFIXES
|
||
|
||
_, markers, _, _ = parse_manuscript(project_path)
|
||
if markers:
|
||
_project_markers_to_videos(
|
||
markers,
|
||
project_path / config.videos_path,
|
||
config,
|
||
project_path,
|
||
)
|
||
|
||
# Warn about shorthand-referenced videos still absent from videos.json
|
||
videos_json_path = project_path / config.videos_path
|
||
local_vids: dict = (
|
||
_read_json(videos_json_path) if videos_json_path.exists() else {}
|
||
)
|
||
seen_missing: set[str] = set()
|
||
for marker in markers:
|
||
for prefix in _SHORTHAND_PREFIXES:
|
||
if marker.startswith(prefix):
|
||
vid_id = marker[len(prefix):].lower()
|
||
if vid_id not in local_vids and vid_id not in seen_missing:
|
||
hint = (
|
||
"run 'gnommo pexels' to download"
|
||
if vid_id.startswith("pexels/")
|
||
else "add it manually"
|
||
)
|
||
print(
|
||
f" ⚠ [{marker}] video '{vid_id}' not found in "
|
||
f"videos.json or shared_assets — {hint}"
|
||
)
|
||
seen_missing.add(vid_id)
|
||
break
|
||
|
||
# Generate teleprompter version of manuscript
|
||
if manuscript_path.exists():
|
||
import configparser as _cp
|
||
_pcfg = _cp.ConfigParser()
|
||
_pcfg.read(Path.home() / ".gnommo.conf")
|
||
_wpm = int(_pcfg.get("prompter", "wpm", fallback="130"))
|
||
_mpw = int(_pcfg.get("prompter", "max_words_per_line", fallback="10"))
|
||
_export_prompter_manuscript(manuscript_path, verbose, prompter_wpm=_wpm, max_words=_mpw)
|
||
|
||
print("Import complete.")
|
||
return 0
|
||
|
||
|
||
def _import_shared_audio(
|
||
shared_assets_dir: Path,
|
||
project_path: Path,
|
||
config,
|
||
verbose: bool,
|
||
) -> None:
|
||
"""Import audio files from shared_assets/media/audio into the project's audio.json."""
|
||
audio_extensions = {".mp3", ".wav", ".aac", ".m4a", ".ogg", ".flac"}
|
||
shared_audio_dir = shared_assets_dir / "media" / "audio"
|
||
|
||
if not shared_audio_dir.exists():
|
||
if verbose:
|
||
print(f" No shared audio dir found at {shared_audio_dir}")
|
||
return
|
||
|
||
audio_files = sorted(
|
||
f
|
||
for f in shared_audio_dir.iterdir()
|
||
if f.is_file()
|
||
and f.suffix.lower() in audio_extensions
|
||
and not f.name.startswith(".")
|
||
)
|
||
|
||
if not audio_files:
|
||
if verbose:
|
||
print(f" No audio files found in {shared_audio_dir}")
|
||
return
|
||
|
||
# Resolve project audio.json path
|
||
if config and config.audio_path:
|
||
audio_json_path = project_path / config.audio_path
|
||
else:
|
||
audio_json_path = project_path / "media" / "audio" / "audio.json"
|
||
|
||
audio_json_path.parent.mkdir(parents=True, exist_ok=True)
|
||
existing: dict = _read_json(audio_json_path) if audio_json_path.exists() else {}
|
||
|
||
added = 0
|
||
normalized = 0
|
||
for f in audio_files:
|
||
audio_id = f.stem.lower()
|
||
if audio_id in existing:
|
||
# Backfill the audio schema onto an existing entry: make `loop`
|
||
# explicit (so it's not an invisible optional the way it was before),
|
||
# and drop `zoom` — a video-only field that means nothing for audio and
|
||
# only ended up here from legacy/hand-edited entries.
|
||
entry = existing[audio_id]
|
||
if isinstance(entry, dict):
|
||
if "loop" not in entry:
|
||
entry["loop"] = None
|
||
normalized += 1
|
||
if "zoom" in entry:
|
||
del entry["zoom"]
|
||
normalized += 1
|
||
if verbose:
|
||
print(f" Skipping {audio_id} (already in audio.json)")
|
||
continue
|
||
existing[audio_id] = {
|
||
"file": f.name,
|
||
"is_shared": True,
|
||
"volume": 1.0,
|
||
# One-shot by default (null → not looping); set true for background
|
||
# music/ambience that should loop for the whole video.
|
||
"loop": None,
|
||
}
|
||
added += 1
|
||
if verbose:
|
||
print(f" Added shared audio: {audio_id}")
|
||
|
||
if added > 0 or normalized > 0:
|
||
with open(audio_json_path, "w", encoding="utf-8") as fh:
|
||
json.dump(existing, fh, indent=2)
|
||
parts = []
|
||
if added:
|
||
parts.append(f"+{added} shared audio files")
|
||
if normalized:
|
||
parts.append(f"{normalized} field(s) normalized")
|
||
print(
|
||
f" Updated {audio_json_path.relative_to(project_path)} ({', '.join(parts)})"
|
||
)
|
||
else:
|
||
if verbose:
|
||
print(f" No new shared audio files to add")
|
||
|
||
|
||
def _probe_is_fresh(cached: dict, path: Path, *fields: str) -> bool:
|
||
"""True when `cached` already holds every field in `fields` AND was probed from
|
||
the file as it exists now (its stored ``src_mtime`` still matches the file on
|
||
disk).
|
||
|
||
A file re-exported after import gets a newer mtime, so its entry re-probes
|
||
automatically on the next import — no --force needed. This is what stops a stale
|
||
``has_audio``/``duration`` (e.g. a render clip regenerated without an audio
|
||
track) from silently persisting and later crashing the render with
|
||
"[N:a] matches no streams". Entries written before mtime-stamping existed have no
|
||
``src_mtime`` and re-probe once to gain the stamp.
|
||
"""
|
||
if not all(f in cached for f in fields):
|
||
return False
|
||
stamp = cached.get("src_mtime")
|
||
if stamp is None:
|
||
return False
|
||
try:
|
||
return abs(float(stamp) - path.stat().st_mtime) < 1.0
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def _file_mtime(path: Path) -> float:
|
||
"""Source-file mtime to stamp into a probed entry (rounded for stable JSON)."""
|
||
return round(path.stat().st_mtime, 3)
|
||
|
||
|
||
def _probe_audio_durations(
|
||
project_path: Path,
|
||
config,
|
||
force: bool,
|
||
verbose: bool,
|
||
shared_assets_dir: Optional[Path] = None,
|
||
) -> None:
|
||
"""Probe and cache audio file durations into audio.json.
|
||
|
||
Runs once at import time so the render stage never needs to scan audio files.
|
||
Skips entries that already have a duration unless --force is set.
|
||
"""
|
||
from .renderer import _get_audio_duration
|
||
|
||
if config and config.audio_path:
|
||
audio_json_path = project_path / config.audio_path
|
||
else:
|
||
audio_json_path = project_path / "audio.json"
|
||
|
||
if not audio_json_path.exists():
|
||
return
|
||
|
||
audio_dir = audio_json_path.parent
|
||
data = _read_json(audio_json_path)
|
||
updated = False
|
||
|
||
for audio_id, audio_data in data.items():
|
||
if "file" not in audio_data:
|
||
continue
|
||
if audio_data.get("is_shared") and shared_assets_dir:
|
||
audio_path = shared_assets_dir / "media" / "audio" / audio_data["file"]
|
||
else:
|
||
audio_path = audio_dir / audio_data["file"]
|
||
if not audio_path.exists():
|
||
if verbose:
|
||
print(f" Audio '{audio_id}': file not found, skipping")
|
||
continue
|
||
if not force and _probe_is_fresh(audio_data, audio_path, "duration"):
|
||
if verbose:
|
||
print(f" Audio '{audio_id}': cached ({audio_data['duration']:.1f}s)")
|
||
continue
|
||
print(
|
||
f" Probing audio '{audio_id}' ({audio_path.name})...", end=" ", flush=True
|
||
)
|
||
try:
|
||
duration = _get_audio_duration(audio_path)
|
||
data[audio_id]["duration"] = round(duration, 3)
|
||
data[audio_id]["src_mtime"] = _file_mtime(audio_path)
|
||
updated = True
|
||
print(f"{duration:.1f}s")
|
||
except Exception as e:
|
||
print(f"failed ({e})")
|
||
|
||
if updated:
|
||
with open(audio_json_path, "w") as f:
|
||
json.dump(data, f, indent=4)
|
||
print(f" Saved durations to {audio_json_path.name}")
|
||
|
||
|
||
def _probe_video_metadata(
|
||
project_path: Path,
|
||
config,
|
||
shared_assets_dir: Optional[Path],
|
||
force: bool,
|
||
verbose: bool,
|
||
) -> None:
|
||
"""Probe and cache video file duration and audio presence into videos.json.
|
||
|
||
Runs once at import time so the render stage never needs to probe video files.
|
||
Shared entries are written back to shared_assets/videos.json (canonical source).
|
||
Local entries are written to the project's videos.json.
|
||
Skips entries that already have both fields unless --force is set.
|
||
"""
|
||
from .preprocessor import get_video_duration
|
||
from .renderer import _has_audio_stream
|
||
|
||
if config and config.videos_path:
|
||
videos_json_path = project_path / config.videos_path
|
||
else:
|
||
videos_json_path = project_path / "media" / "videos" / "videos.json"
|
||
|
||
if not videos_json_path.exists():
|
||
return
|
||
|
||
videos_dir = videos_json_path.parent
|
||
local_data = _read_json(videos_json_path)
|
||
|
||
# Load shared_assets/videos.json separately — shared probes write there
|
||
shared_json_path = shared_assets_dir / "videos.json" if shared_assets_dir else None
|
||
shared_data = (
|
||
_read_json(shared_json_path)
|
||
if shared_json_path and shared_json_path.exists()
|
||
else {}
|
||
)
|
||
|
||
local_updated = False
|
||
shared_updated = False
|
||
|
||
for video_id, video_data in local_data.items():
|
||
if "source_file" not in video_data:
|
||
continue
|
||
|
||
is_shared = video_data.get("is_shared", False)
|
||
|
||
# For shared entries, check the shared_assets/videos.json for cached values
|
||
if is_shared and video_id in shared_data:
|
||
canonical = shared_data[video_id]
|
||
else:
|
||
canonical = video_data
|
||
|
||
base_dir = (
|
||
shared_assets_dir if (is_shared and shared_assets_dir) else videos_dir
|
||
)
|
||
|
||
# Mirror renderer._resolve_video_path: try output_file first, then source_file
|
||
# Use resolve_with_cache so files on external disks (LaCie, GnommoDisk) are found.
|
||
from .cache import resolve_with_cache
|
||
|
||
video_path = None
|
||
output_file = video_data.get("output_file")
|
||
if output_file:
|
||
for candidate_dir in [base_dir, base_dir.parent]:
|
||
candidate = candidate_dir / output_file
|
||
candidate, _ = resolve_with_cache(candidate, project_path)
|
||
if candidate.exists():
|
||
video_path = candidate
|
||
break
|
||
mov_candidate = candidate.with_suffix(".mov")
|
||
mov_candidate, _ = resolve_with_cache(mov_candidate, project_path)
|
||
if mov_candidate.exists():
|
||
video_path = mov_candidate
|
||
break
|
||
|
||
if video_path is None:
|
||
source_candidate = base_dir / video_data["source_file"]
|
||
source_candidate, _ = resolve_with_cache(source_candidate, project_path)
|
||
if source_candidate.exists():
|
||
video_path = source_candidate
|
||
|
||
if video_path is None:
|
||
if verbose:
|
||
print(f" Video '{video_id}': file not found, skipping")
|
||
continue
|
||
|
||
# Re-probe when the file has changed since the cached values were written
|
||
# (mtime mismatch) — a clip re-exported after import self-heals instead of
|
||
# carrying a stale has_audio into the render.
|
||
if not force and _probe_is_fresh(canonical, video_path, "duration", "has_audio"):
|
||
if verbose:
|
||
print(
|
||
f" Video '{video_id}': cached ({canonical['duration']:.1f}s, audio={canonical['has_audio']})"
|
||
)
|
||
continue
|
||
|
||
print(
|
||
f" Probing video '{video_id}' ({video_path.name})...", end=" ", flush=True
|
||
)
|
||
try:
|
||
duration = get_video_duration(video_path)
|
||
has_audio = _has_audio_stream(video_path)
|
||
result = {
|
||
"duration": round(duration, 3),
|
||
"has_audio": has_audio,
|
||
"src_mtime": _file_mtime(video_path),
|
||
}
|
||
print(f"{duration:.1f}s, audio={has_audio}")
|
||
|
||
if is_shared and video_id in shared_data:
|
||
# Write back to shared_assets/videos.json — canonical source for shared assets
|
||
shared_data[video_id].update(result)
|
||
shared_updated = True
|
||
else:
|
||
local_data[video_id].update(result)
|
||
local_updated = True
|
||
except Exception as e:
|
||
print(f"failed ({e})")
|
||
|
||
if local_updated:
|
||
with open(videos_json_path, "w") as f:
|
||
json.dump(local_data, f, indent=4)
|
||
print(f" Saved metadata to {videos_json_path.name}")
|
||
|
||
if shared_updated and shared_json_path:
|
||
with open(shared_json_path, "w") as f:
|
||
json.dump(shared_data, f, indent=4)
|
||
print(f" Saved shared metadata to {shared_json_path.name}")
|
||
|
||
|
||
def _sync_shared_videos_to_local(
|
||
project_path: Path, config, shared_assets_dir: Path, verbose: bool
|
||
) -> None:
|
||
"""Append entries from shared_assets/videos.json into the project's local videos.json.
|
||
|
||
Each new entry gets is_shared=true so the renderer looks in shared_assets_dir.
|
||
Existing local entries are never overwritten (preserves cutout, layer, filters, etc.).
|
||
"""
|
||
shared_videos_json = shared_assets_dir / "videos.json"
|
||
if not shared_videos_json.exists():
|
||
return
|
||
|
||
shared_videos = _read_json(shared_videos_json)
|
||
if not shared_videos:
|
||
return
|
||
|
||
if config and config.videos_path:
|
||
local_json_path = project_path / config.videos_path
|
||
else:
|
||
local_json_path = project_path / "media" / "videos" / "videos.json"
|
||
|
||
local_videos: dict = {}
|
||
if local_json_path.exists():
|
||
local_videos = _read_json(local_json_path)
|
||
|
||
_METADATA_FIELDS = ("duration", "has_audio")
|
||
|
||
added = []
|
||
metadata_updated = []
|
||
for video_id, shared_entry in shared_videos.items():
|
||
if video_id in local_videos:
|
||
# Propagate any metadata fields that were probed into shared_assets/videos.json
|
||
changed = False
|
||
for field in _METADATA_FIELDS:
|
||
if (
|
||
field in shared_entry
|
||
and local_videos[video_id].get(field) != shared_entry[field]
|
||
):
|
||
local_videos[video_id][field] = shared_entry[field]
|
||
changed = True
|
||
if changed:
|
||
metadata_updated.append(video_id)
|
||
elif verbose:
|
||
print(f" Shared '{video_id}': already in local videos.json, skipping")
|
||
continue
|
||
# New entry — copy base metadata from shared and mark it as shared.
|
||
# Exclude presentation fields (cutout, layer) so _project_markers_to_videos
|
||
# can set them from the manuscript without stale shared values interfering.
|
||
_PRESENTATION_FIELDS = {"cutout", "layer", "pause_narration"}
|
||
local_entry = {
|
||
k: v for k, v in shared_entry.items() if k not in _PRESENTATION_FIELDS
|
||
}
|
||
local_entry["is_shared"] = True
|
||
local_videos[video_id] = local_entry
|
||
added.append(video_id)
|
||
|
||
if added or metadata_updated:
|
||
local_json_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(local_json_path, "w", encoding="utf-8") as f:
|
||
json.dump(local_videos, f, indent=4)
|
||
if added:
|
||
print(
|
||
f" Synced {len(added)} shared asset(s) to local videos.json: {', '.join(added)}"
|
||
)
|
||
if metadata_updated:
|
||
print(
|
||
f" Updated metadata for {len(metadata_updated)} shared asset(s): {', '.join(metadata_updated)}"
|
||
)
|
||
elif verbose:
|
||
print(" No new shared assets to sync to local videos.json")
|
||
|
||
|
||
def _find_shared_assets(project_path: Path) -> Optional[Path]:
|
||
"""Find the shared_assets directory.
|
||
|
||
Looks in:
|
||
1. project_path/shared_assets
|
||
2. project_path/../shared_assets (sibling to project)
|
||
"""
|
||
# Check if shared_assets is inside project
|
||
if (project_path / "shared_assets").exists():
|
||
return project_path / "shared_assets"
|
||
|
||
# Check if shared_assets is sibling to project
|
||
if (project_path.parent / "shared_assets").exists():
|
||
return project_path.parent / "shared_assets"
|
||
|
||
return None
|
||
|
||
|
||
def _import_shared_assets(shared_assets_dir: Path, verbose: bool) -> None:
|
||
"""Import video files from shared_assets directory into videos.json.
|
||
|
||
Scans the root level and all subdirectories for video files and creates
|
||
a unified videos.json in shared_assets/.
|
||
|
||
Video IDs use the filename for root-level files (e.g., "Logo") or
|
||
are prefixed with the subfolder name for subdirectory files (e.g., "pexels/filename").
|
||
"""
|
||
video_extensions = {".mov", ".mp4", ".webm", ".avi", ".mkv", ".m4v"}
|
||
|
||
# Find all video files in shared_assets (root level and subdirectories).
|
||
# Also scan external disk mirrors so files placed there are registered.
|
||
from .cache import load_assets_config, load_cache_config
|
||
|
||
scan_roots: list[Path] = [shared_assets_dir]
|
||
for external_base in (load_cache_config(), load_assets_config()):
|
||
if external_base:
|
||
ext_shared = external_base / "shared_assets"
|
||
if ext_shared.exists() and ext_shared != shared_assets_dir:
|
||
scan_roots.append(ext_shared)
|
||
|
||
video_files: list[tuple[Path, Path]] = [] # (relative_path, absolute_path)
|
||
seen_rel: set[str] = set() # deduplicate by relative path
|
||
|
||
for scan_root in scan_roots:
|
||
for item in scan_root.iterdir():
|
||
if item.name.startswith("."):
|
||
continue
|
||
|
||
if item.is_file():
|
||
if (
|
||
item.suffix.lower() in video_extensions
|
||
and not item.name.endswith("_processed.mov")
|
||
and not item.name.endswith("_processed.webm")
|
||
):
|
||
rel_path = item.relative_to(scan_root)
|
||
if str(rel_path) not in seen_rel:
|
||
seen_rel.add(str(rel_path))
|
||
video_files.append((rel_path, item))
|
||
elif item.is_dir():
|
||
for video_file in item.rglob("*"):
|
||
if (
|
||
video_file.is_file()
|
||
and video_file.suffix.lower() in video_extensions
|
||
and not video_file.name.endswith("_processed.mov")
|
||
and not video_file.name.endswith("_processed.webm")
|
||
):
|
||
rel_path = video_file.relative_to(scan_root)
|
||
if str(rel_path) not in seen_rel:
|
||
seen_rel.add(str(rel_path))
|
||
video_files.append((rel_path, video_file))
|
||
|
||
if not video_files:
|
||
if verbose:
|
||
print(f" No video files found in {shared_assets_dir}")
|
||
return
|
||
|
||
# Load existing videos.json if it exists
|
||
videos_json_path = shared_assets_dir / "videos.json"
|
||
existing_videos: dict = {}
|
||
if videos_json_path.exists():
|
||
existing_videos = _read_json(videos_json_path)
|
||
|
||
# Remove entries whose source file no longer exists on any scan root
|
||
removed_count = 0
|
||
stale_keys = [
|
||
vid_id
|
||
for vid_id, vid_data in existing_videos.items()
|
||
if vid_data.get("source_file")
|
||
and not any((root / vid_data["source_file"]).exists() for root in scan_roots)
|
||
]
|
||
for vid_id in stale_keys:
|
||
del existing_videos[vid_id]
|
||
removed_count += 1
|
||
if verbose:
|
||
print(f" Removed stale shared entry: {vid_id}")
|
||
if removed_count > 0:
|
||
print(f" Removed {removed_count} stale shared asset(s) from videos.json")
|
||
|
||
# Add new videos (don't overwrite existing)
|
||
added_count = 0
|
||
for rel_path, abs_path in sorted(video_files):
|
||
# Use path relative to shared_assets without extension as video_id (lowercase)
|
||
# e.g., "logo" for root files, "pexels/6759604-hd" for subdirectory files
|
||
video_id = str(rel_path.with_suffix("")).lower()
|
||
|
||
if video_id in existing_videos:
|
||
if verbose:
|
||
print(f" Skipping {video_id} (already exists)")
|
||
continue
|
||
|
||
existing_videos[video_id] = {
|
||
"source_file": str(rel_path),
|
||
}
|
||
added_count += 1
|
||
if verbose:
|
||
print(f" Added: {video_id}")
|
||
|
||
if added_count > 0 or removed_count > 0:
|
||
# Write updated videos.json
|
||
with open(videos_json_path, "w", encoding="utf-8") as f:
|
||
json.dump(existing_videos, f, indent=2)
|
||
if added_count > 0:
|
||
print(f" Updated {videos_json_path} (+{added_count} shared assets)")
|
||
else:
|
||
print(f" No new shared assets to add")
|
||
|
||
|
||
def _generate_slides_json(directory: Path, verbose: bool) -> None:
|
||
"""Generate slides.json from Keynote export folder."""
|
||
extensions = {".png", ".gif", ".pdf", ".jpg", ".jpeg"}
|
||
files = [f for f in directory.iterdir() if f.suffix.lower() in extensions]
|
||
|
||
if not files:
|
||
print(f" Warning: No image files in {directory}")
|
||
return
|
||
|
||
# Extract numeric suffix from filenames like "Video1.001.png"
|
||
pattern = re.compile(r"\.(\d+)\.[^.]+$")
|
||
|
||
slides = {}
|
||
for file in files:
|
||
match = pattern.search(file.name)
|
||
if match:
|
||
num = int(match.group(1))
|
||
slide_id = f"S{num}"
|
||
slides[slide_id] = {
|
||
"image": file.name,
|
||
"type": "fullscreen",
|
||
}
|
||
|
||
if not slides:
|
||
print(f" Warning: No valid slide files in {directory}")
|
||
return
|
||
|
||
# Sort by slide number
|
||
sorted_slides = dict(sorted(slides.items(), key=lambda x: int(x[0][1:])))
|
||
|
||
# Write slides.json only if content changed
|
||
output_path = directory / "slides.json"
|
||
new_content = json.dumps(sorted_slides, indent=2)
|
||
existing_content = (
|
||
output_path.read_text(encoding="utf-8") if output_path.exists() else None
|
||
)
|
||
if new_content != existing_content:
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
f.write(new_content)
|
||
print(f" Generated {output_path} ({len(sorted_slides)} slides)")
|
||
if verbose:
|
||
for slide_id in sorted_slides:
|
||
print(f" [{slide_id}]")
|
||
|
||
|
||
# Every user-tweakable video parameter with its default value. Written into
|
||
# each new videos.json entry (and backfilled onto existing ones) so nothing is
|
||
# an invisible optional. Auto-managed fields (output_file, duration, has_audio,
|
||
# is_shared, attribution) are intentionally omitted — they're set by the tool.
|
||
# end_on defaults to null = "use the marker-type default" (video → next_video,
|
||
# narration → play to end); set it to "next_slide", "next_video", "loop", "end", or
|
||
# "take" explicitly (resolved per-event by transformer.resolve_video_presentation).
|
||
_VIDEO_DEFAULTS = {
|
||
"cutout": "square", # named zone from project.json cutouts
|
||
"layer": "above", # above | mid | below (relative to slides/narrator)
|
||
"filter": [], # preprocessing filter chain (empty = none)
|
||
"take": None, # max seconds to play (null = to next slide/end)
|
||
"skip": 0.0, # seconds to skip at the start
|
||
"zoom": 1.0, # scale within the cutout
|
||
"volume": 1.0, # audio volume multiplier
|
||
"use_audio_channels": "both", # both | left | right
|
||
"always_visible": False, # always on screen (like the talking head)
|
||
"pause_narration": 0.0, # seconds to freeze narration for a cutscene
|
||
"end_on": None, # null | next_slide | next_video | loop | end | take
|
||
}
|
||
|
||
|
||
def _import_videos(videos_dir: Path, config, verbose: bool) -> None:
|
||
"""Import video files into videos.json.
|
||
|
||
Scans the videos directory for video files and adds them to videos.json.
|
||
Uses the filename (without extension) as the video_id.
|
||
Does not overwrite existing entries - only adds new ones.
|
||
|
||
If the video filename matches a pattern like 'talkinghead*' and a 'talkinghead'
|
||
filter preset exists in default_filters, it will be applied automatically.
|
||
"""
|
||
video_extensions = {".mov", ".mp4", ".webm", ".avi", ".mkv", ".m4v"}
|
||
|
||
# Find all video files (exclude processed outputs, proxies, and intermediate files)
|
||
video_files = [
|
||
f
|
||
for f in videos_dir.iterdir()
|
||
if f.is_file()
|
||
and f.suffix.lower() in video_extensions
|
||
and "_processed" not in f.stem # Exclude any _processed files
|
||
and "_fixed" not in f.stem # Exclude any _fixed files
|
||
]
|
||
|
||
# Also exclude files in subdirectories (proxy/, intermediate/, etc.)
|
||
video_files = [f for f in video_files if f.parent == videos_dir]
|
||
|
||
# Ensure videos.json exists even if there are no video files yet
|
||
videos_json_path = videos_dir / "videos.json"
|
||
if not videos_json_path.exists():
|
||
videos_dir.mkdir(parents=True, exist_ok=True)
|
||
with open(videos_json_path, "w", encoding="utf-8") as f:
|
||
json.dump({}, f, indent=2)
|
||
print(
|
||
f" Created empty {videos_json_path.relative_to(videos_dir.parent.parent)}"
|
||
)
|
||
|
||
if not video_files:
|
||
if verbose:
|
||
print(f" No new video files found in {videos_dir}")
|
||
return
|
||
|
||
# Load existing videos.json
|
||
existing_videos: dict = {}
|
||
if videos_json_path.exists():
|
||
existing_videos = _read_json(videos_json_path)
|
||
|
||
# Remove entries whose source file no longer exists on disk.
|
||
# Skip is_shared entries — their source files live in shared_assets, not videos_dir.
|
||
removed_count = 0
|
||
stale_keys = [
|
||
vid_id
|
||
for vid_id, vid_data in existing_videos.items()
|
||
if not vid_data.get("is_shared")
|
||
and vid_data.get("source_file")
|
||
and not (videos_dir / vid_data["source_file"]).exists()
|
||
]
|
||
for vid_id in stale_keys:
|
||
del existing_videos[vid_id]
|
||
removed_count += 1
|
||
if verbose:
|
||
print(f" Removed stale entry: {vid_id}")
|
||
if removed_count > 0:
|
||
print(f" Removed {removed_count} stale video(s) from {videos_json_path.name}")
|
||
|
||
# Get available filter presets from config
|
||
default_filters = config.default_filters if config else {}
|
||
|
||
# Add new videos (don't overwrite existing)
|
||
added_count = 0
|
||
for video_file in sorted(video_files):
|
||
# Use filename without extension as video_id (lowercase for new entries)
|
||
video_id = video_file.stem.lower()
|
||
|
||
if video_id in existing_videos:
|
||
if verbose:
|
||
print(f" Skipping {video_id} (already exists)")
|
||
continue
|
||
|
||
# Build the video entry with every tweakable parameter spelled out at
|
||
# its default, so nothing is an invisible optional (that's what makes
|
||
# end_on/layer/take/… easy to miss). Auto-managed fields (duration,
|
||
# has_audio) are filled in later by the metadata probe.
|
||
video_entry = {"source_file": video_file.name, **_VIDEO_DEFAULTS}
|
||
if verbose:
|
||
print(f" Added: {video_id}")
|
||
|
||
existing_videos[video_id] = video_entry
|
||
added_count += 1
|
||
|
||
# Backfill missing defaults onto existing (non-shared) entries so every knob
|
||
# is visible there too. setdefault only adds absent keys — user-set values
|
||
# and the ETL-projected cutout/layer are never overwritten.
|
||
backfilled = 0
|
||
for vid_id, entry in existing_videos.items():
|
||
if not isinstance(entry, dict) or entry.get("is_shared"):
|
||
continue
|
||
before = len(entry)
|
||
for k, v in _VIDEO_DEFAULTS.items():
|
||
entry.setdefault(k, list(v) if isinstance(v, list) else v)
|
||
if len(entry) != before:
|
||
backfilled += 1
|
||
|
||
if added_count > 0 or removed_count > 0 or backfilled > 0:
|
||
# Write updated videos.json
|
||
with open(videos_json_path, "w", encoding="utf-8") as f:
|
||
json.dump(existing_videos, f, indent=2)
|
||
bits = []
|
||
if added_count:
|
||
bits.append(f"+{added_count} new")
|
||
if backfilled:
|
||
bits.append(f"{backfilled} backfilled with defaults")
|
||
if bits:
|
||
print(f" Updated {videos_json_path.name} ({', '.join(bits)})")
|
||
else:
|
||
print(f" No new videos to add")
|
||
|
||
|
||
def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> None:
|
||
"""Import narration video files into narration.json.
|
||
|
||
Folder structure:
|
||
media/narration/raw_mov/ ← raw recordings from iPhone/QuickTime
|
||
media/narration/processed/ ← chroma-keyed output (preprocess)
|
||
media/narration/narration.json
|
||
|
||
Scans processed/ for ready-to-render files and raw/ for any new raw
|
||
recordings not yet represented in narration.json.
|
||
|
||
Priority: processed/ files define the segment catalogue.
|
||
Raw files discovered in raw/ add new entries pointing at raw/ with
|
||
output_file preset to processed/<stem>_processed.mov.
|
||
"""
|
||
video_extensions = {".mov", ".mp4", ".webm", ".avi", ".mkv", ".m4v"}
|
||
|
||
processed_dir = narration_dir / "processed"
|
||
raw_dir = narration_dir / "raw_mov"
|
||
processed_dir.mkdir(parents=True, exist_ok=True)
|
||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Load / create narration.json
|
||
narration_json_path = narration_dir / "narration.json"
|
||
existing_narration: dict = {}
|
||
if narration_json_path.exists():
|
||
existing_narration = _read_json(narration_json_path)
|
||
|
||
# Remove stale entries: no raw_mov file and source_file also gone
|
||
_raw_video_exts_set = {".mov", ".mp4", ".avi", ".mkv", ".m4v"}
|
||
raw_stems: set[str] = set()
|
||
if raw_dir.exists():
|
||
for _f in raw_dir.iterdir():
|
||
if _f.is_file() and _f.suffix.lower() in _raw_video_exts_set:
|
||
raw_stems.add(_f.stem.lower())
|
||
|
||
stale_keys = [
|
||
seg_id
|
||
for seg_id, seg_data in existing_narration.items()
|
||
if seg_id.lower() not in raw_stems
|
||
and not (narration_dir / seg_data.get("source_file", "")).exists()
|
||
]
|
||
for seg_id in stale_keys:
|
||
del existing_narration[seg_id]
|
||
print(f" Removed stale narration segment: {seg_id}")
|
||
|
||
# Normalise keys to lowercase, merging case-duplicates by keeping the entry
|
||
# with more data (skip/take values are the most important thing to preserve).
|
||
normalised: dict = {}
|
||
merged_count = 0
|
||
for seg_id, seg_data in existing_narration.items():
|
||
lower_id = seg_id.lower()
|
||
if lower_id in normalised:
|
||
existing_has_trim = "skip" in normalised[lower_id] or "take" in normalised[lower_id]
|
||
incoming_has_trim = "skip" in seg_data or "take" in seg_data
|
||
if incoming_has_trim and not existing_has_trim:
|
||
normalised[lower_id] = seg_data
|
||
merged_count += 1
|
||
print(f" Merged duplicate narration segment: '{seg_id}' → '{lower_id}'")
|
||
else:
|
||
normalised[lower_id] = seg_data
|
||
existing_narration = normalised
|
||
|
||
# Migrate legacy entries to the raw/processed split. Older runs stored the
|
||
# processed output in source_file (e.g. "processed/S1-end_processed.mov").
|
||
# New model: source_file = raw recording, processed_file = processed output,
|
||
# so the project can be rendered from raw before the preprocess stage runs.
|
||
migrated_count = 0
|
||
for seg_id, entry in existing_narration.items():
|
||
changed = False
|
||
|
||
# Fold the legacy "output_file" key into "processed_file".
|
||
if "output_file" in entry:
|
||
entry.setdefault("processed_file", entry.pop("output_file"))
|
||
changed = True
|
||
|
||
# Older runs stored the processed output in source_file itself.
|
||
src = entry.get("source_file", "")
|
||
if src.startswith("processed/") or "_processed." in src:
|
||
# Preserve the processed path under processed_file (don't clobber an
|
||
# explicit one the user already set).
|
||
entry.setdefault("processed_file", src)
|
||
# Repoint source_file at the raw recording when we can find it.
|
||
raw_match = next(
|
||
(
|
||
f
|
||
for f in (raw_dir.iterdir() if raw_dir.exists() else [])
|
||
if f.is_file()
|
||
and f.suffix.lower() in _raw_video_exts_set
|
||
and f.stem.lower() == seg_id.lower()
|
||
),
|
||
None,
|
||
)
|
||
if raw_match:
|
||
entry["source_file"] = f"raw_mov/{raw_match.name}"
|
||
changed = True
|
||
# else: no raw available — leave source_file as the processed file so
|
||
# the segment still renders; it just can't be re-preprocessed from raw.
|
||
|
||
if changed:
|
||
migrated_count += 1
|
||
|
||
default_filters = config.default_filters if config else {}
|
||
added_count = 0
|
||
|
||
def _scan(directory: Path) -> list[Path]:
|
||
if not directory.exists():
|
||
return []
|
||
return sorted(
|
||
f
|
||
for f in directory.iterdir()
|
||
if f.is_file()
|
||
and f.suffix.lower() in video_extensions
|
||
and not f.name.startswith(".")
|
||
)
|
||
|
||
# 1. Scan processed/ — only add entries when NO raw_mov equivalent exists.
|
||
# If raw_mov has the source, step 2 will create the entry pointing there
|
||
# (with the filter chain), which is better for re-processing later.
|
||
_raw_video_exts = {".mov", ".mp4", ".avi", ".mkv", ".m4v"}
|
||
for video_file in _scan(processed_dir):
|
||
segment_id = video_file.stem
|
||
# Strip _processed suffix for cleaner segment IDs if present
|
||
if segment_id.endswith("_processed"):
|
||
segment_id = segment_id[:-10]
|
||
segment_id = segment_id.lower()
|
||
|
||
if segment_id in existing_narration:
|
||
if verbose:
|
||
print(f" Skipping {segment_id} (already exists)")
|
||
continue
|
||
|
||
# If a raw_mov equivalent exists, skip — step 2 will handle it.
|
||
# Compare stems case-INSENSITIVELY: on a case-sensitive disk a raw file
|
||
# "S1-end.mov" must still match the lowercased segment id "s1-end", or we
|
||
# wrongly add a duplicate processed/ entry alongside the raw-based one.
|
||
raw_mov_has_file = raw_dir.exists() and any(
|
||
f.is_file()
|
||
and f.suffix.lower() in _raw_video_exts
|
||
and f.stem.lower() == segment_id
|
||
for f in raw_dir.iterdir()
|
||
)
|
||
if raw_mov_has_file:
|
||
continue
|
||
|
||
narration_entry = {
|
||
"source_file": f"processed/{video_file.name}",
|
||
}
|
||
narration_entry["use_audio_channels"] = "auto"
|
||
# Loudnorm is applied per-segment during preprocess (not deferred to
|
||
# preprocess), so the processed files are already normalized and ready to be
|
||
# concatenated directly at render time.
|
||
narration_entry["defer_loudnorm"] = False
|
||
|
||
existing_narration[segment_id] = narration_entry
|
||
added_count += 1
|
||
print(f" Added narration segment: {segment_id} (from processed/)")
|
||
|
||
# 2. Scan raw/ — add entries for raw files not yet in narration.json
|
||
for video_file in _scan(raw_dir):
|
||
segment_id = video_file.stem.lower()
|
||
|
||
if segment_id in existing_narration:
|
||
if verbose:
|
||
print(f" Skipping {segment_id} (already exists)")
|
||
continue
|
||
|
||
narration_entry = {
|
||
"source_file": f"raw_mov/{video_file.name}",
|
||
"processed_file": f"processed/{video_file.stem}_processed.mov",
|
||
}
|
||
|
||
if "talkinghead" in default_filters:
|
||
narration_entry["cutout"] = "talkinghead"
|
||
narration_entry["filter"] = "talkinghead"
|
||
|
||
narration_entry["use_audio_channels"] = "auto"
|
||
# Loudnorm is applied per-segment during preprocess (not deferred to
|
||
# preprocess), so the processed files are already normalized and ready to be
|
||
# concatenated directly at render time.
|
||
narration_entry["defer_loudnorm"] = False
|
||
|
||
existing_narration[segment_id] = narration_entry
|
||
added_count += 1
|
||
print(f" Added narration segment: {segment_id} (from raw_mov)")
|
||
|
||
removed_count = len(stale_keys)
|
||
if (
|
||
added_count > 0
|
||
or removed_count > 0
|
||
or merged_count > 0
|
||
or migrated_count > 0
|
||
or not narration_json_path.exists()
|
||
):
|
||
with open(narration_json_path, "w", encoding="utf-8") as f:
|
||
json.dump(existing_narration, f, indent=2)
|
||
|
||
if added_count > 0 or removed_count > 0 or merged_count > 0 or migrated_count > 0:
|
||
parts = []
|
||
if added_count:
|
||
parts.append(f"+{added_count}")
|
||
if removed_count:
|
||
parts.append(f"-{removed_count}")
|
||
if merged_count:
|
||
parts.append(f"merged {merged_count} duplicate(s)")
|
||
if migrated_count:
|
||
parts.append(f"migrated {migrated_count} to raw/processed split")
|
||
print(f" Updated narration.json ({', '.join(parts)} segments)")
|
||
else:
|
||
if not existing_narration:
|
||
print(f" narration.json created (empty — add files to processed/ or raw/)")
|
||
else:
|
||
print(f" No new narration segments to add")
|
||
|
||
|
||
def _write_youtube_meta(
|
||
project_path: Path, config, citations: list[str]
|
||
) -> None:
|
||
"""Write youtube_meta.txt with project description and collected citations."""
|
||
meta_path = project_path / "youtube_meta.txt"
|
||
lines: list[str] = []
|
||
|
||
if config and config.description:
|
||
lines.append("== Description ==")
|
||
lines.append(config.description)
|
||
lines.append("")
|
||
|
||
if citations:
|
||
lines.append("== References ==")
|
||
for i, cite in enumerate(citations, 1):
|
||
lines.append(f"{i}. {cite}")
|
||
lines.append("")
|
||
|
||
meta_path.write_text("\n".join(lines), encoding="utf-8")
|
||
print(f" Wrote {meta_path.name} ({len(citations)} reference(s))")
|
||
|
||
|
||
def _import_presenter_notes(
|
||
project_path: Path, keynote_file: Path, verbose: bool, config=None
|
||
) -> None:
|
||
"""Extract presenter notes from Keynote and write to manuscript.txt.
|
||
|
||
[cite:...] markers are stripped from the manuscript and collected into
|
||
youtube_meta.txt alongside the project description.
|
||
|
||
Uses the JXA script (extract_keynote_notes.js) to extract notes via osascript.
|
||
Also exports slides as PNG images to media/slides/{project_name}/.
|
||
Backs up existing manuscript.txt before overwriting.
|
||
"""
|
||
# osascript is macOS-only; skip gracefully on WSL/Linux/Windows
|
||
if shutil.which("osascript") is None:
|
||
print(
|
||
f" Warning: osascript not available (not macOS) — skipping Keynote import for {keynote_file.name}.",
|
||
file=sys.stderr,
|
||
)
|
||
return
|
||
|
||
print(f" Extracting presenter notes from {keynote_file.name}...")
|
||
|
||
# Find the JXA script (in the same directory as this module)
|
||
script_dir = Path(__file__).parent
|
||
jxa_script = script_dir / "extract_keynote_notes.js"
|
||
|
||
if not jxa_script.exists():
|
||
print(f" Error: JXA script not found at {jxa_script}", file=sys.stderr)
|
||
return
|
||
|
||
# Backup existing manuscript.txt if it exists
|
||
manuscript_path = project_path / "manuscript.txt"
|
||
if manuscript_path.exists():
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
backup_path = project_path / f"manuscript.txt.{timestamp}.bak"
|
||
shutil.copy2(manuscript_path, backup_path)
|
||
if verbose:
|
||
print(f" Backed up manuscript.txt to {backup_path.name}")
|
||
|
||
# Slides export directory: {project}/media/slides/{project_name}/
|
||
# Use lowercase so the path is consistent on case-sensitive filesystems (WSL/Linux).
|
||
slides_dir = project_path / "media" / "slides" / project_path.name.lower()
|
||
print(f" Exporting slides to {slides_dir}...")
|
||
|
||
# Run JXA extractor via osascript (also exports slides)
|
||
proc = subprocess.run(
|
||
[
|
||
"osascript",
|
||
"-l",
|
||
"JavaScript",
|
||
str(jxa_script),
|
||
str(keynote_file.resolve()),
|
||
str(slides_dir.resolve()),
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
|
||
if proc.returncode != 0:
|
||
print(f" Error extracting presenter notes:", file=sys.stderr)
|
||
print(f" {proc.stderr}", file=sys.stderr)
|
||
return
|
||
|
||
# Parse JSON output from JXA script
|
||
try:
|
||
notes_data = json.loads(proc.stdout) if proc.stdout.strip() else []
|
||
except json.JSONDecodeError as e:
|
||
print(f" Error parsing notes JSON: {e}", file=sys.stderr)
|
||
return
|
||
|
||
# Convert to manuscript.txt format, stripping [cite:...] markers
|
||
_CITE_RE = re.compile(r"\[cite:([^\]]+)\]")
|
||
lines = []
|
||
citations: list[str] = []
|
||
seen_citations: set[str] = set()
|
||
|
||
for item in notes_data:
|
||
idx = item.get("slide_index")
|
||
notes = (item.get("notes") or "").rstrip()
|
||
|
||
lines.append(f"[S{idx}]")
|
||
if notes:
|
||
clean_note_lines = []
|
||
for note_line in notes.splitlines():
|
||
for m in _CITE_RE.finditer(note_line):
|
||
cite_text = m.group(1).strip()
|
||
if cite_text not in seen_citations:
|
||
citations.append(cite_text)
|
||
seen_citations.add(cite_text)
|
||
cleaned = _CITE_RE.sub("", note_line).strip()
|
||
if cleaned:
|
||
clean_note_lines.append(cleaned)
|
||
if clean_note_lines:
|
||
lines.append("\n".join(clean_note_lines))
|
||
lines.append("") # blank line between slides
|
||
|
||
# Write manuscript.txt with Unix line endings (Keynote notes may contain \r\n or \r)
|
||
content = "\n".join(lines).rstrip() + "\n"
|
||
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||
manuscript_path.write_text(content, encoding="utf-8")
|
||
print(f" Wrote {manuscript_path} ({len(notes_data)} slides)")
|
||
|
||
# Write youtube_meta.txt with description + collected citations
|
||
_write_youtube_meta(project_path, config, citations)
|
||
if citations and verbose:
|
||
for i, cite in enumerate(citations, 1):
|
||
print(f" {i}. {cite}")
|
||
|
||
if verbose:
|
||
non_empty = sum(1 for item in notes_data if item.get("notes"))
|
||
print(f" {non_empty} slides have presenter notes")
|
||
|
||
|
||
# =============================================================================
|
||
# Tasks File
|
||
# =============================================================================
|
||
|
||
_TASKS_VIDEO_PREFIXES = {
|
||
"video:": 6,
|
||
"vft:": 4,
|
||
"vfb:": 4,
|
||
"vf2t:": 5,
|
||
"vf2b:": 5,
|
||
"vst:": 4,
|
||
"vsb:": 4,
|
||
"vftp:": 5,
|
||
"vfbp:": 5,
|
||
"vf2tp:": 6,
|
||
"vf2bp:": 6,
|
||
"vstp:": 5,
|
||
"vsbp:": 5,
|
||
"narration:": 10,
|
||
}
|
||
|
||
|
||
def _collect_missing_video_markers(
|
||
markers: list[str], videos: dict
|
||
) -> list[tuple[str, str]]:
|
||
"""Return (marker_text, video_id) for video markers not defined in videos.json."""
|
||
missing = []
|
||
seen = set()
|
||
for marker in markers:
|
||
matched = next((p for p in _TASKS_VIDEO_PREFIXES if marker.startswith(p)), None)
|
||
if matched is None:
|
||
continue
|
||
# Lowercase to match videos.json keys (import stores handles lowercased);
|
||
# otherwise a mixed-case marker like [vst:XystonGlider] falsely reports the
|
||
# handle "missing" even though "xystonglider" is defined.
|
||
video_id = marker[_TASKS_VIDEO_PREFIXES[matched] :].lower()
|
||
if video_id not in videos and video_id not in seen:
|
||
seen.add(video_id)
|
||
missing.append((marker, video_id))
|
||
return missing
|
||
|
||
|
||
def _write_tasks_file(
|
||
project_path: Path,
|
||
missing_videos: list[tuple[str, str]],
|
||
alignment_issues: list[tuple[str, str]],
|
||
) -> None:
|
||
"""Write tasks.md to project_path with missing assets and alignment issues."""
|
||
tasks_path = project_path / "tasks.md"
|
||
today = datetime.now().strftime("%Y-%m-%d")
|
||
|
||
lines = [
|
||
f"# Tasks: {project_path.name}",
|
||
f"_Generated: {today}_",
|
||
"",
|
||
]
|
||
|
||
if missing_videos:
|
||
lines += [
|
||
f"## Missing Video Assets ({len(missing_videos)})",
|
||
"Referenced in manuscript.txt but not defined in videos.json.",
|
||
"",
|
||
]
|
||
for marker, video_id in missing_videos:
|
||
lines.append(f"- [ ] `{video_id}` — referenced as `[{marker}]`")
|
||
lines.append("")
|
||
|
||
if alignment_issues:
|
||
lines += [
|
||
f"## Slide Alignment Issues ({len(alignment_issues)})",
|
||
"Slide markers that could not be matched to the spoken narration (likely adlibbed).",
|
||
"",
|
||
]
|
||
for marker_id, context in alignment_issues:
|
||
lines.append(f'- [ ] `{marker_id}` — _"{context}"_')
|
||
lines.append("")
|
||
|
||
if not missing_videos and not alignment_issues:
|
||
lines += ["_No outstanding tasks._", ""]
|
||
|
||
tasks_path.write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
# Also surface the tasks in the terminal — otherwise the only signal is a file
|
||
# you have to open. Empty is stated plainly (not a misleading "Tasks written").
|
||
if not missing_videos and not alignment_issues:
|
||
print(" No outstanding tasks.")
|
||
return
|
||
|
||
total = len(missing_videos) + len(alignment_issues)
|
||
print(f" {total} task(s) → tasks.md:")
|
||
for marker, video_id in missing_videos:
|
||
print(f" - missing video '{video_id}' (referenced as [{marker}])")
|
||
for marker_id, context in alignment_issues:
|
||
print(f' - unaligned slide {marker_id} ("{context[:50]}")')
|
||
|
||
|
||
# =============================================================================
|
||
# Pexels Download Command
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_pexels(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
search_query: Optional[str] = None,
|
||
search_max: int = 200,
|
||
) -> int:
|
||
"""Download missing Pexels videos and enrich metadata for existing ones."""
|
||
from .parser import parse_manuscript, parse_project_config, parse_videos
|
||
from .pexels import (
|
||
get_pexels_api_key,
|
||
find_missing_pexels_videos,
|
||
download_video,
|
||
update_videos_json,
|
||
enrich_missing_descriptions,
|
||
search_and_download,
|
||
)
|
||
|
||
api_key = get_pexels_api_key()
|
||
if not api_key:
|
||
print(
|
||
"Error: Pexels API key not configured.\n"
|
||
"Add to ~/.gnommo.conf:\n"
|
||
" [pexels]\n"
|
||
" api_key = YOUR_KEY_HERE\n"
|
||
"Get a free key at https://www.pexels.com/api/",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
# --- Search mode: download all results for a query to the assets disk ---
|
||
if search_query:
|
||
from .cache import load_assets_config
|
||
|
||
# Find shared_assets: prefer assets disk (LaCie), fall back to local
|
||
assets_root = load_assets_config()
|
||
if assets_root and assets_root.exists():
|
||
pexels_dir = assets_root / "shared_assets" / "pexels"
|
||
# Use the assets disk's shared_assets/videos.json as the registry
|
||
shared_videos_json = assets_root / "shared_assets" / "videos.json"
|
||
print(f"Downloading to: {pexels_dir}")
|
||
else:
|
||
# No assets disk — find local shared_assets from project_path or cwd
|
||
local_shared = _find_shared_assets(project_path)
|
||
if not local_shared:
|
||
print("Error: shared_assets directory not found.", file=sys.stderr)
|
||
return 1
|
||
pexels_dir = local_shared / "pexels"
|
||
shared_videos_json = local_shared / "videos.json"
|
||
print(f"Assets disk not found — downloading to local: {pexels_dir}")
|
||
|
||
dl, sk = search_and_download(
|
||
search_query, pexels_dir, shared_videos_json, api_key, max_results=search_max
|
||
)
|
||
print(f"\nDone — {dl} downloaded, {sk} already present.")
|
||
return 0
|
||
|
||
shared_assets_dir = _find_shared_assets(project_path)
|
||
if not shared_assets_dir:
|
||
print("Error: shared_assets directory not found.", file=sys.stderr)
|
||
return 1
|
||
|
||
shared_videos_json = shared_assets_dir / "videos.json"
|
||
|
||
# --- Normal mode: download missing files referenced in the manuscript ---
|
||
config = parse_project_config(project_path)
|
||
_, markers, _, _ = parse_manuscript(project_path)
|
||
videos, _ = parse_videos(project_path, config)
|
||
|
||
local_videos_json = project_path / config.videos_path
|
||
|
||
# 1. Collect missing files: manuscript markers + background
|
||
missing = find_missing_pexels_videos(markers, videos, shared_assets_dir)
|
||
|
||
# Also check background video from project.json
|
||
if config and config.background and config.background.startswith("pexels/"):
|
||
from .cache import resolve_with_cache
|
||
|
||
bg_id = config.background
|
||
shared_vids_raw = _read_json(shared_videos_json) if shared_videos_json.exists() else {}
|
||
bg_entry = shared_vids_raw.get(bg_id)
|
||
if bg_entry:
|
||
bg_sf = bg_entry.get("source_file", "")
|
||
bg_path = shared_assets_dir / bg_sf
|
||
resolved, _ = resolve_with_cache(bg_path, shared_assets_dir)
|
||
if not resolved.exists():
|
||
# Only add if not already in the manuscript-marker list
|
||
if not any(vid_id == bg_id for vid_id, _ in missing):
|
||
missing.append((bg_id, bg_sf))
|
||
|
||
failed = 0
|
||
if missing:
|
||
print(f"Downloading {len(missing)} missing Pexels video(s)...")
|
||
for video_id, source_file in missing:
|
||
meta = download_video(source_file, shared_assets_dir, api_key)
|
||
if meta is None:
|
||
failed += 1
|
||
continue
|
||
for json_path in (local_videos_json, shared_videos_json):
|
||
update_videos_json(json_path, video_id, meta)
|
||
if failed:
|
||
print(f"\n {failed}/{len(missing)} download(s) failed.")
|
||
else:
|
||
print(f"\n {len(missing)} video(s) downloaded.")
|
||
else:
|
||
print("No missing Pexels videos.")
|
||
|
||
# 2. Enrich descriptions for existing files that have none
|
||
enrich_missing_descriptions(shared_assets_dir, api_key)
|
||
|
||
return 1 if failed else 0
|
||
|
||
|
||
# =============================================================================
|
||
# Validate Command
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_validate(project_path: Path, verbose: bool) -> int:
|
||
"""Validate project configuration."""
|
||
from .parser import (
|
||
parse_audio,
|
||
parse_manuscript,
|
||
parse_project_config,
|
||
parse_slides,
|
||
parse_videos,
|
||
)
|
||
from .validator import validate_project
|
||
|
||
print(f"Validating: {project_path.name}")
|
||
|
||
if not (project_path / "project.json").exists():
|
||
print(f"Error: project.json not found in {project_path}", file=sys.stderr)
|
||
return 1
|
||
|
||
# Parse all files
|
||
_, markers, malformed, _ = parse_manuscript(project_path)
|
||
config = parse_project_config(project_path)
|
||
slides = parse_slides(project_path, config)
|
||
videos, videos_dir = parse_videos(project_path, config)
|
||
audio, _ = parse_audio(project_path, config)
|
||
|
||
if verbose:
|
||
print(f" - Markers in manuscript: {len(markers)}")
|
||
print(f" - Slides defined: {len(slides)}")
|
||
print(f" - Videos defined: {len(videos)}")
|
||
print(f" - Audio cues defined: {len(audio)}")
|
||
|
||
# Validate
|
||
warnings = validate_project(
|
||
project_path, markers, config, slides, videos, videos_dir, malformed, audio
|
||
)
|
||
for w in warnings:
|
||
print(f" Warning: {w}")
|
||
|
||
# Write tasks file (missing assets only — no alignment data at validate time)
|
||
missing_videos = _collect_missing_video_markers(markers, videos)
|
||
_write_tasks_file(project_path, missing_videos, alignment_issues=[])
|
||
|
||
print("Validation passed.")
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# Preprocess Command
|
||
# =============================================================================
|
||
|
||
|
||
def _resolve_process_cache(project_path: Path, config) -> Optional[Path]:
|
||
"""Return per-project cache dir on an external disk, or None if none is accessible.
|
||
|
||
Resolution order:
|
||
1. config.process_cache (project.json) — e.g. /Volumes/GnommoDisk/gnommocache
|
||
Must already exist (disk must be mounted and dir pre-created).
|
||
2. [assets] process cache (~/.gnommo.conf) — derived as <assets_path>cache
|
||
e.g. /Volumes/LaCie Jens/Projects/gnommocache.
|
||
Only requires the parent to exist (volume mounted); the cache dir
|
||
itself is created on first use by the caller.
|
||
"""
|
||
from .cache import load_assets_process_cache
|
||
|
||
# Primary: explicitly configured in project.json
|
||
if config and config.process_cache:
|
||
p = Path(config.process_cache)
|
||
if not p.is_absolute():
|
||
p = (project_path / p).resolve()
|
||
if p.exists():
|
||
return p / project_path.name
|
||
|
||
# Fallback: [assets] disk (need only the parent volume/dir to be present)
|
||
assets_cache = load_assets_process_cache()
|
||
if assets_cache and assets_cache.parent.exists():
|
||
return assets_cache / project_path.name
|
||
|
||
return None
|
||
|
||
|
||
def cmd_new(project_path: Path, verbose: bool) -> int:
|
||
"""Create a new gnommo project with standard folder structure and a project.json template."""
|
||
project_name = project_path.name
|
||
project_id = project_name
|
||
|
||
if project_path.exists() and list(project_path.iterdir()):
|
||
print(f"Initialising project: {project_path} (folder exists, filling in missing structure)")
|
||
else:
|
||
print(f"Creating new project: {project_path}")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Directories #
|
||
# ------------------------------------------------------------------ #
|
||
dirs = [
|
||
project_path,
|
||
project_path / "media" / "videos",
|
||
project_path / "media" / "audio",
|
||
project_path / "media" / "narration" / "raw_mov",
|
||
project_path / "media" / "slides",
|
||
project_path / "out",
|
||
]
|
||
for d in dirs:
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
if verbose:
|
||
print(f" mkdir {d.relative_to(project_path.parent)}")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Copy talkinghead filter from the nearest sibling project #
|
||
# ------------------------------------------------------------------ #
|
||
talkinghead_filter = None
|
||
for sibling in sorted(project_path.parent.iterdir()):
|
||
if sibling == project_path or not sibling.is_dir():
|
||
continue
|
||
sib_json = sibling / "project.json"
|
||
if sib_json.exists():
|
||
try:
|
||
sib_cfg = json.loads(sib_json.read_text(encoding="utf-8"))
|
||
talkinghead_filter = (sib_cfg.get("default_filters") or {}).get("talkinghead")
|
||
if talkinghead_filter:
|
||
print(f" Copied talkinghead filter from: {sibling.name}/project.json")
|
||
break
|
||
except (json.JSONDecodeError, OSError):
|
||
pass
|
||
|
||
if not talkinghead_filter:
|
||
# No sibling project to copy from — fall back to the repo-root defaults.
|
||
# User should tweak gnommokey values for their camera.
|
||
talkinghead_filter = load_filter_defaults().get("talkinghead")
|
||
if talkinghead_filter:
|
||
print(
|
||
" Using default talkinghead filter from filter_defaults.json "
|
||
"(adjust gnommokey values for your camera)"
|
||
)
|
||
else:
|
||
print(
|
||
" WARNING: no 'talkinghead' filter in filter_defaults.json — "
|
||
"project.json will have an empty talkinghead filter"
|
||
)
|
||
talkinghead_filter = []
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# project.json #
|
||
# ------------------------------------------------------------------ #
|
||
project_json_path = project_path / "project.json"
|
||
if not project_json_path.exists():
|
||
project_data = {
|
||
"id": project_id,
|
||
"name": "",
|
||
"description": "",
|
||
"platform_targets": ["youtube"],
|
||
"status": "scripted",
|
||
"resolution": [1920, 1080],
|
||
"fps": 30,
|
||
"manuscript": "manuscript.txt",
|
||
"videos": "media/videos/videos.json",
|
||
"narration": "media/narration/narration.json",
|
||
"slides": f"media/slides/{project_id}/slides.json",
|
||
"audio": "media/audio/audio.json",
|
||
"output_video": f"{project_id}.mp4",
|
||
"default_filters": {"talkinghead": talkinghead_filter},
|
||
"cutouts": {
|
||
"talkinghead": {"x": "-10%", "y": "40%", "height": "80%"},
|
||
"square": {"x": "46.5%", "y": "4.5%", "width": "50%", "height": "90%"},
|
||
"fullscreen": {"x": "0%", "y": "0%", "height": "100%"},
|
||
"fullscreen2": {"x": "10%", "y": "7%", "height": "80%"},
|
||
},
|
||
}
|
||
project_json_path.write_text(
|
||
json.dumps(project_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||
)
|
||
print(" Created: project.json")
|
||
else:
|
||
print(" Skipped: project.json (already exists)")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Stub JSON files #
|
||
# ------------------------------------------------------------------ #
|
||
stubs: dict[str, object] = {
|
||
"media/videos/videos.json": {},
|
||
"media/audio/audio.json": {},
|
||
"media/narration/narration.json": {},
|
||
}
|
||
for rel, content in stubs.items():
|
||
p = project_path / rel
|
||
if not p.exists():
|
||
p.write_text(json.dumps(content, indent=2), encoding="utf-8")
|
||
print(f" Created: {rel}")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Manuscript template #
|
||
# ------------------------------------------------------------------ #
|
||
manuscript_path = project_path / "manuscript.txt"
|
||
if not manuscript_path.exists():
|
||
manuscript_path.write_text(
|
||
"[S1]\nYour narration for slide 1 goes here.\n\n[S2]\n\n",
|
||
encoding="utf-8",
|
||
)
|
||
print(" Created: manuscript.txt")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Instructions #
|
||
# ------------------------------------------------------------------ #
|
||
print(f"""
|
||
Done. Here is what to do next:
|
||
|
||
1. Place your Keynote presentation (.key) in the project folder:
|
||
{project_path}/
|
||
|
||
2. Record your talking head segments using a teleprompter.
|
||
Name each recording after the slide range it covers:
|
||
|
||
S1-10.mov covers slides 1 – 10
|
||
S11-32.mov covers slides 11 – 32
|
||
S33-end.mov covers slides 33 to the end
|
||
|
||
Place the recordings in:
|
||
{project_path}/media/narration/raw_mov/
|
||
|
||
3. Edit manuscript.txt so the spoken words appear under the right [SN] marker.
|
||
Add [vfb:Logo6sec] or other video markers where needed.
|
||
|
||
4. Run the pipeline step by step:
|
||
|
||
gnommo -p {project_name} import # extract slides from Keynote
|
||
gnommo -p {project_name} pre # chroma key + audio normalise
|
||
gnommo -p {project_name} trim # auto-detect skip/take per segment
|
||
gnommo -p {project_name} render # produce the final video
|
||
|
||
Or run everything in one go:
|
||
gnommo -p {project_name} all
|
||
|
||
Output: {project_path}/out/{project_id}.mp4
|
||
""")
|
||
return 0
|
||
|
||
|
||
def cmd_clear(project_path: Path, verbose: bool) -> int:
|
||
"""Delete preprocessed outputs so that 'preprocess' re-runs them from scratch.
|
||
|
||
Removes *_processed.mov files from the processed/ directory (or the
|
||
process cache on the external disk if one is configured). narration.json
|
||
skip/take values and raw source files are NOT touched.
|
||
"""
|
||
from .parser import parse_project_config
|
||
|
||
print(f"Clearing preprocessed outputs: {project_path.name}")
|
||
|
||
config = parse_project_config(project_path)
|
||
narration_dir = project_path / "media" / "narration"
|
||
cache_root = _resolve_process_cache(project_path, config)
|
||
|
||
if cache_root:
|
||
processed_dir = cache_root / "media" / "narration" / "processed"
|
||
print(f" Cache: {processed_dir}")
|
||
else:
|
||
processed_dir = narration_dir / "processed"
|
||
print(f" Local: {processed_dir}")
|
||
|
||
if not processed_dir.exists():
|
||
print(" Nothing to clear — processed/ directory does not exist.")
|
||
return 0
|
||
|
||
candidates = sorted(
|
||
f for f in processed_dir.iterdir()
|
||
if f.is_file() and "_processed" in f.stem
|
||
)
|
||
|
||
if not candidates:
|
||
print(" Nothing to clear — no *_processed.* files found.")
|
||
return 0
|
||
|
||
total_bytes = 0
|
||
for f in candidates:
|
||
size = f.stat().st_size
|
||
total_bytes += size
|
||
print(f" Deleting {f.name} ({size / 1e9:.2f} GB)")
|
||
f.unlink()
|
||
|
||
print(f"\n Cleared {len(candidates)} file(s), freed {total_bytes / 1e9:.2f} GB.")
|
||
print(" Run 'gnommo preprocess' (without --force) to reprocess selectively.")
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# Prune Command
|
||
# =============================================================================
|
||
|
||
# Marker prefixes that reference a videos.json entry, mapped to the length of the
|
||
# prefix (so marker[length:] is the video ID). Kept in sync with validator.py.
|
||
_PRUNE_VIDEO_PREFIXES = {
|
||
"video:": 6,
|
||
"vft:": 4, "vfb:": 4, "vfm:": 4,
|
||
"vf2t:": 5, "vf2b:": 5, "vf2m:": 5,
|
||
"vst:": 4, "vsb:": 4, "vsm:": 4,
|
||
"vftp:": 5, "vfbp:": 5, "vfmp:": 5,
|
||
"vf2tp:": 6, "vf2bp:": 6, "vf2mp:": 6,
|
||
"vstp:": 5, "vsbp:": 5, "vsmp:": 5,
|
||
}
|
||
|
||
|
||
def _detect_json_indent(path: Path, default: int = 2) -> int:
|
||
"""Return the indentation width of the first indented line in a JSON file."""
|
||
try:
|
||
for line in path.read_text(encoding="utf-8").splitlines():
|
||
stripped = line.lstrip(" ")
|
||
if stripped and stripped != line:
|
||
return len(line) - len(stripped)
|
||
except OSError:
|
||
pass
|
||
return default
|
||
|
||
|
||
def _write_json_preserve(path: Path, data: dict) -> None:
|
||
"""Rewrite a JSON manifest, preserving its existing indentation style."""
|
||
indent = _detect_json_indent(path)
|
||
text = json.dumps(data, indent=indent, ensure_ascii=False)
|
||
try:
|
||
trailing_nl = path.read_text(encoding="utf-8").endswith("\n")
|
||
except OSError:
|
||
trailing_nl = True
|
||
path.write_text(text + ("\n" if trailing_nl else ""), encoding="utf-8")
|
||
|
||
|
||
def _prune_manifest(
|
||
path: Path,
|
||
keep_ids: set[str],
|
||
label: str,
|
||
lowercase_keys: bool,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
) -> int:
|
||
"""Remove entries from a videos.json/audio.json manifest whose ID isn't in keep_ids.
|
||
|
||
Returns the number of entries removed (or that would be removed, in dry-run mode).
|
||
"""
|
||
if not path.exists():
|
||
return 0
|
||
|
||
try:
|
||
data = _read_json(path)
|
||
except json.JSONDecodeError as e:
|
||
print(f" Skipping {path.name}: invalid JSON ({e})")
|
||
return 0
|
||
if not isinstance(data, dict):
|
||
return 0
|
||
|
||
to_remove = [
|
||
key
|
||
for key in data
|
||
if (key.lower() if lowercase_keys else key) not in keep_ids
|
||
]
|
||
|
||
# Case-collision duplicates: when keys are case-insensitive (videos), several
|
||
# keys can share the same lowercase form (e.g. "KnightRotating" and
|
||
# "knightrotating"). Markers resolve by lowercased id, so only the exact
|
||
# lowercase key is ever reachable — the other case-variants are dead weight.
|
||
# Collapse each surviving collision group to its lowercase canonical.
|
||
dup_remove: list[tuple[str, str]] = [] # (removed_key, kept_canonical_key)
|
||
if lowercase_keys:
|
||
remove_set = set(to_remove)
|
||
groups: dict[str, list[str]] = {}
|
||
for key in data:
|
||
if key in remove_set:
|
||
continue
|
||
groups.setdefault(key.lower(), []).append(key)
|
||
for low, keys in groups.items():
|
||
if len(keys) < 2:
|
||
continue
|
||
# Keep the already-lowercase key (what the renderer resolves); else
|
||
# the first-seen, so at least one entry survives.
|
||
canonical = next((k for k in keys if k == low), keys[0])
|
||
for key in keys:
|
||
if key != canonical:
|
||
dup_remove.append((key, canonical))
|
||
|
||
if not to_remove and not dup_remove:
|
||
if verbose:
|
||
print(f" {path.name}: all {len(data)} {label} entries in use.")
|
||
return 0
|
||
|
||
if to_remove:
|
||
print(f" {path.name}: removing {len(to_remove)} unused {label} entr"
|
||
f"{'y' if len(to_remove) == 1 else 'ies'}:")
|
||
for key in to_remove:
|
||
print(f" - {key}")
|
||
|
||
if dup_remove:
|
||
print(f" {path.name}: removing {len(dup_remove)} case-duplicate {label} entr"
|
||
f"{'y' if len(dup_remove) == 1 else 'ies'}:")
|
||
for key, canonical in dup_remove:
|
||
print(f" - {key} (duplicate of '{canonical}')")
|
||
|
||
if not dry_run:
|
||
for key in to_remove:
|
||
del data[key]
|
||
for key, _ in dup_remove:
|
||
del data[key]
|
||
_write_json_preserve(path, data)
|
||
|
||
return len(to_remove) + len(dup_remove)
|
||
|
||
|
||
def _prune_narration(project_path: Path, verbose: bool, dry_run: bool) -> int:
|
||
"""Remove narration.json entries whose raw source file is missing from raw_mov/.
|
||
|
||
Returns the number of entries removed (or that would be removed, in dry-run mode).
|
||
"""
|
||
narration_dir = project_path / "media" / "narration"
|
||
narration_json = narration_dir / "narration.json"
|
||
if not narration_json.exists():
|
||
return 0
|
||
|
||
try:
|
||
data = _read_json(narration_json)
|
||
except json.JSONDecodeError as e:
|
||
print(f" Skipping narration.json: invalid JSON ({e})")
|
||
return 0
|
||
if not isinstance(data, dict):
|
||
return 0
|
||
|
||
raw_dir = narration_dir / "raw_mov"
|
||
|
||
to_remove = []
|
||
for seg_id, entry in data.items():
|
||
source_file = entry.get("source_file") if isinstance(entry, dict) else None
|
||
if source_file:
|
||
exists = (narration_dir / source_file).exists()
|
||
else:
|
||
# No source_file recorded — look for a matching file in raw_mov/
|
||
exists = bool(list(raw_dir.glob(f"{seg_id}.*"))) if raw_dir.exists() else False
|
||
if not exists:
|
||
to_remove.append(seg_id)
|
||
|
||
if not to_remove:
|
||
if verbose:
|
||
print(f" narration.json: all {len(data)} entries have a raw_mov/ source.")
|
||
return 0
|
||
|
||
print(f" narration.json: removing {len(to_remove)} entr"
|
||
f"{'y' if len(to_remove) == 1 else 'ies'} with no raw_mov/ source:")
|
||
for seg_id in to_remove:
|
||
print(f" - {seg_id}")
|
||
|
||
if not dry_run:
|
||
for seg_id in to_remove:
|
||
del data[seg_id]
|
||
_write_json_preserve(narration_json, data)
|
||
|
||
return len(to_remove)
|
||
|
||
|
||
def cmd_prune(project_path: Path, verbose: bool, dry_run: bool) -> int:
|
||
"""Remove unused entries from videos.json, audio.json, and narration.json.
|
||
|
||
- videos.json / audio.json: drop entries whose IDs are never referenced by a
|
||
marker in manuscript.txt. Videos referenced by project.json (outro sequence,
|
||
main_video) are kept even when no marker triggers them.
|
||
- narration.json: drop entries whose raw source file is missing from
|
||
media/narration/raw_mov/.
|
||
|
||
Only JSON manifest entries are removed — media files on disk are never touched.
|
||
Use --dry-run to preview.
|
||
"""
|
||
from .parser import parse_manuscript, parse_project_config
|
||
|
||
suffix = " (dry run)" if dry_run else ""
|
||
print(f"Pruning manifests: {project_path.name}{suffix}")
|
||
|
||
config = parse_project_config(project_path)
|
||
|
||
# --- Collect IDs referenced by the script (manuscript markers) ---
|
||
_, markers, _, _ = parse_manuscript(project_path)
|
||
|
||
referenced_videos: set[str] = set()
|
||
referenced_audio: set[str] = set()
|
||
for marker in markers:
|
||
prefix = next((p for p in _PRUNE_VIDEO_PREFIXES if marker.startswith(p)), None)
|
||
if prefix is not None:
|
||
referenced_videos.add(marker[_PRUNE_VIDEO_PREFIXES[prefix]:].lower())
|
||
elif marker.startswith("narration:"):
|
||
referenced_videos.add(marker[10:].lower())
|
||
elif marker.startswith("audio:"):
|
||
referenced_audio.add(marker[6:])
|
||
elif marker.startswith("A") and len(marker) > 1 and marker[1:].isalnum():
|
||
referenced_audio.add(marker[1:])
|
||
|
||
# Videos referenced by project.json (outro sequence, main video, background)
|
||
# are kept even though no manuscript marker triggers them.
|
||
def _add_video(value) -> None:
|
||
if isinstance(value, str) and value:
|
||
referenced_videos.add(value.lower())
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
_add_video(item)
|
||
|
||
_add_video(config.outro)
|
||
_add_video(config.main_video)
|
||
_add_video(config.background)
|
||
_add_video(config.background_video)
|
||
|
||
total_removed = 0
|
||
total_removed += _prune_manifest(
|
||
project_path / config.videos_path,
|
||
referenced_videos,
|
||
"video",
|
||
lowercase_keys=True,
|
||
verbose=verbose,
|
||
dry_run=dry_run,
|
||
)
|
||
total_removed += _prune_manifest(
|
||
project_path / config.audio_path,
|
||
referenced_audio,
|
||
"audio",
|
||
lowercase_keys=False,
|
||
verbose=verbose,
|
||
dry_run=dry_run,
|
||
)
|
||
total_removed += _prune_narration(project_path, verbose, dry_run)
|
||
|
||
if total_removed == 0:
|
||
print(" Nothing to prune — all manifest entries are in use.")
|
||
elif dry_run:
|
||
print(f"\n Would remove {total_removed} entr"
|
||
f"{'y' if total_removed == 1 else 'ies'}. "
|
||
f"Re-run without --dry-run to apply.")
|
||
else:
|
||
print(f"\n Removed {total_removed} entr"
|
||
f"{'y' if total_removed == 1 else 'ies'}.")
|
||
return 0
|
||
|
||
|
||
def cmd_preprocess(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
force: bool = False,
|
||
workers: int = 1,
|
||
res: str = "full",
|
||
) -> int:
|
||
"""Run preprocessing pipeline on narration segments and videos.
|
||
|
||
Discovers source files directly from raw_mov/ (preferred) or raw_mp4/
|
||
(fallback when raw_mov/ is empty). Does NOT require narration.json to
|
||
exist — it writes/updates narration.json after processing.
|
||
"""
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from .parser import parse_project_config, parse_videos
|
||
from .preprocessor import preprocess_video, RES_CONFIGS
|
||
from .models import VideoSource as _VideoSource
|
||
from .cache import set_active_project
|
||
|
||
# Apply this project's project.json "performance" overrides (cpu limits etc.).
|
||
set_active_project(project_path)
|
||
|
||
mode_str = f" ({res.upper()})" if res != "full" else ""
|
||
print(f"Preprocessing narration: {project_path.name}{mode_str}")
|
||
|
||
config = parse_project_config(project_path)
|
||
|
||
# Narration directory — source files always in project media/narration/
|
||
narration_dir = project_path / "media" / "narration"
|
||
narration_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
raw_dir = narration_dir / "raw_mov"
|
||
compressed_dir = narration_dir / "raw_mp4"
|
||
|
||
# process_cache: write processed outputs to external disk to save laptop space
|
||
cache_root = _resolve_process_cache(project_path, config)
|
||
if cache_root:
|
||
# Mirror the project's media/ structure so GnommoCache (resolve_with_cache)
|
||
# finds these files transparently during render.
|
||
cache_narration_dir = cache_root / "media" / "narration"
|
||
cache_narration_dir.mkdir(parents=True, exist_ok=True)
|
||
(cache_narration_dir / "processed").mkdir(parents=True, exist_ok=True)
|
||
print(f" Using process cache: {cache_root}")
|
||
else:
|
||
cache_narration_dir = None
|
||
|
||
processed_dir = (cache_narration_dir or narration_dir) / "processed"
|
||
processed_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Remove any .tmp files left by a previously interrupted preprocess run.
|
||
# These are partial outputs that would block the segment from being reprocessed.
|
||
for stale_tmp in processed_dir.glob("*.tmp"):
|
||
print(f" Removing incomplete output from previous run: {stale_tmp.name}")
|
||
stale_tmp.unlink()
|
||
|
||
# Resolve intermediate directory
|
||
gnommo_scratch = None
|
||
if config.gnommo_scratch:
|
||
gnommo_scratch = Path(config.gnommo_scratch)
|
||
if not gnommo_scratch.is_absolute():
|
||
gnommo_scratch = project_path / gnommo_scratch
|
||
print(f" Using intermediate dir: {gnommo_scratch}")
|
||
|
||
# Prepare a segment's intermediate/scratch dir before (re)processing it.
|
||
# A run that crashed mid-file (e.g. the laptop battery dying) leaves behind a
|
||
# mix of COMPLETE chunks and half-written ones. We want to resume from the
|
||
# completed chunks, so we do NOT wipe the dir wholesale: chunked processing
|
||
# validates each chunk and only redoes the missing/partial ones, and all
|
||
# encodes now write to a *.partial sibling that's renamed only on success.
|
||
# Here we just sweep those orphaned *.partial files. With --force, the user
|
||
# is asking for a clean redo, so we clear the whole dir.
|
||
import shutil as _shutil
|
||
|
||
def _clear_segment_scratch(seg_videos_dir: Path, seg_id: str) -> None:
|
||
scratch = (
|
||
gnommo_scratch / seg_id
|
||
if gnommo_scratch
|
||
else seg_videos_dir / "intermediate" / seg_id
|
||
)
|
||
if not scratch.exists():
|
||
return
|
||
if force:
|
||
print(f" {seg_id}: --force — clearing all intermediate files for a fresh run")
|
||
_shutil.rmtree(scratch, ignore_errors=True)
|
||
return
|
||
# Resume mode: drop only half-written files, keep completed chunks so
|
||
# processing continues where the crashed run left off.
|
||
removed = 0
|
||
for p in scratch.rglob("*.partial.*"):
|
||
try:
|
||
p.unlink()
|
||
removed += 1
|
||
except OSError:
|
||
pass
|
||
if removed:
|
||
print(
|
||
f" {seg_id}: removed {removed} incomplete file(s); resuming from completed chunks"
|
||
)
|
||
|
||
# --- Filter pipeline ---
|
||
talkinghead_filter = (config.default_filters or {}).get("talkinghead", [])
|
||
if not talkinghead_filter:
|
||
print(
|
||
" ERROR: No 'talkinghead' filter defined in project.json default_filters."
|
||
)
|
||
print(" Add a 'talkinghead' entry under 'default_filters' in project.json.")
|
||
return 1
|
||
|
||
# --- Source discovery ---
|
||
_video_exts = {".mov", ".mp4", ".avi", ".mkv", ".m4v"}
|
||
|
||
def _scan_dir(d: Path) -> list[Path]:
|
||
if not d.exists():
|
||
return []
|
||
return sorted(
|
||
f
|
||
for f in d.iterdir()
|
||
if f.is_file()
|
||
and f.suffix.lower() in _video_exts
|
||
and not f.name.startswith(".")
|
||
)
|
||
|
||
raw_mov_files = _scan_dir(raw_dir)
|
||
raw_mp4_files = _scan_dir(compressed_dir)
|
||
|
||
if raw_mov_files:
|
||
source_files = raw_mov_files
|
||
using_compressed = False
|
||
elif raw_mp4_files:
|
||
source_files = raw_mp4_files
|
||
using_compressed = True
|
||
print(
|
||
" WARNING: raw_mov/ is empty — using compressed files from raw_mp4/ instead. Quality may be reduced."
|
||
)
|
||
else:
|
||
print(f" No source files found in raw_mov/ or raw_mp4/.")
|
||
print(f" Place .mov recordings in {raw_dir}")
|
||
return 1
|
||
|
||
# --- Load existing narration.json to preserve per-segment settings ---
|
||
narration_json_path = narration_dir / "narration.json"
|
||
existing_narration: dict = {}
|
||
if narration_json_path.exists():
|
||
existing_narration = _read_json(narration_json_path)
|
||
|
||
# --- Build segments list ---
|
||
# Per-segment staleness: reprocess a segment when its raw source's fingerprint
|
||
# differs from the one recorded the last time we produced its output. An
|
||
# existing output with no recorded fingerprint is adopted (recorded, not
|
||
# reprocessed) so introducing state never triggers a needless re-encode.
|
||
from . import state as _state
|
||
|
||
_stage_key = f"preprocess:{res}"
|
||
_prev_fps = _state.get_items(project_path, _stage_key)
|
||
_seg_source_fp: dict[str, str] = {} # segment_id -> current source fingerprint
|
||
_adopted_fps: dict[str, str] = {} # up-to-date segments to (re)record
|
||
|
||
segments_to_process: list[tuple[str, _VideoSource]] = []
|
||
skipped_count = 0
|
||
|
||
for source_file in source_files:
|
||
segment_id = source_file.stem
|
||
# Strip _compressed suffix (raw_mp4 naming convention)
|
||
if using_compressed and segment_id.endswith("_compressed"):
|
||
segment_id = segment_id[: -len("_compressed")]
|
||
|
||
# For non-full res, write processed outputs into the res subdir.
|
||
# (narration.json still records the plain "processed/..." path.)
|
||
_res_cfg = RES_CONFIGS.get(res) if res != "full" else None
|
||
if _res_cfg:
|
||
_, _, _subdir = _res_cfg
|
||
output_file = f"{_subdir}/processed/{segment_id}_processed.mov"
|
||
else:
|
||
output_file = f"processed/{segment_id}_processed.mov"
|
||
# When process_cache is set, output goes to the cache dir; narration.json
|
||
# still records the relative path so render (also using cache) can find it.
|
||
output_base = cache_narration_dir or narration_dir
|
||
output_path = output_base / output_file
|
||
|
||
current_fp = _state.fingerprint_path(source_file, _state.META)
|
||
_seg_source_fp[segment_id] = current_fp
|
||
|
||
if output_path.exists() and not force:
|
||
recorded = _prev_fps.get(segment_id)
|
||
if recorded is None or recorded == current_fp:
|
||
# Up to date (or adopting a pre-existing output into state).
|
||
print(f" {segment_id}: output exists, skipping (use --force to reprocess)")
|
||
skipped_count += 1
|
||
_adopted_fps[segment_id] = current_fp
|
||
continue
|
||
print(f" {segment_id}: raw source changed since last run — reprocessing")
|
||
|
||
# Filter: from existing narration.json entry (if explicitly set), else talkinghead
|
||
existing_entry = existing_narration.get(segment_id, {})
|
||
raw_filter = existing_entry.get("filter")
|
||
if raw_filter:
|
||
if isinstance(raw_filter, str):
|
||
filter_list = (config.default_filters or {}).get(
|
||
raw_filter, talkinghead_filter
|
||
)
|
||
else:
|
||
filter_list = raw_filter
|
||
else:
|
||
filter_list = talkinghead_filter
|
||
|
||
video_source = _VideoSource(
|
||
source_file=source_file,
|
||
filter=filter_list,
|
||
output_file=output_file,
|
||
use_audio_channels=existing_entry.get("use_audio_channels", "auto"),
|
||
# Default False: apply loudnorm now (in preprocess), not deferred.
|
||
defer_loudnorm=existing_entry.get("defer_loudnorm", False),
|
||
)
|
||
segments_to_process.append((segment_id, video_source))
|
||
|
||
if not segments_to_process:
|
||
if skipped_count:
|
||
print(
|
||
f"\n All {skipped_count} segment(s) already preprocessed. Use --force to reprocess."
|
||
)
|
||
else:
|
||
print("\n No segments to preprocess.")
|
||
return 0
|
||
|
||
if dry_run:
|
||
for segment_id, segment_source in segments_to_process:
|
||
print(f"\n Would preprocess: {segment_id}")
|
||
print(f" Source: {segment_source.source_file}")
|
||
print(f" Output: {segment_source.output_file}")
|
||
print(f" Filters: {len(segment_source.filter)} step(s)")
|
||
return 0
|
||
|
||
# --- Process segments ---
|
||
successfully_processed: list[tuple[str, _VideoSource]] = []
|
||
|
||
if workers > 1 and len(segments_to_process) > 1:
|
||
num_workers = min(workers, len(segments_to_process))
|
||
print(
|
||
f"\n Processing {len(segments_to_process)} segments in parallel ({num_workers} workers)"
|
||
)
|
||
|
||
def process_segment_task(task):
|
||
seg_id, seg_source = task
|
||
_clear_segment_scratch(cache_narration_dir or narration_dir, seg_id)
|
||
preprocess_video(
|
||
cache_narration_dir or narration_dir,
|
||
seg_id,
|
||
seg_source,
|
||
verbose=False,
|
||
force=force,
|
||
custom_gnommo_scratch=gnommo_scratch,
|
||
res=res,
|
||
)
|
||
return task
|
||
|
||
completed = 0
|
||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||
futures = {
|
||
executor.submit(process_segment_task, t): t for t in segments_to_process
|
||
}
|
||
for future in as_completed(futures):
|
||
seg_id, seg_source = future.result()
|
||
completed += 1
|
||
print(f" Completed: {seg_id} ({completed}/{len(segments_to_process)})")
|
||
output_path = (
|
||
cache_narration_dir or narration_dir
|
||
) / seg_source.output_file
|
||
if output_path.exists():
|
||
successfully_processed.append((seg_id, seg_source))
|
||
else:
|
||
for segment_id, segment_source in segments_to_process:
|
||
_out_full = (
|
||
cache_narration_dir or narration_dir
|
||
) / segment_source.output_file
|
||
print(f"\n Processing: {segment_id}")
|
||
print(f" Source: {segment_source.source_file}")
|
||
print(f" Output: {_out_full}")
|
||
print(f" Filters: {len(segment_source.filter)} step(s)")
|
||
_clear_segment_scratch(cache_narration_dir or narration_dir, segment_id)
|
||
preprocess_video(
|
||
cache_narration_dir or narration_dir,
|
||
segment_id,
|
||
segment_source,
|
||
verbose,
|
||
force,
|
||
gnommo_scratch,
|
||
res=res,
|
||
)
|
||
output_path = (
|
||
cache_narration_dir or narration_dir
|
||
) / segment_source.output_file
|
||
if output_path.exists():
|
||
successfully_processed.append((segment_id, segment_source))
|
||
|
||
# --- Update narration.json ---
|
||
# Record where the processed output landed WITHOUT touching source_file. The
|
||
# raw recording (raw_mov/…) stays as source_file so the project remains
|
||
# renderable before preprocessing; render prefers processed_file once it
|
||
# exists on disk and otherwise falls back to source_file. The rest of the
|
||
# entry (filter, cutout, trim points, …) is preserved as-is.
|
||
for segment_id, segment_source in successfully_processed:
|
||
entry = dict(existing_narration.get(segment_id, {}))
|
||
# Always record the plain path; the res subdir shift happens at render for low/tiny.
|
||
entry["processed_file"] = f"processed/{segment_id}_processed.mov"
|
||
# Store the RESOLVED audio channel (preprocess_video mutated "auto" → the
|
||
# detected left/right/both). This is the concrete value; render reads it and
|
||
# never re-runs the auto-detect probe.
|
||
entry["use_audio_channels"] = segment_source.use_audio_channels or "auto"
|
||
entry.setdefault("defer_loudnorm", False)
|
||
existing_narration[segment_id] = entry
|
||
|
||
with open(narration_json_path, "w", encoding="utf-8") as f:
|
||
json.dump(existing_narration, f, indent=2)
|
||
|
||
# Record source fingerprints for every segment whose output is now present
|
||
# (freshly processed + adopted skips) so future runs can detect raw changes.
|
||
_record_fps = dict(_adopted_fps)
|
||
for _seg_id, _ in successfully_processed:
|
||
_fp = _seg_source_fp.get(_seg_id)
|
||
if _fp is not None:
|
||
_record_fps[_seg_id] = _fp
|
||
if _record_fps:
|
||
_state.record_items(project_path, _stage_key, _record_fps)
|
||
|
||
if successfully_processed:
|
||
print(f"\n Updated narration.json ({len(successfully_processed)} segment(s))")
|
||
|
||
print(
|
||
f"\n Narration segments are concatenated automatically at render time — run 'gnommo -p <project> render'."
|
||
)
|
||
|
||
# Also preprocess videos from videos.json (e.g. chroma key, color grade)
|
||
videos, videos_dir = parse_videos(project_path, config)
|
||
videos_to_process = [
|
||
(vid_id, vid_src)
|
||
for vid_id, vid_src in videos.items()
|
||
if vid_src.filter and not vid_src.is_shared
|
||
]
|
||
|
||
if videos_to_process:
|
||
print(f"\n Processing {len(videos_to_process)} video(s) from videos.json:")
|
||
for video_id, video_source in videos_to_process:
|
||
if video_source.output_file:
|
||
output_path = videos_dir / video_source.output_file
|
||
if output_path.exists() and not force:
|
||
print(
|
||
f" {video_id}: output exists, skipping (use --force to reprocess)"
|
||
)
|
||
continue
|
||
if dry_run:
|
||
print(
|
||
f" Would preprocess: {video_id} ({len(video_source.filter)} filter(s))"
|
||
)
|
||
continue
|
||
print(f" Processing: {video_id}")
|
||
_clear_segment_scratch(videos_dir, video_id)
|
||
preprocess_video(
|
||
videos_dir,
|
||
video_id,
|
||
video_source,
|
||
verbose,
|
||
force,
|
||
gnommo_scratch,
|
||
res=res,
|
||
)
|
||
|
||
print("\nPreprocessing complete.")
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# Trim Command — transcript-based trimming for slide-range segments
|
||
# =============================================================================
|
||
|
||
# Transcript-based auto-trim padding: keep this much lead-in before the start
|
||
# slide's first word, and this much tail after the end slide's last word.
|
||
_TRIM_LEAD_IN = 0.5
|
||
_TRIM_TAIL_OUT = 2.0
|
||
|
||
|
||
def _user_begin_skip(existing: dict) -> "float | None":
|
||
"""Return the skip (seconds) implied by a user-pinned begin/start, or None."""
|
||
from .parser import parse_timestamp
|
||
if existing.get("begin"):
|
||
return parse_timestamp(existing["begin"])
|
||
if existing.get("start"):
|
||
return parse_timestamp(existing["start"])
|
||
return None
|
||
|
||
|
||
def _locked_begin_skip(existing: dict, begin_user: bool) -> float:
|
||
"""Skip (seconds) for a segment whose begin is locked — user pin or prior value."""
|
||
if begin_user:
|
||
return _user_begin_skip(existing) or 0.0
|
||
return float(existing.get("skip", 0.0))
|
||
|
||
|
||
def _locked_end_abs(existing: dict, end_user: bool, skip: float) -> float:
|
||
"""Absolute end (seconds) for a segment whose end is locked."""
|
||
from .parser import parse_timestamp
|
||
if end_user:
|
||
return parse_timestamp(existing["end"])
|
||
return skip + float(existing.get("take", 0.0))
|
||
|
||
|
||
def _extract_slide_texts(manuscript_path: Path) -> "dict[int, str]":
|
||
"""Return {slide_num: text} for each [SN] marker in the manuscript."""
|
||
text = manuscript_path.read_text(encoding="utf-8")
|
||
parts = re.split(r"\[S(\d+)\]", text)
|
||
result: dict[int, str] = {}
|
||
i = 1
|
||
while i + 1 < len(parts):
|
||
slide_num = int(parts[i])
|
||
content = re.sub(r"\[[^\]]+\]", " ", parts[i + 1])
|
||
result[slide_num] = content.strip()
|
||
i += 2
|
||
return result
|
||
|
||
|
||
def _map_slides_in_transcript(
|
||
slide_texts: "dict[int, str]",
|
||
transcript_words: list,
|
||
fuzzy_threshold: float = 0.6,
|
||
) -> "dict[int, tuple[float, float]]":
|
||
"""Locate each manuscript slide inside one segment's transcript.
|
||
|
||
Reuses the same fuzzy phrase matcher the build stage aligns with
|
||
(transformer._find_phrase_timestamp), so a slide's start time and match
|
||
quality are derived exactly as they will be at render alignment.
|
||
|
||
Returns {slide_num: (start_sec, quality)} for every slide that belongs to
|
||
this recording. Coverage is discovered from content, not trusted from the
|
||
filename's slide numbers — that absence of a match (and a low quality on a
|
||
botched retake) is the signal the reconciliation pass uses to place segment
|
||
boundaries.
|
||
|
||
Each slide is matched independently over the whole transcript, then only the
|
||
longest run whose start times rise with slide number is kept. A recording
|
||
holds one contiguous span of slides, so a slide from outside that span can
|
||
only throw a stray, out-of-order match — the monotonic filter drops it
|
||
instead of letting it poison a forward cursor.
|
||
"""
|
||
from .transformer import _find_phrase_timestamp
|
||
|
||
hits: "list[tuple[int, float, float]]" = [] # (slide_num, start_sec, quality)
|
||
for slide_num in sorted(slide_texts):
|
||
anchor = " ".join(slide_texts[slide_num].split()[:10])
|
||
if not anchor.strip():
|
||
continue
|
||
idx, timestamp, confidence, _match_end = _find_phrase_timestamp(
|
||
anchor, transcript_words, start_from=0, fuzzy_threshold=fuzzy_threshold
|
||
)
|
||
if idx >= 0:
|
||
hits.append((slide_num, max(0.0, round(timestamp, 3)), round(confidence, 3)))
|
||
if not hits:
|
||
return {}
|
||
|
||
# Longest strictly-increasing-by-time subsequence over slides in number order.
|
||
n = len(hits)
|
||
run_len = [1] * n
|
||
prev = [-1] * n
|
||
for i in range(n):
|
||
for j in range(i):
|
||
if hits[j][1] < hits[i][1] and run_len[j] + 1 > run_len[i]:
|
||
run_len[i] = run_len[j] + 1
|
||
prev[i] = j
|
||
end = max(range(n), key=lambda i: run_len[i])
|
||
keep = []
|
||
while end != -1:
|
||
keep.append(end)
|
||
end = prev[end]
|
||
keep.reverse()
|
||
return {hits[i][0]: (hits[i][1], hits[i][2]) for i in keep}
|
||
|
||
|
||
def cmd_trim(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
force: bool = False,
|
||
threshold_db: float = -40.0,
|
||
res: str = "full",
|
||
whisper_model: str = "base",
|
||
) -> int:
|
||
"""
|
||
Auto-detect skip/take for narration segments and write them into narration.json.
|
||
|
||
Each segment is transcribed (cached in narration/transcripts/{seg_id}.json)
|
||
and its slides are located by content — _map_slides_in_transcript discovers
|
||
which manuscript slides the recording actually covers, so trim never relies on
|
||
the segment's filename numbers (which go stale when slides are inserted).
|
||
|
||
Two passes:
|
||
1. Per segment, build a {slide: (start, quality)} map.
|
||
2. Reconcile overlaps: when consecutive segments share a slide (a
|
||
re-recorded retake), the earlier take is cut before that slide and the
|
||
later take begins at it, so the botched tail is dropped automatically.
|
||
|
||
Otherwise begin = first slide's first word − 0.5s and end = last spoken
|
||
word + 2.0s. Trim only fills the side(s) the user hasn't pinned: a user-set
|
||
`begin`/`start` pins the beginning, `end` pins the end (respected under
|
||
--force). Segments with no usable transcript fall back to silence detection.
|
||
"""
|
||
from .parser import parse_project_config, parse_narration
|
||
from .preprocessor import detect_silence_bounds, get_video_duration
|
||
|
||
print(f"Trimming narration: {project_path.name}")
|
||
|
||
config = parse_project_config(project_path)
|
||
narration, narration_dir = parse_narration(project_path, config)
|
||
|
||
if not narration:
|
||
print(" No narration segments found in narration.json")
|
||
print(" Run 'gnommo -p <project> import' first.")
|
||
return 1
|
||
|
||
# Load slide texts from manuscript for transcript-based trimming
|
||
manuscript_path = project_path / "manuscript.txt"
|
||
slide_texts: dict[int, str] = {}
|
||
if manuscript_path.exists():
|
||
slide_texts = _extract_slide_texts(manuscript_path)
|
||
|
||
_video_exts = {".mov", ".mp4", ".avi", ".mkv", ".m4v"}
|
||
raw_dir = narration_dir / "raw_mov"
|
||
compressed_dir = narration_dir / "raw_mp4"
|
||
transcripts_dir = narration_dir / "transcripts"
|
||
|
||
# Build lookup of raw files keyed by lowercased stem
|
||
raw_lookup: dict[str, Path] = {}
|
||
for search_dir in (raw_dir, compressed_dir):
|
||
if search_dir.exists():
|
||
for f in search_dir.iterdir():
|
||
if (
|
||
f.is_file()
|
||
and f.suffix.lower() in _video_exts
|
||
and not f.name.startswith(".")
|
||
):
|
||
stem = f.stem
|
||
if stem.endswith("_compressed"):
|
||
stem = stem[: -len("_compressed")]
|
||
raw_lookup[stem.lower()] = f
|
||
|
||
narration_json_path = narration_dir / "narration.json"
|
||
raw_data: dict = _read_json(narration_json_path)
|
||
|
||
# Per-segment staleness: re-trim (and re-transcribe) a segment when its raw
|
||
# source's fingerprint differs from the one recorded last time it was trimmed.
|
||
from . import state as _state
|
||
|
||
_prev_trim_fps = _state.get_items(project_path, "trim")
|
||
_trim_fps: dict[str, str] = {} # segments to (re)record after the loop
|
||
|
||
# =====================================================================
|
||
# Phase A — gather each segment's transcript + slide map (or leave it for
|
||
# silence-based trimming when no usable transcript is available). The slide
|
||
# map is what lets reconciliation discover which slides each recording
|
||
# actually covers, independent of the (possibly stale) filename numbers.
|
||
# =====================================================================
|
||
from .transcriber import transcribe_video, save_transcript, load_transcript
|
||
from .narration import segment_order
|
||
|
||
transcripts_dir.mkdir(parents=True, exist_ok=True)
|
||
infos: "dict[str, dict]" = {}
|
||
|
||
for seg_id in segment_order(narration):
|
||
seg = narration[seg_id]
|
||
|
||
# Prefer raw file; fall back to source_file from narration.json.
|
||
source_path = raw_lookup.get(seg_id)
|
||
if source_path is None:
|
||
source_path = narration_dir / seg.source_file
|
||
if not source_path.exists():
|
||
print(f" {seg_id}: source file not found, skipping")
|
||
continue
|
||
|
||
current_fp = _state.fingerprint_path(source_path, _state.META)
|
||
recorded_fp = _prev_trim_fps.get(seg_id)
|
||
source_changed = recorded_fp is not None and recorded_fp != current_fp
|
||
seg_force = force or source_changed
|
||
|
||
existing = raw_data.get(seg_id, {})
|
||
# begin/start pin the beginning; end pins the end (user vocabulary).
|
||
# A side is "locked" if the user pinned it, or trim already wrote it and
|
||
# we're not re-detecting (--force / changed source). Locked sides are
|
||
# preserved verbatim; only unlocked sides are (re)computed.
|
||
begin_user = bool(existing.get("begin") or existing.get("start"))
|
||
end_user = bool(existing.get("end"))
|
||
begin_locked = begin_user or ("skip" in existing and not seg_force)
|
||
end_locked = end_user or ("take" in existing and not seg_force)
|
||
fully_locked = begin_locked and end_locked
|
||
|
||
if source_changed:
|
||
print(f" {seg_id}: raw source changed since last trim — re-trimming")
|
||
|
||
info = {
|
||
"seg": seg,
|
||
"source_path": source_path,
|
||
"current_fp": current_fp,
|
||
"existing": existing,
|
||
"begin_user": begin_user,
|
||
"end_user": end_user,
|
||
"begin_locked": begin_locked,
|
||
"end_locked": end_locked,
|
||
"words": None,
|
||
"slide_map": {},
|
||
"total_dur": 0.0,
|
||
}
|
||
infos[seg_id] = info
|
||
|
||
# Transcription is expensive and deterministic, so reuse a cached
|
||
# transcript whenever the source is unchanged — even under --force, which
|
||
# only means "re-detect skip/take", not "re-transcribe". A fully-locked
|
||
# segment still gets its map for free from a cached transcript (it may
|
||
# anchor a neighbour's overlap) but is never transcribed just for that.
|
||
transcript_path = transcripts_dir / f"{seg_id}.json"
|
||
words = None
|
||
try:
|
||
if transcript_path.exists() and not source_changed:
|
||
words = load_transcript(transcript_path)
|
||
print(f" {seg_id}: loaded cached transcript ({len(words)} words)")
|
||
elif fully_locked and not seg_force:
|
||
pass # nothing to detect and no reusable transcript — skip
|
||
else:
|
||
print(f" {seg_id}: transcribing {source_path.parent.name}/{source_path.name} (model={whisper_model})...", end="", flush=True)
|
||
words = transcribe_video(source_path, model=whisper_model)
|
||
save_transcript(words, transcript_path)
|
||
print(f" {len(words)} words")
|
||
except Exception as exc:
|
||
label = "Whisper not installed" if "openai-whisper" in str(exc) else str(exc)
|
||
print(f"\n \u26a0 transcription failed ({label}) — will use silence detection for {seg_id}")
|
||
words = None
|
||
|
||
if words:
|
||
info["words"] = words
|
||
info["total_dur"] = get_video_duration(source_path)
|
||
info["slide_map"] = _map_slides_in_transcript(slide_texts, words) if slide_texts else {}
|
||
|
||
# =====================================================================
|
||
# Phase B — reconcile overlaps between consecutive transcript segments.
|
||
# When two neighbours both contain a slide (a re-recorded retake), the whole
|
||
# shared region belongs to the later take: the earlier take is cut before
|
||
# the first shared slide, and the later take begins at it. One split point,
|
||
# so the join has no gap and no duplicated audio.
|
||
# =====================================================================
|
||
overlap_end: "dict[str, tuple[int, float]]" = {} # seg_id -> (split_slide, cut_sec)
|
||
overlap_begin: "dict[str, tuple[int, float]]" = {} # seg_id -> (split_slide, begin_sec)
|
||
|
||
mapped = [s for s in segment_order(narration) if infos.get(s, {}).get("slide_map")]
|
||
for a_id, b_id in zip(mapped, mapped[1:]):
|
||
a_map = infos[a_id]["slide_map"]
|
||
b_map = infos[b_id]["slide_map"]
|
||
shared = sorted(set(a_map) & set(b_map))
|
||
# A real retake: the later take matches the shared slide at least as well
|
||
# as the earlier one. Split at the first such slide — the later take owns
|
||
# the whole overlap from there. If the earlier take is better everywhere
|
||
# (a stray coincidental match, not a retake), there's nothing to reconcile.
|
||
split = next((s for s in shared if b_map[s][1] >= a_map[s][1]), None)
|
||
if split is None:
|
||
continue
|
||
overlap_end[a_id] = (split, a_map[split][0])
|
||
overlap_begin[b_id] = (split, b_map[split][0])
|
||
a_q, b_q = a_map[split][1], b_map[split][1]
|
||
print(
|
||
f" \u2194 overlap {a_id}\u2194{b_id} on S{split}: cut {a_id} at "
|
||
f"{a_map[split][0]:.2f}s, {b_id} begins {b_map[split][0]:.2f}s "
|
||
f"(quality {a_q:.2f}\u2192{b_q:.2f})"
|
||
)
|
||
if max(a_q, b_q) < 0.5:
|
||
print(f" \u26a0\ufe0f both takes match S{split} weakly — verify the {a_id}/{b_id} seam.")
|
||
|
||
# =====================================================================
|
||
# Compute skip/take per segment and write the unlocked sides.
|
||
# =====================================================================
|
||
updated = 0
|
||
for seg_id in segment_order(narration):
|
||
info = infos.get(seg_id)
|
||
if info is None:
|
||
continue
|
||
existing = info["existing"]
|
||
begin_user, end_user = info["begin_user"], info["end_user"]
|
||
begin_locked, end_locked = info["begin_locked"], info["end_locked"]
|
||
words = info["words"]
|
||
|
||
if begin_locked and end_locked:
|
||
print(f" {seg_id}: begin & end already set, skipping (use --force to redo)")
|
||
_trim_fps[seg_id] = info["current_fp"]
|
||
continue
|
||
|
||
if not words:
|
||
# --- Silence-based fallback (no usable transcript) ---
|
||
print(f" {seg_id}: analysing {info['source_path'].parent.name}/{info['source_path'].name}...", end="", flush=True)
|
||
first_sound, last_sound = detect_silence_bounds(
|
||
info["source_path"], noise_threshold_db=threshold_db, verbose=verbose
|
||
)
|
||
total_dur = get_video_duration(info["source_path"])
|
||
if begin_locked:
|
||
skip = _locked_begin_skip(existing, begin_user)
|
||
begin_note = f"begin kept ({skip:.2f}s)"
|
||
else:
|
||
skip = max(0.0, round(first_sound - _TRIM_LEAD_IN, 3))
|
||
begin_note = f"begin auto: first sound {first_sound:.2f}s \u2212{_TRIM_LEAD_IN:g}s \u2192 {skip:.2f}s"
|
||
if end_locked:
|
||
end_abs = _locked_end_abs(existing, end_user, skip)
|
||
end_note = f"end kept ({end_abs:.2f}s)"
|
||
else:
|
||
end_abs = min(total_dur, last_sound + 3.0)
|
||
end_note = f"end auto: last sound {last_sound:.2f}s +3.0s \u2192 {end_abs:.2f}s"
|
||
else:
|
||
total_dur = info["total_dur"]
|
||
slide_map = info["slide_map"]
|
||
|
||
# ---- Beginning ----
|
||
if begin_locked:
|
||
skip = _locked_begin_skip(existing, begin_user)
|
||
begin_note = f"begin kept ({skip:.2f}s)"
|
||
elif seg_id in overlap_begin:
|
||
split, begin_sec = overlap_begin[seg_id]
|
||
skip = round(begin_sec, 3) # retake begins exactly at the shared slide
|
||
begin_note = f"begin: overlap retake \u2192 S{split} at {skip:.2f}s"
|
||
else:
|
||
if slide_map:
|
||
first_slide = min(slide_map)
|
||
start_ts = slide_map[first_slide][0]
|
||
src = f"S{first_slide}"
|
||
else:
|
||
start_ts = words[0].start
|
||
src = "first word"
|
||
skip = max(0.0, round(start_ts - _TRIM_LEAD_IN, 3))
|
||
begin_note = f"begin auto: {src} {start_ts:.2f}s \u2212{_TRIM_LEAD_IN:g}s \u2192 {skip:.2f}s"
|
||
|
||
# ---- End ----
|
||
if end_locked:
|
||
end_abs = _locked_end_abs(existing, end_user, skip)
|
||
end_note = f"end kept ({end_abs:.2f}s)"
|
||
elif seg_id in overlap_end:
|
||
split, cut_sec = overlap_end[seg_id]
|
||
end_abs = round(cut_sec, 3) # cut before the re-recorded slide
|
||
end_note = f"end: cut before S{split} (retaken next) at {end_abs:.2f}s"
|
||
else:
|
||
end_abs = min(words[-1].end + _TRIM_TAIL_OUT, total_dur)
|
||
end_note = f"end auto: last word {words[-1].end:.2f}s +{_TRIM_TAIL_OUT:g}s \u2192 {end_abs:.2f}s"
|
||
|
||
if not begin_locked:
|
||
raw_data.setdefault(seg_id, {})["skip"] = skip
|
||
if not end_locked:
|
||
raw_data.setdefault(seg_id, {})["take"] = round(max(0.0, end_abs - skip), 3)
|
||
|
||
print(f" {begin_note} \u00b7 {end_note}")
|
||
_trim_fps[seg_id] = info["current_fp"]
|
||
updated += 1
|
||
|
||
if updated > 0:
|
||
with open(narration_json_path, "w", encoding="utf-8") as f:
|
||
json.dump(raw_data, f, indent=2)
|
||
print(f"\n Updated {updated} segment(s) in narration.json")
|
||
else:
|
||
print(f"\n No segments updated")
|
||
|
||
# Persist per-segment source fingerprints (freshly trimmed + adopted skips)
|
||
# so a later standalone or 'all' run can tell whether a raw was re-recorded.
|
||
if _trim_fps:
|
||
_state.record_items(project_path, "trim", _trim_fps)
|
||
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# Transcode Command — compress narration folder to H.265
|
||
# =============================================================================
|
||
|
||
|
||
def _get_video_codec(path: Path) -> str:
|
||
"""Return the codec name of the first video stream (e.g. 'hevc', 'prores', 'h264')."""
|
||
result = subprocess.run(
|
||
[
|
||
"ffprobe",
|
||
"-v",
|
||
"error",
|
||
"-select_streams",
|
||
"v:0",
|
||
"-show_entries",
|
||
"stream=codec_name",
|
||
"-of",
|
||
"default=noprint_wrappers=1:nokey=1",
|
||
str(path),
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
return result.stdout.strip().lower()
|
||
|
||
|
||
def _transcode_processed_files(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
replace: bool,
|
||
force: bool,
|
||
alpha_quality: float,
|
||
) -> int:
|
||
"""
|
||
Compress _processed.mov files (ProRes 4444 + alpha) to HEVC+alpha via
|
||
Apple VideoToolbox.
|
||
|
||
For each _processed.mov:
|
||
1. Transcode to a temp file using hevc_videotoolbox with alpha.
|
||
2. Move the ProRes original into a prores/ subdirectory (never deleted).
|
||
3. Rename the compressed file to the original _processed.mov name
|
||
so render finds it unchanged.
|
||
|
||
The prores/ subdirectory is never scanned — only top-level files are candidates.
|
||
If prores/<filename> already exists the file has already been compressed —
|
||
skip unless --force.
|
||
"""
|
||
from .parser import parse_project_config, parse_narration
|
||
|
||
print(f"Transcoding processed files (HEVC+alpha): {project_path.name}")
|
||
|
||
config = parse_project_config(project_path)
|
||
|
||
# Resolve narration_dir and videos_dir — processed files live in both
|
||
_narration, narration_dir = parse_narration(project_path, config)
|
||
videos_json_path = project_path / config.videos_path
|
||
videos_dir = videos_json_path.parent
|
||
|
||
# Glob both directories for *_processed.mov; skip any _prores.mov archives
|
||
search_dirs = [d for d in [narration_dir, videos_dir] if d.exists()]
|
||
candidates: list[Path] = []
|
||
seen: set[Path] = set()
|
||
for d in search_dirs:
|
||
for p in d.glob("*_processed.mov"):
|
||
if p not in seen and "_prores" not in p.stem:
|
||
seen.add(p)
|
||
candidates.append(p)
|
||
|
||
if not candidates:
|
||
print(" No _processed.mov files found.")
|
||
return 0
|
||
|
||
# Smallest first
|
||
candidates = [c for c in candidates if c.exists()]
|
||
candidates.sort(key=lambda f: f.stat().st_size)
|
||
|
||
total_original = 0
|
||
total_compressed = 0
|
||
transcoded = 0
|
||
skipped = 0
|
||
|
||
for src in candidates:
|
||
# Archive goes into prores/ subdirectory alongside the source file
|
||
prores_dir = src.parent / "prores"
|
||
archive = prores_dir / src.name
|
||
|
||
# Always skip files already encoded as HEVC — regardless of --replace or --force
|
||
if _get_video_codec(src) == "hevc":
|
||
print(f" {src.name}: already HEVC, skipping")
|
||
skipped += 1
|
||
continue
|
||
|
||
# Without --replace, skip if the archive already exists in prores/
|
||
if not replace and archive.exists() and not force:
|
||
size_mb = src.stat().st_size / 1_048_576
|
||
print(
|
||
f" {src.name}: already compressed ({size_mb:.1f} MB), skipping (use --force to redo)"
|
||
)
|
||
skipped += 1
|
||
continue
|
||
|
||
src_mb = src.stat().st_size / 1_048_576
|
||
print(f" {src.name} ({src_mb:.1f} MB) → HEVC+alpha", end="")
|
||
|
||
if dry_run:
|
||
print(" [dry-run]")
|
||
continue
|
||
|
||
print(" ...", end="", flush=True)
|
||
|
||
tmp_out = src.with_name(src.stem + "_hevc_tmp.mov")
|
||
|
||
cmd = [
|
||
"ffmpeg",
|
||
"-i",
|
||
str(src),
|
||
"-c:v",
|
||
"hevc_videotoolbox",
|
||
"-allow_sw",
|
||
"1",
|
||
"-alpha_quality",
|
||
str(alpha_quality),
|
||
"-tag:v",
|
||
"hvc1",
|
||
"-c:a",
|
||
"copy",
|
||
"-y",
|
||
str(tmp_out),
|
||
]
|
||
|
||
if verbose:
|
||
print()
|
||
print(" " + " ".join(cmd))
|
||
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=not verbose,
|
||
text=True,
|
||
)
|
||
|
||
if result.returncode != 0:
|
||
print(f"\n ERROR transcoding {src.name}")
|
||
if tmp_out.exists():
|
||
tmp_out.unlink()
|
||
if not verbose and result.stderr:
|
||
last_lines = result.stderr.strip().splitlines()[-5:]
|
||
for line in last_lines:
|
||
print(f" {line}", file=sys.stderr)
|
||
continue
|
||
|
||
out_mb = tmp_out.stat().st_size / 1_048_576
|
||
ratio = (1.0 - tmp_out.stat().st_size / src.stat().st_size) * 100
|
||
|
||
if replace:
|
||
# Delete ProRes original, move compressed into its place
|
||
src.unlink()
|
||
tmp_out.rename(src)
|
||
print(
|
||
f"\r {src.name} ({src_mb:.1f} MB) → HEVC+alpha"
|
||
f" ({out_mb:.1f} MB, -{ratio:.0f}%)"
|
||
)
|
||
else:
|
||
# Move ProRes original into prores/ subdirectory, compressed takes its place
|
||
prores_dir.mkdir(exist_ok=True)
|
||
src.rename(archive)
|
||
tmp_out.rename(src)
|
||
print(
|
||
f"\r {src.name} ({src_mb:.1f} MB) → HEVC+alpha"
|
||
f" ({out_mb:.1f} MB, -{ratio:.0f}%)"
|
||
f" [ProRes → prores/{archive.name}]"
|
||
)
|
||
|
||
total_original += int(src_mb * 1_048_576)
|
||
total_compressed += int(out_mb * 1_048_576)
|
||
transcoded += 1
|
||
|
||
print()
|
||
if dry_run:
|
||
print(f" [dry-run] Would compress {len(candidates) - skipped} file(s)")
|
||
return 0
|
||
|
||
if transcoded > 0:
|
||
orig_mb = total_original / 1_048_576
|
||
comp_mb = total_compressed / 1_048_576
|
||
saved_mb = orig_mb - comp_mb
|
||
ratio = (saved_mb / orig_mb * 100) if orig_mb else 0
|
||
print(
|
||
f" Compressed {transcoded} file(s): {orig_mb:.1f} MB → {comp_mb:.1f} MB"
|
||
f" (saved {saved_mb:.1f} MB, -{ratio:.0f}%)"
|
||
)
|
||
if skipped:
|
||
print(f" Skipped {skipped} already-compressed file(s)")
|
||
|
||
return 0
|
||
|
||
|
||
def cmd_transcode(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool = False,
|
||
replace: bool = False,
|
||
crf: int = 23,
|
||
force: bool = False,
|
||
processed: bool = False,
|
||
alpha_quality: float = 0.75,
|
||
) -> int:
|
||
"""
|
||
Transcode project video files to save disk space.
|
||
|
||
Default (1st pass, before preprocess):
|
||
Compress raw narration recordings to H.265. Output: {stem}_compressed.mp4.
|
||
Skips files with '_compressed.' or '_processed.' in the name.
|
||
Use --replace to delete originals after success.
|
||
|
||
With --processed (2nd pass, after preprocess):
|
||
Compress _processed.mov files (ProRes 4444 + alpha) to HEVC+alpha.
|
||
Archives the ProRes original as _prores.mov (never deleted).
|
||
The compressed file takes the original _processed.mov name so the
|
||
rest of the pipeline (render) finds it unchanged.
|
||
Uses Apple VideoToolbox (hevc_videotoolbox) with --alpha-quality.
|
||
"""
|
||
if processed:
|
||
return _transcode_processed_files(
|
||
project_path, verbose, dry_run, replace, force, alpha_quality
|
||
)
|
||
|
||
from .parser import parse_project_config, parse_narration
|
||
|
||
print(f"Transcoding narration: {project_path.name}")
|
||
|
||
config = parse_project_config(project_path)
|
||
_narration, narration_dir = parse_narration(project_path, config)
|
||
|
||
raw_dir = narration_dir / "raw_mov"
|
||
compressed_dir = narration_dir / "raw_mp4"
|
||
|
||
if not raw_dir.exists():
|
||
print(f" raw/ directory not found: {raw_dir}", file=sys.stderr)
|
||
print(f" Place raw recordings in {raw_dir} and run 'import' first.")
|
||
return 1
|
||
|
||
compressed_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Collect eligible video files from raw/ only
|
||
video_extensions = {".mp4", ".mov", ".avi", ".mkv", ".m4v", ".mts", ".webm"}
|
||
|
||
candidates = [
|
||
f
|
||
for f in raw_dir.iterdir()
|
||
if f.is_file()
|
||
and f.suffix.lower() in video_extensions
|
||
and not f.name.startswith(".")
|
||
]
|
||
|
||
if not candidates:
|
||
print(f" No video files found in {raw_dir}.")
|
||
return 0
|
||
|
||
# Process smallest files first
|
||
candidates.sort(key=lambda f: f.stat().st_size)
|
||
|
||
total_original = 0
|
||
total_compressed = 0
|
||
transcoded = 0
|
||
skipped = 0
|
||
|
||
for src in candidates:
|
||
# Output: compressed/<stem>.mp4 (clean name, no _compressed suffix)
|
||
output = compressed_dir / f"{src.stem}.mp4"
|
||
|
||
if output.exists() and not force:
|
||
size_mb = output.stat().st_size / 1_048_576
|
||
print(
|
||
f" {src.name}: already transcoded ({size_mb:.1f} MB), skipping (use --force to redo)"
|
||
)
|
||
skipped += 1
|
||
continue
|
||
|
||
src_mb = src.stat().st_size / 1_048_576
|
||
print(
|
||
f" raw/{src.name} ({src_mb:.1f} MB) → compressed/{output.name}", end=""
|
||
)
|
||
|
||
if dry_run:
|
||
print(" [dry-run]")
|
||
continue
|
||
|
||
print(" ...", end="", flush=True)
|
||
|
||
cmd = [
|
||
"ffmpeg",
|
||
"-i",
|
||
str(src),
|
||
"-vf",
|
||
"scale=-2:1080",
|
||
"-c:v",
|
||
"libx265",
|
||
"-crf",
|
||
str(crf),
|
||
"-preset",
|
||
"medium",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
"-tag:v",
|
||
"hvc1",
|
||
"-y",
|
||
str(output),
|
||
]
|
||
|
||
if verbose:
|
||
print()
|
||
print(" " + " ".join(cmd))
|
||
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=not verbose,
|
||
text=True,
|
||
)
|
||
|
||
if result.returncode != 0:
|
||
print(f"\n ERROR transcoding {src.name}")
|
||
if not verbose and result.stderr:
|
||
# Print last few lines of ffmpeg stderr for diagnosis
|
||
last_lines = result.stderr.strip().splitlines()[-5:]
|
||
for line in last_lines:
|
||
print(f" {line}", file=sys.stderr)
|
||
continue
|
||
|
||
out_mb = output.stat().st_size / 1_048_576
|
||
ratio = (1.0 - output.stat().st_size / src.stat().st_size) * 100
|
||
print(
|
||
f"\r raw/{src.name} ({src_mb:.1f} MB) → compressed/{output.name} ({out_mb:.1f} MB, -{ratio:.0f}%)"
|
||
)
|
||
|
||
total_original += src.stat().st_size
|
||
total_compressed += output.stat().st_size
|
||
transcoded += 1
|
||
|
||
print()
|
||
if dry_run:
|
||
print(f" [dry-run] Would transcode {len(candidates) - skipped} file(s)")
|
||
return 0
|
||
|
||
if transcoded > 0:
|
||
orig_mb = total_original / 1_048_576
|
||
comp_mb = total_compressed / 1_048_576
|
||
saved_mb = orig_mb - comp_mb
|
||
ratio = (saved_mb / orig_mb * 100) if orig_mb else 0
|
||
print(
|
||
f" Transcoded {transcoded} file(s): {orig_mb:.1f} MB → {comp_mb:.1f} MB (saved {saved_mb:.1f} MB, -{ratio:.0f}%)"
|
||
)
|
||
if replace:
|
||
print(f" Originals deleted.")
|
||
if skipped:
|
||
print(f" Skipped {skipped} already-transcoded file(s)")
|
||
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# Render Command
|
||
# =============================================================================
|
||
|
||
|
||
def _format_time(seconds: float) -> str:
|
||
"""Format seconds as MM:SS.ms"""
|
||
if seconds < 0:
|
||
return "??:??.??"
|
||
mins = int(seconds // 60)
|
||
secs = seconds % 60
|
||
return f"{mins:02d}:{secs:05.2f}"
|
||
|
||
|
||
def _print_render_plan_details(plan, marker_timings, slides: dict, events=None) -> None:
|
||
"""
|
||
Print a detailed render plan showing each marker with its (final) time.
|
||
|
||
`events` is the resolved events.json list — the source of truth. Every marker
|
||
has an interpolated final_time there even when the aligner couldn't place it,
|
||
so markers the raw alignment marks unaligned still show their real position.
|
||
"""
|
||
from .models import CAMERA_PRESETS
|
||
|
||
events_by_id = {e["id"]: e for e in (events or [])}
|
||
|
||
print("\n RENDER PLAN:")
|
||
print(" " + "-" * 76)
|
||
|
||
# Build lookup for video events by video_id
|
||
video_events_by_id = {}
|
||
for event in plan.video_events:
|
||
video_events_by_id[event.video_id] = event
|
||
|
||
audio_events_by_time = {}
|
||
for event in plan.audio_events:
|
||
t = round(event.start_time, 1)
|
||
if t not in audio_events_by_time:
|
||
audio_events_by_time[t] = []
|
||
audio_events_by_time[t].append(event)
|
||
|
||
camera_events_by_time = {}
|
||
for event in plan.camera_events:
|
||
t = round(event.time, 1)
|
||
if t not in camera_events_by_time:
|
||
camera_events_by_time[t] = []
|
||
camera_events_by_time[t].append(event)
|
||
|
||
# Output-time position for every slide — i.e. when it actually appears in the
|
||
# finished video, WITH any narration pauses already added. Both aligned and
|
||
# interpolated slides print from this so the plan reads in one consistent
|
||
# (output) time base and matches the rendered result.
|
||
_slide_out = {e.slide_id: e.start_time for e in plan.slide_events}
|
||
|
||
# Detect slide markers that share a timestamp with the adjacent slide marker.
|
||
# Two slides at the same time means alignment is ambiguous — treat as an error.
|
||
slide_timings = [
|
||
t for t in marker_timings if t.marker_id in slides and t.timestamp >= 0
|
||
]
|
||
collision_ids: set[str] = set()
|
||
for a, b in zip(slide_timings, slide_timings[1:]):
|
||
if abs(a.timestamp - b.timestamp) < 0.1:
|
||
collision_ids.add(a.marker_id)
|
||
collision_ids.add(b.marker_id)
|
||
|
||
# Print each marker timing
|
||
aligned_count = 0
|
||
unaligned_count = 0
|
||
collision_count = 0
|
||
|
||
for timing in marker_timings:
|
||
marker_id = timing.marker_id
|
||
context = timing.context
|
||
if len(context) > 50:
|
||
context = context[:47] + "..."
|
||
|
||
if timing.timestamp >= 0:
|
||
time_str = _format_time(timing.timestamp)
|
||
|
||
# Show confidence if fuzzy match
|
||
conf_str = ""
|
||
if timing.confidence < 1.0:
|
||
conf_str = f" ({timing.confidence:.0%})"
|
||
|
||
# Determine marker type for display. Slides print their FINAL (output)
|
||
# time — when the viewer sees them, pauses included — from the plan.
|
||
if marker_id in slides:
|
||
_st = _format_time(_slide_out.get(marker_id, timing.timestamp))
|
||
if marker_id in collision_ids:
|
||
collision_count += 1
|
||
print(
|
||
f' {marker_id:6} {_st}{conf_str} COLLISION - same time as adjacent slide - "{context}"'
|
||
)
|
||
else:
|
||
aligned_count += 1
|
||
print(f' {marker_id:6} {_st}{conf_str} "{context}"')
|
||
elif any(
|
||
marker_id.startswith(p)
|
||
for p in (
|
||
"video:",
|
||
"vft:",
|
||
"vfb:",
|
||
"vf2t:",
|
||
"vf2b:",
|
||
"vst:",
|
||
"vsb:",
|
||
"vftp:",
|
||
"vfbp:",
|
||
"vf2tp:",
|
||
"vf2bp:",
|
||
"vstp:",
|
||
"vsbp:",
|
||
)
|
||
):
|
||
aligned_count += 1
|
||
pfx_len = next(
|
||
len(p)
|
||
for p in (
|
||
"video:",
|
||
"vft:",
|
||
"vfb:",
|
||
"vf2t:",
|
||
"vf2b:",
|
||
"vst:",
|
||
"vsb:",
|
||
"vftp:",
|
||
"vfbp:",
|
||
"vf2tp:",
|
||
"vf2bp:",
|
||
"vstp:",
|
||
"vsbp:",
|
||
)
|
||
if marker_id.startswith(p)
|
||
)
|
||
# Handles are stored lowercased in videos.json (and the plan's video
|
||
# events), so lowercase before the lookup — otherwise a camel-cased
|
||
# marker like vst:KnightRotating misses and shows '?'.
|
||
video_id = marker_id[pfx_len:].lower()
|
||
# Find corresponding event by video_id
|
||
event = video_events_by_id.get(video_id)
|
||
if event:
|
||
cutout_name = event.cutout_name
|
||
end_on = event.video_source.end_on or "next_slide"
|
||
layer_tag = f" [{event.layer}]"
|
||
else:
|
||
# No resolved event — but the shorthand prefix itself fixes the
|
||
# cutout and layer (vst: = square/above), so never show '?'.
|
||
from .transformer import _SHORTHAND_PREFIXES
|
||
|
||
_pfx = next(
|
||
(p for p in _SHORTHAND_PREFIXES if marker_id.startswith(p)), None
|
||
)
|
||
if _pfx:
|
||
cutout_name, _layer = _SHORTHAND_PREFIXES[_pfx]
|
||
layer_tag = f" [{_layer}]"
|
||
else:
|
||
cutout_name = "?"
|
||
layer_tag = ""
|
||
end_on = "next_slide"
|
||
|
||
cache_ind = " 📁" if video_id in plan.cached_files else ""
|
||
print(
|
||
f" {marker_id:20} {time_str} in '{cutout_name}' [{end_on}]{layer_tag}{cache_ind}"
|
||
)
|
||
elif marker_id.startswith("narration:"):
|
||
aligned_count += 1
|
||
video_id = marker_id[10:]
|
||
cache_ind = " 📁" if video_id in plan.cached_files else ""
|
||
print(f" {marker_id:20} {time_str} (continuous){cache_ind}")
|
||
elif marker_id in CAMERA_PRESETS:
|
||
aligned_count += 1
|
||
print(f" {time_str} [{marker_id}]")
|
||
elif marker_id.startswith("audio:"):
|
||
aligned_count += 1
|
||
print(f" {time_str} [{marker_id}]")
|
||
else:
|
||
aligned_count += 1
|
||
_st = _format_time(_slide_out.get(marker_id, timing.timestamp))
|
||
print(f' {marker_id:6} {_st} "{context}"')
|
||
else:
|
||
unaligned_count += 1
|
||
# The aligner couldn't place this marker, but events.json interpolates
|
||
# EVERY marker (slide/video/audio/camera), so show that final time — it's
|
||
# where the render actually puts it. Only truly-missing markers read '??'.
|
||
_ev = events_by_id.get(marker_id)
|
||
if _ev and _ev.get("final_time") is not None:
|
||
interp_str = _format_time(_ev["final_time"])
|
||
print(f' {marker_id:20} ~{interp_str} INTERPOLATED - "{context}"')
|
||
elif marker_id in slides:
|
||
interp_event = next(
|
||
(e for e in plan.slide_events if e.slide_id == marker_id), None
|
||
)
|
||
if interp_event:
|
||
interp_str = _format_time(
|
||
_slide_out.get(marker_id, interp_event.start_time)
|
||
)
|
||
print(f' {marker_id:6} ~{interp_str} INTERPOLATED - "{context}"')
|
||
else:
|
||
print(f' {marker_id:6} ??:??.?? NOT ALIGNED - "{context}"')
|
||
else:
|
||
print(f' {marker_id:6} ??:??.?? NOT ALIGNED - "{context}"')
|
||
|
||
print(" " + "-" * 76)
|
||
|
||
# Summary
|
||
total_markers = len(marker_timings)
|
||
slide_markers = [t for t in marker_timings if t.marker_id in slides]
|
||
good_slides = len(
|
||
[
|
||
t
|
||
for t in slide_markers
|
||
if t.timestamp >= 0 and t.marker_id not in collision_ids
|
||
]
|
||
)
|
||
total_slides = len(slide_markers)
|
||
|
||
issues = []
|
||
if unaligned_count:
|
||
issues.append(f"{unaligned_count} UNALIGNED")
|
||
if collision_count:
|
||
issues.append(f"{collision_count} COLLISION")
|
||
status = "OK" if not issues else ", ".join(issues)
|
||
print(f" Markers: {aligned_count}/{total_markers} aligned ({status})")
|
||
print(f" Slides: {good_slides}/{total_slides}")
|
||
print(
|
||
f" Videos: {len(plan.video_events)} triggered, {len(plan.narration_videos)} always-visible"
|
||
)
|
||
if plan.outro_events:
|
||
print(f" Outro: {len(plan.outro_events)} video(s)")
|
||
for event in plan.outro_events:
|
||
print(
|
||
f" - {event.video_id}: {_format_time(event.start_time)} - {_format_time(event.end_time)}"
|
||
)
|
||
print(f" Duration: {_format_time(plan.total_duration)}")
|
||
|
||
|
||
def _parse_slide_range(slides_arg: str) -> tuple[str, Optional[str]]:
|
||
"""Parse slide range argument like 'S1:S10' or 'S5:' into a tuple."""
|
||
if ":" not in slides_arg:
|
||
raise ValueError(
|
||
f"Invalid slide range '{slides_arg}'. Expected format: S1:S10 or S5:"
|
||
)
|
||
|
||
parts = slides_arg.split(":", 1)
|
||
start_slide = parts[0].strip()
|
||
end_slide = parts[1].strip() if parts[1].strip() else None
|
||
|
||
if not start_slide:
|
||
raise ValueError(
|
||
f"Invalid slide range '{slides_arg}'. Start slide is required."
|
||
)
|
||
|
||
return start_slide, end_slide
|
||
|
||
|
||
def _resolve_pause_duration(
|
||
entry: dict, video_id: str, videos_dir: Path, shared_duration: dict[str, float]
|
||
) -> Optional[float]:
|
||
"""Resolve a clip's duration for auto-setting pause_narration.
|
||
|
||
Tries, in order: the local videos.json entry's own 'duration', the shared
|
||
library's duration (for is_shared clips whose metadata lives elsewhere), then
|
||
a direct ffprobe of the source file. Returns None if none succeed.
|
||
"""
|
||
dur = entry.get("duration") or shared_duration.get(video_id.lower())
|
||
if dur:
|
||
return float(dur)
|
||
|
||
source_file = entry.get("source_file")
|
||
if source_file:
|
||
candidate = videos_dir / source_file
|
||
if candidate.exists():
|
||
try:
|
||
from .preprocessor import get_video_duration
|
||
|
||
return round(get_video_duration(candidate), 3)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _project_markers_to_videos(
|
||
markers: list[str], videos_json_path: Path, config, project_path: Path = None
|
||
) -> None:
|
||
"""ETL: project shorthand marker semantics into videos.json.
|
||
|
||
Scans the manuscript marker list for shorthand prefixes (vft:, vfb:, vst:,
|
||
vsb:, vf2t:, vf2b: and their pause variants) and writes the implied cutout
|
||
and layer values directly into videos.json. This runs before parse_videos
|
||
so the render pass reads already-projected data and needs no shorthand logic.
|
||
|
||
Videos may live in the project's local videos.json or in shared_assets/videos.json.
|
||
Both files are updated so the render pass always finds the projected values.
|
||
|
||
The manuscript is the authoritative source: the LAST shorthand reference to
|
||
a given video_id wins, matching what a human editor would expect when they
|
||
change a marker near the end of the script.
|
||
"""
|
||
if not videos_json_path.exists():
|
||
return
|
||
|
||
from .transformer import _SHORTHAND_PREFIXES # (cutout, layer) lookup table
|
||
|
||
_PAUSE_PREFIXES = {
|
||
"vftp:", "vfbp:", "vfmp:",
|
||
"vf2tp:", "vf2bp:", "vf2mp:",
|
||
"vstp:", "vsbp:", "vsmp:",
|
||
}
|
||
|
||
# Build projection: video_id → {cutout, layer, auto_pause_narration}
|
||
# auto_pause_narration=True means: write pause_narration=duration if not already set.
|
||
projection: dict[str, dict] = {}
|
||
for marker in markers:
|
||
for prefix, implied in _SHORTHAND_PREFIXES.items():
|
||
if marker.startswith(prefix):
|
||
video_id = marker[len(prefix):].lower()
|
||
cutout, layer = implied[0], implied[1]
|
||
projection[video_id] = {
|
||
"cutout": cutout,
|
||
"layer": layer,
|
||
"_auto_pause": prefix in _PAUSE_PREFIXES,
|
||
}
|
||
break
|
||
|
||
if not projection:
|
||
return
|
||
|
||
# Build case-insensitive indexes of shared_assets pause_narration and duration.
|
||
# When a video is marked is_shared its metadata lives in the shared canonical
|
||
# entry, not the local one — so a pause-prefix marker on a shared clip would
|
||
# otherwise never get pause_narration set (the local entry has no duration).
|
||
_shared_pause: dict[str, float] = {}
|
||
_shared_duration: dict[str, float] = {}
|
||
for _shared_candidate in [
|
||
project_path / "shared_assets" / "videos.json",
|
||
project_path.parent / "shared_assets" / "videos.json",
|
||
]:
|
||
if _shared_candidate and _shared_candidate.exists():
|
||
try:
|
||
with open(_shared_candidate, "r", encoding="utf-8") as _f:
|
||
_shared_raw = json.load(_f)
|
||
for _k, _v in _shared_raw.items():
|
||
pn = _v.get("pause_narration")
|
||
if pn:
|
||
_shared_pause[_k.lower()] = float(pn)
|
||
dur = _v.get("duration")
|
||
if dur:
|
||
_shared_duration[_k.lower()] = float(dur)
|
||
except (json.JSONDecodeError, OSError):
|
||
pass
|
||
break
|
||
|
||
def _apply_projection(json_path: Path) -> list[str]:
|
||
"""Apply projection to one videos.json file; return list of updated IDs."""
|
||
if not json_path.exists():
|
||
return []
|
||
with open(json_path, "r", encoding="utf-8") as f:
|
||
raw = json.load(f)
|
||
changed = False
|
||
updated = []
|
||
for video_id, fields in projection.items():
|
||
if video_id not in raw:
|
||
continue
|
||
entry = raw[video_id]
|
||
video_changed = False
|
||
for field, value in fields.items():
|
||
if field == "_auto_pause":
|
||
# A pause-prefix marker (vftp:, vfbp:, …) means "freeze the
|
||
# narration for this clip's whole length", so pause_narration
|
||
# must equal the clip's duration. Set it whenever:
|
||
# - marker is a pause-prefix (value is True)
|
||
# - pause_narration not already set (preserve manual overrides)
|
||
# - a duration can be resolved (local entry, shared library,
|
||
# or by probing the file as a last resort)
|
||
if value and not entry.get("pause_narration"):
|
||
dur = _resolve_pause_duration(
|
||
entry, video_id, json_path.parent, _shared_duration
|
||
)
|
||
if dur:
|
||
entry["pause_narration"] = dur
|
||
changed = True
|
||
video_changed = True
|
||
elif entry.get(field) != value:
|
||
entry[field] = value
|
||
changed = True
|
||
video_changed = True
|
||
# For is_shared entries: inherit pause_narration from shared_assets if
|
||
# still not set (handles an explicit pause_narration that lives on a
|
||
# different-case key in the shared library).
|
||
if entry.get("is_shared") and not entry.get("pause_narration"):
|
||
shared_pn = _shared_pause.get(video_id.lower())
|
||
if shared_pn:
|
||
entry["pause_narration"] = shared_pn
|
||
changed = True
|
||
video_changed = True
|
||
if video_changed:
|
||
updated.append(video_id)
|
||
if changed:
|
||
with open(json_path, "w", encoding="utf-8") as f:
|
||
json.dump(raw, f, indent=2, ensure_ascii=False)
|
||
return updated
|
||
|
||
updated_local = _apply_projection(videos_json_path)
|
||
if updated_local:
|
||
print(f" Projected marker semantics → videos.json: {', '.join(updated_local)}")
|
||
|
||
|
||
def _writeback_video_metadata(plan, project_path, config) -> None:
|
||
"""Write back cutout/layer derived from shorthand markers to videos.json.
|
||
|
||
When a shorthand like [vfb:FARTSection1] is used and FARTSection1 has no
|
||
'cutout' set in videos.json, this persists the resolved cutout (and layer if
|
||
the shorthand implies a non-default layer) back to the file. Once written,
|
||
subsequent renders read the value directly and no further write-back occurs.
|
||
"""
|
||
import json
|
||
|
||
videos_json_path = project_path / config.videos_path
|
||
if not videos_json_path.exists():
|
||
return
|
||
|
||
# Collect field updates per video_id
|
||
writebacks: dict[str, dict] = {}
|
||
for event in plan.video_events:
|
||
video_id = event.video_id
|
||
source = event.video_source
|
||
if source.is_shared:
|
||
continue # shared videos live in their own file
|
||
|
||
updates = {}
|
||
if source.cutout is None and event.cutout_name:
|
||
updates["cutout"] = event.cutout_name
|
||
if event.layer != source.layer:
|
||
updates["layer"] = event.layer
|
||
|
||
if updates:
|
||
writebacks.setdefault(video_id, {}).update(updates)
|
||
|
||
if not writebacks:
|
||
return
|
||
|
||
with open(videos_json_path, "r", encoding="utf-8") as f:
|
||
raw = json.load(f)
|
||
|
||
changed = False
|
||
for video_id, updates in writebacks.items():
|
||
if video_id not in raw:
|
||
continue
|
||
for field, value in updates.items():
|
||
if raw[video_id].get(field) != value:
|
||
raw[video_id][field] = value
|
||
changed = True
|
||
|
||
if changed:
|
||
with open(videos_json_path, "w", encoding="utf-8") as f:
|
||
json.dump(raw, f, indent=2, ensure_ascii=False)
|
||
written = ", ".join(
|
||
f"{vid}({', '.join(upd)})" for vid, upd in writebacks.items()
|
||
)
|
||
print(f" Updated videos.json: {written}")
|
||
|
||
|
||
def _chunk_boundary_span_warnings(plan, groups) -> list[str]:
|
||
"""Report clips that span a chunk boundary.
|
||
|
||
Chunking v2 (docs/chunking_v2.md) INCLUDES these in the later chunk and seeks
|
||
into them (VideoEvent.skip_override / AudioEvent.src_offset) so they resume
|
||
mid-clip instead of being dropped. The seam then relies on `-c copy` joining
|
||
frame-aligned chunks, which is the one thing worth eyeballing — so this stays as
|
||
an informational list (logged; shown on the terminal only with --verbose), not
|
||
the hard "will be dropped" warning of v1.
|
||
"""
|
||
slide_start = {e.slide_id: e.start_time for e in plan.slide_events}
|
||
boundaries = [] # (slide_id, output_time) at each chunk seam after the first
|
||
for g in groups[1:]:
|
||
t = slide_start.get(g[0])
|
||
if t is not None:
|
||
boundaries.append((g[0], t))
|
||
if not boundaries:
|
||
return []
|
||
|
||
warnings: list[str] = []
|
||
|
||
def _check(start, end, label, kind):
|
||
crossed = [(sid, bt) for sid, bt in boundaries if start < bt < end]
|
||
if crossed:
|
||
sid, bt = crossed[0]
|
||
extra = f" (and {len(crossed) - 1} more)" if len(crossed) > 1 else ""
|
||
warnings.append(
|
||
f"{kind} '{label}' plays {_format_time(start)}–{_format_time(end)} and "
|
||
f"crosses the chunk boundary at {sid} ({_format_time(bt)}){extra}; v2 "
|
||
f"seeks into it so it continues across the seam."
|
||
)
|
||
|
||
for e in plan.video_events:
|
||
_check(e.start_time, e.end_time,
|
||
getattr(e.video_source, "source_file", e.video_id), "video")
|
||
for e in plan.outro_events:
|
||
_check(e.start_time, e.end_time,
|
||
getattr(e.video_source, "source_file", e.video_id), "outro video")
|
||
for e in plan.audio_events:
|
||
ad = e.audio_def
|
||
if getattr(ad, "loop", False):
|
||
end = plan.total_duration
|
||
elif getattr(ad, "duration", None) is not None:
|
||
end = e.start_time + ad.duration
|
||
else:
|
||
continue # unknown length — can't judge span
|
||
_check(e.start_time, end, ad.file, "audio")
|
||
return warnings
|
||
|
||
|
||
def _chunked_render(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
res: str,
|
||
force: bool,
|
||
chunk_size: int,
|
||
slide_ids: list[str],
|
||
out_dir: Path,
|
||
final_output: Path,
|
||
plan=None,
|
||
concat: bool = True,
|
||
) -> int:
|
||
"""Render in slide-based chunks — avoids filter graph OOM on long videos.
|
||
|
||
concat=True (default) concatenates the chunks into `final_output` and deletes
|
||
the parts. concat=False (debug, -n) keeps each chunk as its own inspectable
|
||
file in `out_dir`, named `{stem}_{Sfirst}_{Slast}{suffix}`, and does not
|
||
concatenate — for isolating which slide range a long render fails on.
|
||
"""
|
||
# Split slide IDs into groups of chunk_size
|
||
groups = [
|
||
slide_ids[i : i + chunk_size] for i in range(0, len(slide_ids), chunk_size)
|
||
]
|
||
_mode = "concatenate" if concat else "separate files (debug, no concat)"
|
||
print(
|
||
f"\n Chunking: {len(slide_ids)} slides → {len(groups)} chunks of ≤{chunk_size} [{_mode}]"
|
||
)
|
||
|
||
# Report clips that span a chunk boundary. v2 seeks into them so they continue
|
||
# across the seam (no longer dropped); this is informational — logged always,
|
||
# and echoed to the terminal only under --verbose — with a nudge to eyeball the
|
||
# seam since concat uses -c copy.
|
||
if plan is not None:
|
||
_span_notes = _chunk_boundary_span_warnings(plan, groups)
|
||
if _span_notes:
|
||
_render_log(f"chunking v2: {len(_span_notes)} clip(s) span a boundary (seam-seeked):")
|
||
for w in _span_notes:
|
||
_render_log(f" - {w}")
|
||
if verbose:
|
||
print(f"\n {len(_span_notes)} clip(s) span a chunk boundary — v2 seeks across the seam:",
|
||
file=sys.stderr)
|
||
for w in _span_notes:
|
||
print(f" - {w}", file=sys.stderr)
|
||
print(" If a seam looks off, verify frame alignment (concat uses -c copy).",
|
||
file=sys.stderr)
|
||
|
||
if concat:
|
||
chunks_dir = out_dir / "chunks"
|
||
chunks_dir.mkdir(parents=True, exist_ok=True)
|
||
else:
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
chunk_paths: list[Path] = []
|
||
for i, group in enumerate(groups):
|
||
start = group[0]
|
||
end = groups[i + 1][0] if i + 1 < len(groups) else None
|
||
slides_arg = f"{start}:{end}" if end else f"{start}:"
|
||
if concat:
|
||
chunk_path = chunks_dir / f"chunk_{i+1:03d}_{start}-{end or 'end'}.mp4"
|
||
else:
|
||
# Debug: land next to the final output, named by this chunk's own range.
|
||
chunk_path = out_dir / f"{final_output.stem}_{start}_{group[-1]}{final_output.suffix}"
|
||
|
||
print(f"\n {'='*56}")
|
||
print(f" Chunk {i+1}/{len(groups)}: {slides_arg} → {chunk_path.name}")
|
||
print(f" {'='*56}")
|
||
|
||
result = cmd_render(
|
||
project_path,
|
||
verbose,
|
||
dry_run,
|
||
slides_arg=slides_arg,
|
||
res=res,
|
||
force=force,
|
||
_output_path_override=chunk_path,
|
||
)
|
||
if result != 0:
|
||
print(f"\n Chunk {i+1} failed — aborting.", file=sys.stderr)
|
||
if not concat:
|
||
print(
|
||
f" → the render fails somewhere in {slides_arg} "
|
||
f"({len(group)} slides). Narrow with a smaller -n.",
|
||
file=sys.stderr,
|
||
)
|
||
return result
|
||
chunk_paths.append(chunk_path)
|
||
|
||
# Debug mode: leave the per-chunk files on disk for inspection, no concat.
|
||
if not concat:
|
||
if dry_run:
|
||
print(f"\n [dry-run] Would write {len(groups)} separate chunk file(s) to {out_dir}")
|
||
return 0
|
||
print(f"\n Wrote {len(chunk_paths)} chunk file(s) to {out_dir}:")
|
||
for p in chunk_paths:
|
||
print(f" {p.name}")
|
||
return 0
|
||
|
||
if dry_run:
|
||
print(
|
||
f"\n [dry-run] Would concatenate {len(chunk_paths)} chunks → {final_output}"
|
||
)
|
||
return 0
|
||
|
||
# Concatenate chunks
|
||
print(f"\n Concatenating {len(chunk_paths)} chunks → {final_output.name}...")
|
||
concat_list = chunks_dir / "concat.txt"
|
||
with open(concat_list, "w") as f:
|
||
for p in chunk_paths:
|
||
f.write(f"file '{p.resolve()}'\n")
|
||
|
||
concat_cmd = [
|
||
"ffmpeg",
|
||
"-y",
|
||
"-f",
|
||
"concat",
|
||
"-safe",
|
||
"0",
|
||
"-i",
|
||
str(concat_list),
|
||
"-c",
|
||
"copy",
|
||
str(final_output),
|
||
]
|
||
result = subprocess.run(concat_cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
print(f" Concatenation failed:\n{result.stderr}", file=sys.stderr)
|
||
return 1
|
||
|
||
# Clean up chunk files
|
||
for p in chunk_paths:
|
||
p.unlink(missing_ok=True)
|
||
concat_list.unlink(missing_ok=True)
|
||
try:
|
||
chunks_dir.rmdir()
|
||
except OSError:
|
||
pass
|
||
|
||
print(f" Output: {final_output}")
|
||
return 0
|
||
|
||
|
||
def _build_merged_transcription(project_path: Path, config, verbose: bool = False):
|
||
"""Deterministic merged transcript for slide alignment.
|
||
|
||
Builds a single word-level transcript from the per-segment transcripts +
|
||
the current narration.json skip/take, re-timed into the combined timeline
|
||
(see narration.build_narration_schedule). This keeps alignment in sync with
|
||
narration.json and avoids re-transcribing the combined file.
|
||
|
||
Returns None (caller falls back to the on-disk transcript) when there are no
|
||
narration segments or any segment is missing its per-segment transcript.
|
||
"""
|
||
from .parser import parse_narration, get_video_duration
|
||
from .narration import build_narration_schedule
|
||
|
||
try:
|
||
narration, narration_dir = parse_narration(project_path, config)
|
||
except GnommoError:
|
||
return None
|
||
if not narration:
|
||
return None
|
||
|
||
transcripts_dir = narration_dir / "transcripts"
|
||
missing = [sid for sid in narration if not (transcripts_dir / f"{sid}.json").exists()]
|
||
if missing:
|
||
if verbose:
|
||
print(f" Merged transcript unavailable (no per-segment transcript for: "
|
||
f"{', '.join(missing)}) — using on-disk transcript.")
|
||
return None
|
||
|
||
_segments, merged = build_narration_schedule(
|
||
narration, narration_dir, get_video_duration,
|
||
transcripts_dir=transcripts_dir, verbose=verbose,
|
||
)
|
||
return merged or None
|
||
|
||
|
||
def cmd_build(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
slides_arg: str = None,
|
||
res: str = "full",
|
||
force: bool = False,
|
||
realign: bool = False,
|
||
) -> int:
|
||
"""Build the timing scaffold (events.json + scaffold.json) without rendering.
|
||
|
||
Aligns manuscript markers to the transcript (or reuses/updates events.json
|
||
times), writes the editable events.json + compiled scaffold.json, and stops.
|
||
Edit events.json to nudge slide/video timings, then run `render`. Pass
|
||
--realign to discard existing events.json times and re-align from scratch
|
||
(e.g. after re-recording narration).
|
||
"""
|
||
return cmd_render(
|
||
project_path,
|
||
verbose,
|
||
dry_run,
|
||
slides_arg,
|
||
res,
|
||
force,
|
||
plan_only=True,
|
||
realign=realign,
|
||
)
|
||
|
||
|
||
# ── Render logging ────────────────────────────────────────────────────────────
|
||
# A hard crash on Windows/Linux (the OS OOM-killing ffmpeg or python, a native
|
||
# segfault) leaves no Python traceback and just prints "Terminated". So we tee the
|
||
# whole render to <project>/<project>.log with line-flushing: the log keeps a
|
||
# header (platform / ffmpeg / memory / args) and the exact ffmpeg command, so a
|
||
# run that dies mid-encode can still be diagnosed from the last lines written.
|
||
|
||
_RENDER_LOGFILE = None
|
||
|
||
|
||
class _TeeStream:
|
||
"""Write to the real stream and mirror completed lines into a log file.
|
||
|
||
Progress-bar redraws (carriage returns with no newline) are dropped from the
|
||
log; only whole lines are kept, so the log stays greppable. Everything is
|
||
flushed immediately so a hard kill still leaves the trail on disk.
|
||
"""
|
||
|
||
def __init__(self, stream, logfile):
|
||
self._stream = stream
|
||
self._logfile = logfile
|
||
self._buf = ""
|
||
|
||
def write(self, data):
|
||
self._stream.write(data)
|
||
self._buf += data
|
||
while "\n" in self._buf:
|
||
line, self._buf = self._buf.split("\n", 1)
|
||
try:
|
||
self._logfile.write(line.split("\r")[-1] + "\n")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self._logfile.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
def flush(self):
|
||
self._stream.flush()
|
||
try:
|
||
self._logfile.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
def __getattr__(self, name):
|
||
return getattr(self._stream, name)
|
||
|
||
|
||
def _render_log(msg: str) -> None:
|
||
"""Write a line only to the render log (not the terminal)."""
|
||
if _RENDER_LOGFILE is not None:
|
||
try:
|
||
_RENDER_LOGFILE.write(msg + "\n")
|
||
_RENDER_LOGFILE.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _system_mem():
|
||
"""Return (available_bytes, total_bytes) of physical RAM, or None.
|
||
|
||
Cross-platform without requiring psutil (the machine that OOM-crashes is
|
||
Windows): prefers psutil, falls back to Windows GlobalMemoryStatusEx and
|
||
Linux /proc/meminfo so system-memory pressure is visible everywhere.
|
||
"""
|
||
try:
|
||
import psutil
|
||
|
||
vm = psutil.virtual_memory()
|
||
return vm.available, vm.total
|
||
except Exception:
|
||
pass
|
||
if sys.platform.startswith("win"):
|
||
try:
|
||
import ctypes
|
||
|
||
class _MEMSTAT(ctypes.Structure):
|
||
_fields_ = [
|
||
("dwLength", ctypes.c_ulong),
|
||
("dwMemoryLoad", ctypes.c_ulong),
|
||
("ullTotalPhys", ctypes.c_ulonglong),
|
||
("ullAvailPhys", ctypes.c_ulonglong),
|
||
("ullTotalPageFile", ctypes.c_ulonglong),
|
||
("ullAvailPageFile", ctypes.c_ulonglong),
|
||
("ullTotalVirtual", ctypes.c_ulonglong),
|
||
("ullAvailVirtual", ctypes.c_ulonglong),
|
||
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||
]
|
||
|
||
m = _MEMSTAT()
|
||
m.dwLength = ctypes.sizeof(_MEMSTAT)
|
||
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
|
||
return int(m.ullAvailPhys), int(m.ullTotalPhys)
|
||
except Exception:
|
||
return None
|
||
if sys.platform.startswith("linux"):
|
||
try:
|
||
info = {}
|
||
with open("/proc/meminfo") as f:
|
||
for line in f:
|
||
k, _, v = line.partition(":")
|
||
info[k.strip()] = int(v.strip().split()[0]) * 1024
|
||
return info.get("MemAvailable", info.get("MemFree", 0)), info.get("MemTotal", 0)
|
||
except Exception:
|
||
return None
|
||
if sys.platform == "darwin":
|
||
try:
|
||
import os as _os
|
||
|
||
page = _os.sysconf("SC_PAGE_SIZE")
|
||
total = _os.sysconf("SC_PHYS_PAGES") * page
|
||
out = subprocess.run(["vm_stat"], capture_output=True, text=True).stdout
|
||
free = inactive = spec = 0
|
||
for line in out.splitlines():
|
||
num = line.split(":")[-1].strip().rstrip(".")
|
||
if line.startswith("Pages free:"):
|
||
free = int(num)
|
||
elif line.startswith("Pages inactive:"):
|
||
inactive = int(num)
|
||
elif line.startswith("Pages speculative:"):
|
||
spec = int(num)
|
||
return (free + inactive + spec) * page, total
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _start_memory_sampler(logfile, interval: float = 3.0):
|
||
"""Log memory every `interval`s in a background thread → OOM visibility.
|
||
|
||
Records system free/used RAM (always) and the gnommo+ffmpeg process-tree RSS
|
||
(when psutil is available). Returns a stop callback + peak dict, or None. On a
|
||
hard OOM kill the finally block never runs, but the per-sample lines are
|
||
flushed as they happen, so the log shows memory climbing right up to the kill.
|
||
"""
|
||
import threading
|
||
|
||
total = _system_mem()
|
||
if total is None:
|
||
_render_log("memory sampler: unavailable on this platform")
|
||
return None
|
||
|
||
try:
|
||
import psutil
|
||
|
||
_proc = psutil.Process()
|
||
except Exception:
|
||
psutil = None
|
||
_proc = None
|
||
_render_log(
|
||
"memory sampler: system RAM only ('pip install psutil' adds per-process RSS)"
|
||
)
|
||
|
||
stop = threading.Event()
|
||
peak = {"rss": 0, "used_pct": 0.0}
|
||
|
||
def _tree_rss():
|
||
if _proc is None:
|
||
return None
|
||
try:
|
||
rss = _proc.memory_info().rss
|
||
for c in _proc.children(recursive=True):
|
||
try:
|
||
rss += c.memory_info().rss
|
||
except Exception:
|
||
pass
|
||
return rss
|
||
except Exception:
|
||
return None
|
||
|
||
def _loop():
|
||
while not stop.wait(interval):
|
||
mem = _system_mem()
|
||
if not mem:
|
||
continue
|
||
avail, tot = mem
|
||
used_pct = 100.0 * (tot - avail) / tot if tot else 0.0
|
||
peak["used_pct"] = max(peak["used_pct"], used_pct)
|
||
rss = _tree_rss()
|
||
if rss is not None:
|
||
peak["rss"] = max(peak["rss"], rss)
|
||
rss_str = f"gnommo+ffmpeg={rss / 1e9:.2f}GB | "
|
||
else:
|
||
rss_str = ""
|
||
try:
|
||
logfile.write(
|
||
f"[mem {datetime.now().strftime('%H:%M:%S')}] {rss_str}"
|
||
f"system {used_pct:.0f}% used, {avail / 1e9:.2f}GB free of {tot / 1e9:.1f}GB\n"
|
||
)
|
||
logfile.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
t = threading.Thread(target=_loop, daemon=True)
|
||
t.start()
|
||
return stop, peak
|
||
|
||
|
||
def _write_render_log_header(logfile, project_path, res, slides_arg, force, chunk_slides):
|
||
import os
|
||
import platform as _platform
|
||
|
||
try:
|
||
_ff = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True)
|
||
ffmpeg_ver = _ff.stdout.splitlines()[0] if _ff.stdout else "unknown"
|
||
except Exception:
|
||
ffmpeg_ver = "unavailable"
|
||
|
||
_m = _system_mem()
|
||
mem = (
|
||
f"{_m[0] / 1e9:.1f} GB free / {_m[1] / 1e9:.1f} GB total"
|
||
if _m
|
||
else "unknown"
|
||
)
|
||
|
||
logfile.write("=" * 70 + "\n")
|
||
logfile.write(f"gnommo render log — {project_path.name}\n")
|
||
logfile.write(f"time : {datetime.now().isoformat(timespec='seconds')}\n")
|
||
logfile.write(f"platform : {_platform.platform()}\n")
|
||
logfile.write(f"python : {sys.version.split()[0]}\n")
|
||
logfile.write(f"ffmpeg : {ffmpeg_ver}\n")
|
||
logfile.write(f"cpu_count : {os.cpu_count()}\n")
|
||
logfile.write(f"memory : {mem}\n")
|
||
logfile.write(
|
||
f"args : res={res} slides={slides_arg} force={force} chunk_slides={chunk_slides}\n"
|
||
)
|
||
logfile.write("=" * 70 + "\n\n")
|
||
logfile.flush()
|
||
|
||
|
||
def _preflight_memory_advisory(plan, config, res: str) -> None:
|
||
"""Warn BEFORE launching ffmpeg when the render is likely to exceed available
|
||
RAM, so an OOM kill is anticipated (with mitigations) rather than a surprise.
|
||
|
||
ffmpeg opens every -i input up front and each decoder holds frame buffers, so
|
||
peak memory scales with the concurrent input count times the frame size. The
|
||
estimate is deliberately rough — it only fires when memory is genuinely tight,
|
||
so it stays quiet on the roomy render rig and speaks up on an 8 GB VM.
|
||
"""
|
||
mem = _system_mem()
|
||
if not mem or not mem[1]:
|
||
return
|
||
avail, _total = mem
|
||
n_inputs = (
|
||
len(getattr(plan, "video_events", []) or [])
|
||
+ len(getattr(plan, "outro_events", []) or [])
|
||
+ len(getattr(plan, "narration_segments", []) or [])
|
||
+ (1 if getattr(plan, "background", None) else 0)
|
||
)
|
||
if n_inputs <= 0:
|
||
return
|
||
try:
|
||
w, h = config.resolution
|
||
except Exception:
|
||
w, h = 1920, 1080
|
||
# ~0.15 GB per active 4K video input (decoder + swscale + filter buffers),
|
||
# scaled by output pixel count, plus a base for ffmpeg + python themselves.
|
||
per_input_gb = 0.15 * (w * h) / (3840 * 2160)
|
||
est_gb = 0.5 + n_inputs * per_input_gb
|
||
avail_gb = avail / 1e9
|
||
_render_log(
|
||
f"[preflight] est ~{est_gb:.1f} GB for {n_inputs} inputs @ {w}x{h}, "
|
||
f"{avail_gb:.1f} GB free"
|
||
)
|
||
if est_gb > 0.8 * avail_gb:
|
||
print(
|
||
f" ! Memory advisory: ~{est_gb:.1f} GB estimated for {n_inputs} inputs "
|
||
f"at {w}x{h}, but only {avail_gb:.1f} GB free — risk of an OOM kill."
|
||
)
|
||
print(
|
||
" Consider: render --res low, a smaller chunk_slides, or freeing RAM. "
|
||
"(rough estimate; if it survives, ignore me)"
|
||
)
|
||
|
||
|
||
# Minimum ffmpeg the render is validated against. Older builds (e.g. Ubuntu 24.04's
|
||
# 6.1.1) mis-mix the paused-narration audio — the talking head goes quiet before
|
||
# interstitial videos and recovers after — silently producing a wrong file.
|
||
_MIN_FFMPEG = (7, 0, 2)
|
||
|
||
|
||
def _ffmpeg_version_at_least(minimum: tuple) -> tuple:
|
||
"""Return (ok, version_str). ok is False only when a *parseable* version is
|
||
below `minimum`. Unparseable output (git/nightly builds) or a missing ffmpeg
|
||
is allowed here (ok=True) — those fail later with their own clearer error."""
|
||
import re
|
||
|
||
try:
|
||
out = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True)
|
||
first = out.stdout.splitlines()[0] if out.stdout else ""
|
||
except Exception:
|
||
return True, "unavailable"
|
||
m = re.search(r"version\s+n?(\d+)\.(\d+)(?:\.(\d+))?", first)
|
||
if not m:
|
||
return True, (first.replace("ffmpeg version", "").strip()[:40] or "unknown")
|
||
ver = (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
|
||
return ver >= minimum, ".".join(str(x) for x in ver)
|
||
|
||
|
||
def cmd_render(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
slides_arg: str = None,
|
||
res: str = "full",
|
||
force: bool = False,
|
||
chunk_slides: int = 0,
|
||
chunk_debug_slides: int = 0,
|
||
_output_path_override: Path = None,
|
||
plan_only: bool = False,
|
||
realign: bool = False,
|
||
) -> int:
|
||
"""Render entry point — opens <project>/<project>.log, then runs the render.
|
||
|
||
Internal chunk sub-renders (_output_path_override set) and any nested call
|
||
while a log is already open reuse the parent log instead of clobbering it.
|
||
"""
|
||
global _RENDER_LOGFILE
|
||
|
||
passthrough = dict(
|
||
slides_arg=slides_arg,
|
||
res=res,
|
||
force=force,
|
||
chunk_slides=chunk_slides,
|
||
chunk_debug_slides=chunk_debug_slides,
|
||
_output_path_override=_output_path_override,
|
||
plan_only=plan_only,
|
||
realign=realign,
|
||
)
|
||
|
||
if _output_path_override is not None or _RENDER_LOGFILE is not None:
|
||
return _cmd_render_impl(project_path, verbose, dry_run, **passthrough)
|
||
|
||
log_path = project_path / f"{project_path.name}.log"
|
||
try:
|
||
logfile = open(log_path, "w", encoding="utf-8", buffering=1)
|
||
except OSError:
|
||
return _cmd_render_impl(project_path, verbose, dry_run, **passthrough)
|
||
|
||
_write_render_log_header(logfile, project_path, res, slides_arg, force, chunk_slides)
|
||
_orig_out, _orig_err = sys.stdout, sys.stderr
|
||
sys.stdout = _TeeStream(_orig_out, logfile)
|
||
sys.stderr = _TeeStream(_orig_err, logfile)
|
||
_RENDER_LOGFILE = logfile
|
||
_sampler = _start_memory_sampler(logfile)
|
||
try:
|
||
return _cmd_render_impl(project_path, verbose, dry_run, **passthrough)
|
||
except BaseException:
|
||
import traceback
|
||
|
||
logfile.write("\n=== EXCEPTION / ABORT ===\n")
|
||
traceback.print_exc(file=logfile)
|
||
logfile.flush()
|
||
raise
|
||
finally:
|
||
if _sampler is not None:
|
||
_stop, _peak = _sampler
|
||
_stop.set()
|
||
_peak_rss = f"{_peak['rss'] / 1e9:.2f} GB tree RSS, " if _peak["rss"] else ""
|
||
logfile.write(
|
||
f"\n[peak memory] {_peak_rss}system peaked at {_peak['used_pct']:.0f}% used\n"
|
||
)
|
||
sys.stdout = _orig_out
|
||
sys.stderr = _orig_err
|
||
_RENDER_LOGFILE = None
|
||
try:
|
||
logfile.write(
|
||
f"\n[render log closed {datetime.now().isoformat(timespec='seconds')}]\n"
|
||
)
|
||
logfile.close()
|
||
except Exception:
|
||
pass
|
||
print(f" (render log: {log_path})")
|
||
|
||
|
||
def _cmd_render_impl(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
slides_arg: str = None,
|
||
res: str = "full",
|
||
force: bool = False,
|
||
chunk_slides: int = 0,
|
||
chunk_debug_slides: int = 0,
|
||
_output_path_override: Path = None,
|
||
plan_only: bool = False,
|
||
realign: bool = False,
|
||
) -> int:
|
||
"""Render final video.
|
||
|
||
Two-stage timing model: this builds/refreshes events.json + scaffold.json (the
|
||
editable timing layer) and then renders. When events.json already exists its
|
||
times are used verbatim (hand-edits win, no re-alignment) unless realign=True.
|
||
With plan_only=True it stops after writing the scaffold — that's the `build`
|
||
command.
|
||
"""
|
||
from .parser import (
|
||
parse_audio,
|
||
parse_manuscript,
|
||
parse_project_config,
|
||
parse_slides,
|
||
parse_videos,
|
||
save_citations,
|
||
)
|
||
from .transcriber import load_transcript
|
||
from .validator import validate_project
|
||
from .transformer import build_render_plan
|
||
from .renderer import render, generate_ffmpeg_command_string
|
||
from .preprocessor import RES_CONFIGS, ensure_downscaled_files_exist
|
||
from .cache import set_active_project
|
||
|
||
# Apply this project's project.json "performance" overrides (chunk size, cpu
|
||
# limits) before any chunk-size or thread-count decisions below.
|
||
set_active_project(project_path)
|
||
|
||
# ffmpeg version guard — only for an actual encode (not dry-run/build, and only
|
||
# once at the top level, not per chunk sub-render). Older builds silently
|
||
# mis-mix the paused-narration audio, so refuse rather than produce a bad file.
|
||
if not dry_run and not plan_only and _output_path_override is None:
|
||
_ok, _ver = _ffmpeg_version_at_least(_MIN_FFMPEG)
|
||
if not _ok:
|
||
_min = ".".join(str(x) for x in _MIN_FFMPEG)
|
||
print(
|
||
f"Error: ffmpeg {_ver} is too old — render requires >= {_min}.",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
" Older builds mis-mix paused-narration audio (talking head goes "
|
||
"quiet before interstitials). Install a static build:",
|
||
file=sys.stderr,
|
||
)
|
||
print(" https://johnvansickle.com/ffmpeg/", file=sys.stderr)
|
||
return 1
|
||
|
||
# Parse slide range if provided
|
||
_verb = "Building scaffold" if plan_only else "Rendering"
|
||
slide_range = None
|
||
if slides_arg:
|
||
slide_range = _parse_slide_range(slides_arg)
|
||
print(f"{_verb}: {project_path.name} (slides {slides_arg})")
|
||
else:
|
||
print(f"{_verb}: {project_path.name}")
|
||
|
||
# Show resolution mode
|
||
if res != "full":
|
||
cfg = RES_CONFIGS[res]
|
||
print(f" Resolution: {res.upper()} ({cfg[0]}x{cfg[1]})")
|
||
|
||
# Show cache status
|
||
cache_info = get_cache_info()
|
||
if cache_info:
|
||
print(f" Cache: {cache_info}")
|
||
|
||
# Stage 1: Parse
|
||
print("\n[1/4] Parsing...")
|
||
manuscript_text, markers, malformed, citations = parse_manuscript(project_path)
|
||
|
||
# Save citations for later use (e.g., description generation)
|
||
if citations:
|
||
citations_path = project_path / "citations.json"
|
||
save_citations(citations, citations_path)
|
||
config = parse_project_config(project_path)
|
||
|
||
# ETL: project shorthand marker semantics (cutout/layer) into videos.json
|
||
# before parse_videos reads it, so the render pass is purely data-driven.
|
||
_project_markers_to_videos(markers, project_path / config.videos_path, config, project_path)
|
||
|
||
# Override resolution for preview modes
|
||
if res != "full":
|
||
cfg = RES_CONFIGS[res]
|
||
config.resolution = (cfg[0], cfg[1])
|
||
|
||
slides = parse_slides(project_path, config)
|
||
videos, videos_dir = parse_videos(project_path, config)
|
||
source_videos_dir = videos_dir # keep original for validation (pre-downscale)
|
||
|
||
# Non-full res: use downscaled video directory, create on-the-fly if needed
|
||
if res != "full":
|
||
# Skip downscaling sources that have a preprocessed output_file — the
|
||
# renderer will use the full-res processed version instead, saving disk space.
|
||
sources_with_output = {v.source_file for v in videos.values() if v.output_file}
|
||
videos_dir = ensure_downscaled_files_exist(
|
||
videos_dir,
|
||
res,
|
||
force=False,
|
||
verbose=verbose,
|
||
skip_sources=sources_with_output,
|
||
)
|
||
if verbose:
|
||
print(f" Using {res} dir: {videos_dir}")
|
||
audio, audio_dir = parse_audio(project_path, config)
|
||
|
||
# --- Narration: render-time concat of the processed segments ---
|
||
# narration.json is the single source of truth; the processed segments are
|
||
# concatenated directly in the render graph.
|
||
from .narration import build_narration_schedule
|
||
from .parser import parse_narration as _parse_narr, get_video_duration
|
||
|
||
narration_map, narration_seg_dir = _parse_narr(project_path, config)
|
||
narration_schedule: list = []
|
||
narration_source = None
|
||
transcript_path = None
|
||
if narration_map:
|
||
narration_schedule, _ = build_narration_schedule(
|
||
narration_map, narration_seg_dir, get_video_duration
|
||
)
|
||
# Preprocess may write the processed segments to the process cache (an
|
||
# external disk that mirrors media/narration/) rather than locally. If the
|
||
# local outputs aren't present, rebuild the schedule against the cache so
|
||
# source paths — and their probed durations — resolve to the real files.
|
||
if any(not s.source_path.exists() for s in narration_schedule):
|
||
_cache_root = _resolve_process_cache(project_path, config)
|
||
if _cache_root:
|
||
_cache_narr = _cache_root / "media" / "narration"
|
||
narration_schedule, _ = build_narration_schedule(
|
||
narration_map, _cache_narr, get_video_duration
|
||
)
|
||
missing = [s.seg_id for s in narration_schedule if not s.source_path.exists()]
|
||
if missing:
|
||
print(
|
||
f"Error: processed narration segment(s) not found: {', '.join(missing)}",
|
||
file=sys.stderr,
|
||
)
|
||
print(f"Run 'gnommo -p {project_path.name} preprocess' first.", file=sys.stderr)
|
||
return 1
|
||
|
||
# Detect segments rendering from RAW because the processed file is missing.
|
||
# get_preprocessed_path silently falls back to raw_mov (the render-before-
|
||
# preprocess preview path), but for a real render that means un-keyed/un-graded
|
||
# footage — and large raw camera files with -probesize 1000 + -ss seeks are
|
||
# exactly what chokes ffmpeg on the render rig. Refuse rather than crash/ship it.
|
||
_raw_fallback = [
|
||
s.seg_id for s in narration_schedule if "_processed" not in s.source_path.name
|
||
]
|
||
if _raw_fallback and not force:
|
||
print(
|
||
f"\nError: narration would render from RAW footage — the processed files "
|
||
f"are missing for: {', '.join(_raw_fallback)}",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
" That produces un-keyed (green screen), un-graded output, and reading the "
|
||
"large raw camera files can crash ffmpeg on the render rig.",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
f" Run 'gnommo -p {project_path.name} preprocess' first, or pass -f/--force to "
|
||
"render from raw anyway (quick preview only).",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
elif _raw_fallback:
|
||
print(
|
||
f" ⚠ WARNING: rendering narration from RAW (processed missing): "
|
||
f"{', '.join(_raw_fallback)} — un-keyed/un-graded preview."
|
||
)
|
||
|
||
# Talking-head cutout/zoom/audio settings come from the first segment.
|
||
narration_source = narration_map[narration_schedule[0].seg_id]
|
||
|
||
# --- Transcript for slide alignment ---
|
||
# Prefer the deterministic merged transcript (per-segment transcripts re-timed
|
||
# into the concatenated timeline). Otherwise fall back to an on-disk transcript.
|
||
transcription = _build_merged_transcription(project_path, config, verbose)
|
||
if transcription is not None:
|
||
if verbose:
|
||
print(f" Using merged per-segment transcript ({len(transcription)} words)")
|
||
else:
|
||
if config.transcript_path and (project_path / config.transcript_path).exists():
|
||
transcript_path = project_path / config.transcript_path
|
||
elif narration_map:
|
||
# Narration project with no per-segment transcripts to merge.
|
||
print(
|
||
"Error: No per-segment transcripts found for the narration segments.",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
f"Run 'gnommo -p {project_path.name} trim' first (it transcribes each segment).",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
else:
|
||
result = _find_narration_video(config, videos)
|
||
if result:
|
||
_vid, _src = result
|
||
config.main_video = _vid
|
||
transcript_path = (videos_dir / _src.source_file).with_suffix(".transcript.json")
|
||
else:
|
||
transcript_path = project_path / "transcript.json"
|
||
transcript_path, _ = resolve_with_cache(transcript_path, project_path)
|
||
if not transcript_path.exists():
|
||
print(f"Error: Transcription not found: {transcript_path}", file=sys.stderr)
|
||
print(f"Run 'gnommo -p {project_path.name} trim' first (it produces per-segment transcripts).", file=sys.stderr)
|
||
return 1
|
||
transcription = load_transcript(transcript_path, project_path)
|
||
|
||
if verbose:
|
||
print(f" - Markers in manuscript: {len(markers)}")
|
||
print(f" - Slides defined: {len(slides)}")
|
||
print(f" - Audio clips: {len(audio)}")
|
||
print(f" - Transcription words: {len(transcription)}")
|
||
|
||
# Stage 2: Validate
|
||
print("\n[2/4] Validating...")
|
||
warnings = validate_project(
|
||
project_path, markers, config, slides, videos, source_videos_dir, malformed
|
||
)
|
||
for w in warnings:
|
||
print(f" Warning: {w}")
|
||
print(" Passed.")
|
||
|
||
# Stage 3: Transform.
|
||
# build (plan_only): align the manuscript to the transcript (or reuse an
|
||
# existing events.json), producing the timing layer.
|
||
# render: NEVER aligns — it requires the precomputed events.json/scaffold.json
|
||
# and executes them. If they're missing it errors, so `down` + `render` is
|
||
# all the rig ever needs and the output can't silently diverge from the build.
|
||
from . import scaffold as _scaffold
|
||
|
||
# Always read the previous events.json: even when re-aligning we carry each
|
||
# event's manual `adjustment` (a relative nudge) forward. `_existing_events`
|
||
# (the timing OVERRIDE — use stored times instead of aligning) is only set when
|
||
# NOT re-aligning, so render / `all` are unaffected by the always-read.
|
||
_old_events = _scaffold.read_events(project_path)
|
||
_existing_events = None if realign else _old_events
|
||
if not plan_only:
|
||
if _existing_events is None or _scaffold.read_scaffold(project_path) is None:
|
||
print(
|
||
f"Error: render requires the precomputed timing layer "
|
||
f"({_scaffold.EVENTS_FILE} + {_scaffold.SCAFFOLD_FILE}), which is missing.",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
f" Render does not align — run 'gnommo -p {project_path.name} build' first "
|
||
f"(then 'up'; on the rig, 'down' brings them over).",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
_timings_override = (
|
||
_scaffold.events_to_marker_timings(_existing_events) if _existing_events else None
|
||
)
|
||
if _timings_override is not None:
|
||
print("\n[3/4] Building render plan (using events.json timings)...")
|
||
else:
|
||
print("\n[3/4] Building render plan (aligning to transcript)...")
|
||
plan, marker_timings = build_render_plan(
|
||
project_path,
|
||
config,
|
||
slides,
|
||
videos,
|
||
videos_dir,
|
||
manuscript_text,
|
||
transcription,
|
||
audio,
|
||
audio_dir,
|
||
slide_range=slide_range,
|
||
narration_schedule=narration_schedule,
|
||
narration_source=narration_source,
|
||
marker_timings_override=_timings_override,
|
||
)
|
||
if plan.time_offset > 0:
|
||
print(f" Time offset: {plan.time_offset:.1f}s (partial render)")
|
||
if plan.narration_segments:
|
||
print(f" Narration concat: {len(plan.narration_segments)} segment(s) at render time")
|
||
|
||
# (The detailed render plan is printed by the build/timing-layer block below,
|
||
# so the computation and its printout live together — not in the render stage.)
|
||
if plan.audio_events:
|
||
print(f"\n Audio effects:")
|
||
for event in plan.audio_events:
|
||
loop_str = " (loop)" if event.audio_def.loop else ""
|
||
pause_str = " [ignores pauses]" if event.audio_def.ignore_pauses else ""
|
||
print(
|
||
f" - {event.audio_id}: '{event.audio_def.file}' @ {_format_time(event.start_time)}{loop_str}{pause_str}"
|
||
)
|
||
# Show always-visible videos
|
||
if plan.narration_videos:
|
||
print(f"\n Always-visible videos:")
|
||
for video_id, video_source, cutout in plan.narration_videos:
|
||
skip_str = (
|
||
f" (skip: {video_source.skip:.1f}s)" if video_source.skip > 0 else ""
|
||
)
|
||
cache_ind = " 📁" if video_id in plan.cached_files else ""
|
||
print(f" - {video_id} in '{video_source.cutout}'{skip_str}{cache_ind}")
|
||
|
||
# Show narration pauses
|
||
if plan.narration_pauses:
|
||
print(f"\n Narration pauses:")
|
||
for pause in plan.narration_pauses:
|
||
print(
|
||
f" - {pause.video_id} at {_format_time(pause.output_time)} "
|
||
f"for {pause.duration:.1f}s (narration freezes at {_format_time(pause.narration_time)})"
|
||
)
|
||
|
||
# Write tasks file with both missing assets and alignment issues
|
||
missing_videos = _collect_missing_video_markers(markers, videos)
|
||
slide_timings_for_collision = [
|
||
t for t in marker_timings if t.marker_id in slides and t.timestamp >= 0
|
||
]
|
||
collision_ids_render = set()
|
||
for _a, _b in zip(slide_timings_for_collision, slide_timings_for_collision[1:]):
|
||
if abs(_a.timestamp - _b.timestamp) < 0.1:
|
||
collision_ids_render.add(_a.marker_id)
|
||
collision_ids_render.add(_b.marker_id)
|
||
alignment_issues = [
|
||
(t.marker_id, t.context)
|
||
for t in marker_timings
|
||
if t.marker_id in slides
|
||
and (t.timestamp < 0 or t.marker_id in collision_ids_render)
|
||
]
|
||
_write_tasks_file(project_path, missing_videos, alignment_issues)
|
||
|
||
# --- Timing layer (BUILD only): derive events, keep hand-edits, persist
|
||
# events.json + scaffold.json, and print the plan. Render never runs this — it
|
||
# only consumes the files — so the timing layer is written exactly once, by build.
|
||
if plan_only and slide_range is None and _output_path_override is None:
|
||
_events = _scaffold.derive_events(marker_timings, slides, videos, audio)
|
||
# Carry each id's human `adjustment` (a relative nudge) forward from the
|
||
# previous events.json — matched by (id, ordinal) — so re-aligning keeps your
|
||
# offsets even though narration_time is recomputed from the transcript.
|
||
_events = _scaffold.merge_events(_events, _old_events)
|
||
if realign:
|
||
# Re-align (default): narration_time comes fresh from the transcript;
|
||
# recompute the interpolated markers between the aligned anchors.
|
||
_scaffold.reinterpolate_events(_events)
|
||
elif _old_events:
|
||
# Freeze (--no-realign): the marker_timings the plan used were EFFECTIVE
|
||
# narration (narration_time + adjustment). narration_time itself is owned
|
||
# by events.json — restore it so the effective value doesn't leak into the
|
||
# stored narration_time. Pair by ordinal (not id) so a marker reused
|
||
# several times keeps each occurrence's own narration_time.
|
||
for e, _old in _scaffold.pair_events_by_ordinal(_events, _old_events):
|
||
if _old is not None and _old.get("narration_time") is not None:
|
||
e["narration_time"] = _old["narration_time"]
|
||
# final_time = (narration + adjustment) shifted by preceding pauses.
|
||
_scaffold.compute_final_times(_events)
|
||
# events.json carries representation-only narration-track events so a GUI
|
||
# treats every track uniformly. scaffold.json (the compiled render timeline)
|
||
# stays marker-only, and the render never reads these back, so behaviour is
|
||
# unchanged — this is a read-only mirror of the narration backbone.
|
||
_events_gui = _events + _scaffold.derive_narration_events(
|
||
narration_schedule,
|
||
plan.narration_videos,
|
||
plan.narration_pauses,
|
||
plan.total_duration,
|
||
)
|
||
_scaffold.write_events(project_path, _events_gui)
|
||
_scaffold.write_scaffold(
|
||
project_path, _events, transcription, narration_schedule, plan.total_duration
|
||
)
|
||
# Spoken transcript with the aligned markers interleaved — diff against
|
||
# manuscript.txt to see where the recording drifts from the script.
|
||
_scaffold.write_transcribed_manuscript(project_path, _events, transcription)
|
||
_summ = _scaffold.mapping_summary(_events)
|
||
print(
|
||
f"\n Timing layer: {_summ['exact']} exact, {_summ['interpolated']} interpolated "
|
||
f"→ {_scaffold.EVENTS_FILE} + {_scaffold.SCAFFOLD_FILE} + {_scaffold.TRANSCRIBED_FILE}"
|
||
)
|
||
# The full render plan is printed here at build time — render just executes it.
|
||
_print_render_plan_details(plan, marker_timings, slides, events=_events)
|
||
if plan_only:
|
||
if _summ["interpolated"]:
|
||
print(
|
||
f" {_summ['interpolated']} event(s) interpolated. Nudge them in "
|
||
f"{_scaffold.EVENTS_FILE} via the \"adjustment\" field (seconds); it's a "
|
||
f"relative tweak that survives re-alignment."
|
||
)
|
||
print(
|
||
f" Compare {_scaffold.TRANSCRIBED_FILE} against manuscript.txt to see the drift, "
|
||
f"then run 'gnommo -p {project_path.name} render'."
|
||
)
|
||
else:
|
||
print(f" Run 'gnommo -p {project_path.name} render' to produce the video.")
|
||
return 0
|
||
elif plan_only:
|
||
# Partial build (--slides): nothing to persist for the full scaffold.
|
||
return 0
|
||
|
||
# Check for unaligned markers
|
||
unaligned = [t for t in marker_timings if t.timestamp < 0]
|
||
if slide_range and unaligned:
|
||
# Partial (--slides) render: only unaligned markers INSIDE the requested
|
||
# range should block it — failures elsewhere in the manuscript are
|
||
# irrelevant to this window. Restrict to the manuscript-order span
|
||
# [start_slide, end_slide].
|
||
order = [t.marker_id for t in marker_timings]
|
||
start_slide, end_slide = slide_range
|
||
lo = order.index(start_slide) if start_slide in order else 0
|
||
hi = order.index(end_slide) if (end_slide and end_slide in order) else len(order) - 1
|
||
in_range = set(order[lo : hi + 1])
|
||
skipped = [t for t in unaligned if t.marker_id not in in_range]
|
||
unaligned = [t for t in unaligned if t.marker_id in in_range]
|
||
if skipped:
|
||
print(
|
||
f"\n ({len(skipped)} unaligned marker(s) outside the {start_slide}:"
|
||
f"{end_slide or ''} range ignored for this partial render)"
|
||
)
|
||
if unaligned:
|
||
print(f"\n WARNING: {len(unaligned)} marker(s) could not be aligned!")
|
||
for t in unaligned:
|
||
print(f' [{t.marker_id}] - "{t.context}"')
|
||
if not force:
|
||
print(f"\n Run with -f/--force to render anyway.")
|
||
return 1
|
||
else:
|
||
print(f"\n Continuing anyway due to --force flag...")
|
||
|
||
# Stage 4: Render
|
||
# Determine output filename and directory
|
||
if _output_path_override:
|
||
output_path = _output_path_override
|
||
out_dir = output_path.parent
|
||
out_filename = output_path.name
|
||
else:
|
||
base = config.output_video if config.output_video else f"{config.co}.mp4"
|
||
# A partial (--slides) render appends the range to the filename so that
|
||
# e.g. S1:S9 and S10:S19 don't overwrite each other (or the full render).
|
||
if slide_range:
|
||
start, end = slide_range
|
||
rng = f"{start}_{end}" if end else f"{start}_end"
|
||
base_p = Path(base)
|
||
base = f"{base_p.stem}_{rng}{base_p.suffix or '.mp4'}"
|
||
out_filename = base
|
||
out_dir = project_path / "out" / res if res != "full" else project_path / "out"
|
||
output_path = out_dir / out_filename
|
||
|
||
# Check if chunked rendering is needed (avoids filter graph OOM on long videos)
|
||
from .cache import get_render_chunk_size
|
||
|
||
_slide_ids = [e.slide_id for e in plan.slide_events]
|
||
|
||
# Debug mode (-n N): render each N-slide chunk to its own file on disk and
|
||
# stop — no concatenation. Lets you see exactly which slide range a long
|
||
# render dies on, and what chunking does to the memory profile.
|
||
if chunk_debug_slides > 0 and _output_path_override is None:
|
||
if slide_range:
|
||
print(" -n/--chunk-debug cannot be combined with --slides.", file=sys.stderr)
|
||
return 1
|
||
return _chunked_render(
|
||
project_path,
|
||
verbose,
|
||
dry_run,
|
||
res,
|
||
force,
|
||
chunk_debug_slides,
|
||
_slide_ids,
|
||
out_dir,
|
||
output_path,
|
||
plan=plan,
|
||
concat=False,
|
||
)
|
||
|
||
_chunk_size = chunk_slides or get_render_chunk_size() or 0
|
||
if _chunk_size > 0 and not slide_range and len(_slide_ids) > _chunk_size:
|
||
return _chunked_render(
|
||
project_path,
|
||
verbose,
|
||
dry_run,
|
||
res,
|
||
force,
|
||
_chunk_size,
|
||
_slide_ids,
|
||
out_dir,
|
||
output_path,
|
||
plan=plan,
|
||
)
|
||
|
||
plan.output_path = output_path
|
||
|
||
if dry_run:
|
||
print("\n[4/4] FFmpeg command (dry run):")
|
||
print(generate_ffmpeg_command_string(plan, output_path))
|
||
return 0
|
||
|
||
# Stage-level staleness gate. Only whole-project renders are gated — partial
|
||
# (--slides) renders and internal chunk sub-renders always run. The render is
|
||
# skipped when the combined narration, manifests, manuscript, transcript and
|
||
# slide images are all unchanged since the last successful render of this
|
||
# resolution and the output file still exists.
|
||
from . import state as _state
|
||
|
||
_render_gateable = (
|
||
not force and slide_range is None and _output_path_override is None
|
||
)
|
||
|
||
def _render_input_specs() -> list:
|
||
_slides_json = project_path / config.slides_path.lower()
|
||
_slides_dir = _slides_json.parent
|
||
specs = [
|
||
("videos.json", project_path / config.videos_path, _state.HASH),
|
||
("audio.json", project_path / config.audio_path, _state.HASH),
|
||
("manuscript.txt", project_path / "manuscript.txt", _state.HASH),
|
||
("project.json", project_path / "project.json", _state.HASH),
|
||
("slides.json", _slides_json, _state.HASH),
|
||
# narration.json (skip/take) drives the concat timeline + alignment.
|
||
("narration.json", project_path / "media" / "narration" / "narration.json", _state.HASH),
|
||
]
|
||
if transcript_path:
|
||
specs.append(("transcript", transcript_path, _state.HASH))
|
||
# The concatenated narration segments (the render's main video input).
|
||
for _seg in plan.narration_segments:
|
||
specs.append((f"narr:{_seg.seg_id}", _seg.source_path, _state.META))
|
||
# Per-segment transcripts feed the merged transcript for alignment.
|
||
_tdir = project_path / "media" / "narration" / "transcripts"
|
||
if _tdir.is_dir():
|
||
for _tj in sorted(_tdir.glob("*.json")):
|
||
specs.append((f"transcript:{_tj.stem}", _tj, _state.HASH))
|
||
for _sid, _sdef in slides.items():
|
||
specs.append((f"slide:{_sid}", _slides_dir / _sdef.image, _state.META))
|
||
return specs
|
||
|
||
_render_key = f"render:{res}"
|
||
_render_current = _render_gateable and _state.is_current(
|
||
project_path, _render_key, _state.compute(_render_input_specs()), [output_path]
|
||
)
|
||
|
||
# Timestamp guard (make-style): even if the fingerprint matches, re-render
|
||
# when a key upstream artifact — the concatenated narration or its transcript —
|
||
# is newer than the rendered output. This catches cases the fingerprint
|
||
# can't: the state file isn't transferred to the render rig (it's a dotfile),
|
||
# and the combined may live on an external cache disk. If the previous stage
|
||
# produced a newer file, the render is stale regardless of recorded state.
|
||
if _render_current and output_path.exists():
|
||
try:
|
||
_out_mtime = output_path.stat().st_mtime
|
||
_deps = [s.source_path for s in plan.narration_segments]
|
||
if transcript_path:
|
||
_deps.append(transcript_path)
|
||
for _dep in _deps:
|
||
if _dep and Path(_dep).exists() and Path(_dep).stat().st_mtime > _out_mtime:
|
||
print(f" {Path(_dep).name} is newer than the render — regenerating.")
|
||
_render_current = False
|
||
break
|
||
except OSError:
|
||
_render_current = False
|
||
|
||
if _render_current:
|
||
print(f"\n[4/4] Output up to date: {output_path}")
|
||
print(" (inputs unchanged since last render — use --force to re-render)")
|
||
print("\nDone.")
|
||
return 0
|
||
|
||
print("\n[4/4] Rendering...")
|
||
_preflight_memory_advisory(plan, config, res)
|
||
# Record the exact ffmpeg command in the log only (not the terminal), so a
|
||
# render that gets hard-killed mid-encode can still be reproduced/diagnosed.
|
||
try:
|
||
_render_log("FFmpeg command:\n" + generate_ffmpeg_command_string(plan, output_path))
|
||
except Exception as _e:
|
||
_render_log(f"(could not serialize ffmpeg command: {_e})")
|
||
render(plan, output_path, verbose=verbose, log=_render_log)
|
||
print(f" Output: {output_path}")
|
||
|
||
if _render_gateable:
|
||
_state.record(project_path, _render_key, _state.compute(_render_input_specs()))
|
||
|
||
print("\nDone.")
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# Transcribe Command
|
||
# =============================================================================
|
||
|
||
|
||
def _find_narration_video(config, videos: dict) -> Optional[tuple[str, "VideoSource"]]:
|
||
"""
|
||
Find the video to use for transcription/narration.
|
||
|
||
Priority:
|
||
1. config.audio_source if set
|
||
2. First video with always_visible=True
|
||
3. First video in dict
|
||
"""
|
||
from .models import VideoSource
|
||
|
||
# 1. Check audio_source config
|
||
if config.audio_source and config.audio_source in videos:
|
||
return config.audio_source, videos[config.audio_source]
|
||
|
||
# 2. Find always_visible video (main talking head)
|
||
for video_id, video_source in videos.items():
|
||
if video_source.always_visible:
|
||
return video_id, video_source
|
||
|
||
# 3. Fall back to first video
|
||
if videos:
|
||
video_id = next(iter(videos.keys()))
|
||
return video_id, videos[video_id]
|
||
|
||
return None
|
||
|
||
|
||
# =============================================================================
|
||
# Grade Command
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_grade(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
file: Optional[str] = None,
|
||
ss: Optional[float] = None,
|
||
dur: float = 3.0,
|
||
overrides: Optional[list] = None,
|
||
stage: Optional[str] = None,
|
||
pick: Optional[str] = None,
|
||
) -> int:
|
||
"""Sample a raw narration clip through the talkinghead filter chain so you
|
||
can iterate on gnommokey / color_grade settings without a full preprocess.
|
||
|
||
Default: writes grade_preview.mov (keyed ProRes 4444 with alpha) to the
|
||
project root.
|
||
|
||
--set KEY=VALUE (repeatable) overrides any gnommokey field for the preview,
|
||
e.g. --set screen_gain=200 --set spill_suppress=1.5 --set screen_color=81,137,65
|
||
|
||
--sweep KEY=START:END:STEPS renders STEPS still frames varying one gnommokey
|
||
field, reports the transparent / opaque / partial-alpha pixel split for each
|
||
(to find the value that keys the background out cleanly without eating the
|
||
subject), and saves a magenta-composite PNG per step for eyeballing.
|
||
"""
|
||
from .parser import parse_project_config
|
||
from .preprocessor import _process_chunk_to_prores4444, get_video_duration
|
||
|
||
config = parse_project_config(project_path)
|
||
|
||
talkinghead_filter = (config.default_filters or {}).get("talkinghead", [])
|
||
if not talkinghead_filter:
|
||
print(
|
||
" ERROR: No 'talkinghead' filter defined in project.json default_filters."
|
||
)
|
||
return 1
|
||
|
||
# --- Resolve source clip ---
|
||
raw_dir = project_path / "media" / "narration" / "raw_mov"
|
||
_video_exts = {".mov", ".mp4", ".avi", ".mkv", ".m4v"}
|
||
|
||
if file:
|
||
source = Path(file)
|
||
if not source.is_absolute():
|
||
source = project_path / file
|
||
if not source.exists():
|
||
alt = raw_dir / Path(file).name
|
||
if alt.exists():
|
||
source = alt
|
||
if not source.exists():
|
||
print(f" ERROR: source file not found: {file}")
|
||
return 1
|
||
else:
|
||
candidates = (
|
||
sorted(
|
||
f
|
||
for f in raw_dir.iterdir()
|
||
if f.is_file()
|
||
and f.suffix.lower() in _video_exts
|
||
and not f.name.startswith(".")
|
||
)
|
||
if raw_dir.exists()
|
||
else []
|
||
)
|
||
if not candidates:
|
||
print(f" ERROR: no raw clips found in {raw_dir}")
|
||
print(" Pass --file to point at a specific clip.")
|
||
return 1
|
||
source = candidates[0]
|
||
|
||
# --- Resolve seek / duration, clamped to the clip length ---
|
||
clip_len = get_video_duration(source)
|
||
if ss is None:
|
||
if stage or pick:
|
||
# A frame well into the clip (subject settled, lit): ~1 min in, or
|
||
# the midpoint on a short clip.
|
||
ss = min(60.0, clip_len / 2)
|
||
else:
|
||
# Default: 5s in, or centred if the clip is short.
|
||
ss = 5.0 if clip_len > 8 else max(0.0, clip_len / 2 - dur / 2)
|
||
if ss >= clip_len:
|
||
ss = max(0.0, clip_len - dur)
|
||
take = min(dur, max(0.1, clip_len - ss))
|
||
|
||
# Deep-copy the filter chain so CLI overrides don't mutate the parsed config,
|
||
# and locate the gnommokey step (the keyer we tune).
|
||
import copy
|
||
filters = copy.deepcopy(talkinghead_filter)
|
||
key_cfg = next((f for f in filters if f.get("type") == "gnommokey"), None)
|
||
|
||
# Apply --set overrides to the gnommokey config.
|
||
if overrides:
|
||
if key_cfg is None:
|
||
print(" ERROR: no 'gnommokey' step in the talkinghead filter to override.")
|
||
return 1
|
||
for kv in overrides:
|
||
if "=" not in kv:
|
||
print(f" ERROR: --set expects KEY=VALUE, got '{kv}'")
|
||
return 1
|
||
k, v = kv.split("=", 1)
|
||
key_cfg[k.strip()] = _parse_grade_value(v.strip())
|
||
|
||
print(f"Grading preview: {project_path.name}")
|
||
print(f" Source: {source}")
|
||
|
||
# --- Pick mode: apply a previously-generated candidate from a manifest ---
|
||
if pick:
|
||
return _grade_pick(project_path, stage, pick)
|
||
|
||
# --- Stage mode: generate deterministic candidate stills + a manifest ---
|
||
if stage:
|
||
if key_cfg is None:
|
||
print(" ERROR: no 'gnommokey' step in the talkinghead filter.")
|
||
return 1
|
||
out_dir = project_path / "grade_sweep"
|
||
out_dir.mkdir(exist_ok=True)
|
||
ref = out_dir / "_ref.png"
|
||
if not _grade_extract_frame(source, ss, ref):
|
||
print(f" ERROR: could not extract a frame at {ss:.1f}s from {source.name}")
|
||
return 1
|
||
print(f" Stage: {stage} (reference frame {ref} @ {ss:.1f}s)")
|
||
stage_fn = {"key": _stage_key, "despill": _stage_despill, "grade": _stage_grade}[stage]
|
||
return stage_fn(project_path, filters, ref, out_dir, source.name, ss)
|
||
|
||
print(f" Sample: {take:.1f}s starting at {ss:.1f}s (clip is {clip_len:.1f}s)")
|
||
print(f" Filters: {len(filters)} step(s)")
|
||
if overrides:
|
||
print(f" Overrides: {', '.join(overrides)}")
|
||
|
||
mov_out = project_path / "grade_preview.mov"
|
||
_process_chunk_to_prores4444(
|
||
source,
|
||
mov_out,
|
||
filters,
|
||
start_time=ss,
|
||
chunk_duration=take,
|
||
verbose=verbose,
|
||
take=take,
|
||
keep_audio=False,
|
||
)
|
||
|
||
print(f"\n Done. Keyed preview (ProRes 4444, alpha): {mov_out}")
|
||
return 0
|
||
|
||
|
||
def _parse_grade_value(v: str):
|
||
"""Parse a --set value into list[int] (comma-separated), float, or str."""
|
||
if "," in v:
|
||
parts = [p.strip() for p in v.split(",")]
|
||
try:
|
||
return [int(p) for p in parts]
|
||
except ValueError:
|
||
return v
|
||
try:
|
||
f = float(v)
|
||
return int(f) if f.is_integer() else f
|
||
except ValueError:
|
||
return v
|
||
|
||
|
||
def _grade_extract_frame(source: Path, ss: float, out_png: Path) -> bool:
|
||
"""Extract a single RGB reference frame at `ss` seconds. Returns success."""
|
||
out_png.parent.mkdir(parents=True, exist_ok=True)
|
||
cmd = [
|
||
"ffmpeg", "-y", "-v", "error", "-ss", f"{ss:.3f}", "-i", str(source),
|
||
"-frames:v", "1", "-f", "image2", "-pix_fmt", "rgb24", str(out_png),
|
||
]
|
||
subprocess.run(cmd, capture_output=True)
|
||
return out_png.exists()
|
||
|
||
|
||
def _grade_video_filter(filters: "list[dict]") -> str:
|
||
"""Build an FFmpeg video-filter string from a list of filter-config dicts
|
||
(gnommokey / color_grade / mask), skipping audio steps."""
|
||
from .preprocessor import (
|
||
build_gnommokey_filter, build_color_grade_filter, build_mask_filter,
|
||
)
|
||
parts = []
|
||
for f in filters:
|
||
t = f.get("type")
|
||
if t == "gnommokey":
|
||
parts.append(build_gnommokey_filter(f))
|
||
elif t == "color_grade":
|
||
parts.append(build_color_grade_filter(f))
|
||
elif t == "mask":
|
||
m = build_mask_filter(f)
|
||
if m != "copy":
|
||
parts.append(m)
|
||
return ",".join(parts) if parts else "null"
|
||
|
||
|
||
def _grade_step(filters, step_type):
|
||
return next((f for f in filters if f.get("type") == step_type), None)
|
||
|
||
|
||
def _mask_and(filters, *chains):
|
||
"""Return [*chains, fixed mask] — the tuned step(s) followed by the mask."""
|
||
out = list(chains)
|
||
mask = _grade_step(filters, "mask")
|
||
if mask:
|
||
out.append(mask)
|
||
return out
|
||
|
||
|
||
def _eval_alpha(ref: Path, filters: "list[dict]") -> "tuple[float, float, float]":
|
||
"""Return (transparent, opaque, partial) fractions of the alpha channel.
|
||
|
||
A clean key = high transparent (background gone) + steady opaque (subject
|
||
intact) + low partial (mid-alpha = green fringe/spill the keyer missed).
|
||
"""
|
||
from collections import Counter
|
||
vf = _grade_video_filter(filters)
|
||
cmd = ["ffmpeg", "-v", "error", "-i", str(ref), "-frames:v", "1",
|
||
"-vf", f"{vf},format=yuva444p10le,alphaextract,format=gray",
|
||
"-f", "rawvideo", "-"]
|
||
raw = subprocess.run(cmd, capture_output=True).stdout
|
||
total = len(raw)
|
||
if total == 0:
|
||
return (0.0, 0.0, 1.0)
|
||
h = Counter(raw)
|
||
transparent = sum(c for b, c in h.items() if b < 16)
|
||
opaque = sum(c for b, c in h.items() if b > 240)
|
||
return (transparent / total, opaque / total,
|
||
(total - transparent - opaque) / total)
|
||
|
||
|
||
def _read_subject_rgba(ref: Path, filters: "list[dict]", step: int = 7):
|
||
"""Yield (r,g,b) for subsampled subject skin pixels — opaque, fleshy (has
|
||
blue, so not the low-blue yellow suit or pure green screen), not deep shadow."""
|
||
vf = _grade_video_filter(filters)
|
||
cmd = ["ffmpeg", "-v", "error", "-i", str(ref), "-frames:v", "1",
|
||
"-vf", f"{vf},format=rgba", "-f", "rawvideo", "-pix_fmt", "rgba", "-"]
|
||
raw = subprocess.run(cmd, capture_output=True).stdout
|
||
stride = 4 * step
|
||
for i in range(0, len(raw) - 3, stride):
|
||
r, g, b, a = raw[i], raw[i + 1], raw[i + 2], raw[i + 3]
|
||
if a > 200 and 55 < b < 210 and r > 70 and r >= b:
|
||
yield r, g, b
|
||
|
||
|
||
def _measure_green_cast(ref: Path, filters: "list[dict]") -> float:
|
||
"""Mean green tint on subject skin: G − (R+B)/2. >0 residual green, ~0
|
||
neutral, <0 over-despilled toward magenta."""
|
||
tot, n = 0.0, 0
|
||
for r, g, b in _read_subject_rgba(ref, filters):
|
||
tot += g - (r + b) / 2.0
|
||
n += 1
|
||
return round(tot / n, 2) if n else 0.0
|
||
|
||
|
||
def _measure_skin(ref: Path, filters: "list[dict]") -> "tuple[int, int, int]":
|
||
"""Mean (R,G,B) of subject skin pixels."""
|
||
sr = sg = sb = n = 0
|
||
for r, g, b in _read_subject_rgba(ref, filters):
|
||
sr += r; sg += g; sb += b; n += 1
|
||
return (sr // n, sg // n, sb // n) if n else (0, 0, 0)
|
||
|
||
|
||
def _measure_suit(ref: Path, filters: "list[dict]") -> "tuple[int, int, int]":
|
||
"""Mean (R,G,B) of yellow-costume pixels (opaque, high R&G, low B). R−G is
|
||
the 'orange-ness': ~0 = pure yellow, larger = more orange."""
|
||
vf = _grade_video_filter(filters)
|
||
cmd = ["ffmpeg", "-v", "error", "-i", str(ref), "-frames:v", "1",
|
||
"-vf", f"{vf},format=rgba", "-f", "rawvideo", "-pix_fmt", "rgba", "-"]
|
||
raw = subprocess.run(cmd, capture_output=True).stdout
|
||
sr = sg = sb = n = 0
|
||
for i in range(0, len(raw) - 3, 4 * 7):
|
||
r, g, b, a = raw[i], raw[i + 1], raw[i + 2], raw[i + 3]
|
||
if a > 200 and r > 120 and g > 90 and b < 90 and r > b and g > b:
|
||
sr += r; sg += g; sb += b; n += 1
|
||
return (sr // n, sg // n, sb // n) if n else (0, 0, 0)
|
||
|
||
|
||
def _png_dims(path: Path) -> "tuple[int, int]":
|
||
"""Return (width, height) of an image/frame, or (1920, 1080) on failure."""
|
||
r = subprocess.run(
|
||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", str(path)],
|
||
capture_output=True, text=True,
|
||
)
|
||
try:
|
||
w, h = r.stdout.strip().split("x")[:2]
|
||
return int(w), int(h)
|
||
except ValueError:
|
||
return (1920, 1080)
|
||
|
||
|
||
def _render_preview(ref: Path, filters: "list[dict]", out_png: Path) -> None:
|
||
"""Save the filtered still composited over magenta (to judge the matte).
|
||
|
||
The magenta layer is sized to the frame so the full frame shows at any
|
||
source resolution — a fixed-size backdrop would crop the overlay."""
|
||
vf = _grade_video_filter(filters)
|
||
w, h = _png_dims(ref)
|
||
cmd = ["ffmpeg", "-y", "-v", "error",
|
||
"-f", "lavfi", "-i", f"color=c=magenta:s={w}x{h}",
|
||
"-i", str(ref), "-filter_complex",
|
||
f"[1]{vf},format=yuva444p10le[fg];"
|
||
f"[0][fg]overlay=shortest=1,format=rgb24",
|
||
str(out_png)]
|
||
subprocess.run(cmd, capture_output=True)
|
||
|
||
|
||
def _grade_write_manifest(out_dir: Path, manifest: dict) -> Path:
|
||
p = out_dir / f"{manifest['stage']}_manifest.json"
|
||
with open(p, "w", encoding="utf-8") as f:
|
||
json.dump(manifest, f, indent=2)
|
||
return p
|
||
|
||
|
||
def _stage_key(project_path, filters, ref, out_dir, source_name, ss) -> int:
|
||
"""Deterministic aggressiveness sweep → 10 matte variants to pick from.
|
||
|
||
An objective search always prefers the most aggressive key (lowest fringe),
|
||
which erodes edges and eats low-saturation subject pixels (e.g. eyes go
|
||
transparent/magenta). So instead of auto-picking, this ramps the three
|
||
eroding matte knobs together — screen_gain, shadow_boost, clip_black — from
|
||
gentle (subject fully intact, maybe faint background residue) to aggressive
|
||
(background gone but subject eroding), and lets the eye judge. The opaque
|
||
column falls as the key eats the subject; pick the balance before that."""
|
||
base = _grade_step(filters, "gnommokey")
|
||
|
||
# Pre-scan (gain only, no eroders) for the gentlest gain that clears the
|
||
# background — the "knee" where transparency plateaus. The sweep then centres
|
||
# on it so the variants aren't all bunched to one side of the useful range.
|
||
scan_gains = [60, 90, 120, 150, 180, 210, 240, 270]
|
||
scan = [(g, _eval_alpha(ref, _mask_and(filters, dict(base, screen_gain=g, shadow_boost=0, clip_black=0)))[0])
|
||
for g in scan_gains]
|
||
max_t = max(t for _, t in scan)
|
||
g_knee = next((g for g, t in scan if t >= max_t - 0.005), scan_gains[-1])
|
||
|
||
steps = [round(i / 9, 2) for i in range(10)] # aggressiveness 0.0 .. 1.0
|
||
print(f" Background clears near gain {g_knee}; sweeping aggressiveness 0.0 → 1.0 centred there")
|
||
print(" (gain 0.6×→1.5× knee; shadow_boost/clip_black kick in only past the knee)")
|
||
candidates = []
|
||
for i, a in enumerate(steps, 1):
|
||
params = {
|
||
# gain spans under-keyed → over the knee; eroders (shadow_boost,
|
||
# clip_black) stay at 0 until past the knee, then ramp — so the gentle
|
||
# half is clean and only the aggressive half erodes.
|
||
"screen_gain": int(round(g_knee * (0.6 + 0.9 * a))),
|
||
"shadow_boost": round(max(0.0, 3.0 * (a - 0.5) / 0.5), 2),
|
||
"clip_black": int(round(max(0.0, 12 * (a - 0.6) / 0.4))),
|
||
}
|
||
cfg = dict(base, **params)
|
||
t, o, p = _eval_alpha(ref, _mask_and(filters, cfg))
|
||
png = out_dir / f"key_{i}.png"
|
||
_render_preview(ref, _mask_and(filters, cfg), png)
|
||
params["aggressiveness"] = a
|
||
candidates.append({
|
||
"id": f"key_{i}", "file": png.name, "params": params,
|
||
"hint": {"transparent_pct": round(t * 100, 1),
|
||
"opaque_pct": round(o * 100, 1),
|
||
"partial_pct": round(p * 100, 1)},
|
||
})
|
||
# Recommend the *gentlest* variant that has essentially cleared the background
|
||
# (transparency within 0.3% of the max) — the knee. Below it is under-keyed;
|
||
# above it only erodes the subject.
|
||
peak_t = max(c["hint"]["transparent_pct"] for c in candidates)
|
||
rec = next((c["id"] for c in candidates
|
||
if c["hint"]["transparent_pct"] >= peak_t - 0.5), candidates[0]["id"])
|
||
manifest = {
|
||
"stage": "key", "target_step": "gnommokey",
|
||
"source": source_name, "ss": round(ss, 2),
|
||
"swept": ["screen_gain", "shadow_boost", "clip_black"],
|
||
"recommended": rec, "candidates": candidates,
|
||
}
|
||
mpath = _grade_write_manifest(out_dir, manifest)
|
||
print(f" {'id':>8} {'aggr':>5} {'gain':>4} {'shadow':>6} {'transp':>7} {'opaque':>7} {'partial':>7} png")
|
||
print(f" {'-'*8} {'-'*5} {'-'*4} {'-'*6} {'-'*7} {'-'*7} {'-'*7} {'-'*3}")
|
||
for c in candidates:
|
||
star = " ◀ suggested" if c["id"] == rec else ""
|
||
pr, h = c["params"], c["hint"]
|
||
print(f" {c['id']:>8} {pr['aggressiveness']:>5} {pr['screen_gain']:>4} {pr['shadow_boost']:>6} "
|
||
f"{h['transparent_pct']:>6}% {h['opaque_pct']:>6}% {h['partial_pct']:>6}% {c['file']}{star}")
|
||
print("\n opaque falls as the key eats the subject (eroded edges, keyed eyes) — pick just before that.")
|
||
print(f" Manifest: {mpath}")
|
||
print(" Pick the gentlest variant that clears the background without eroding the head/eyes:")
|
||
print(f" gnommo -p {project_path.name} grade --pick key_4")
|
||
return 0
|
||
|
||
|
||
def _stage_despill(project_path, filters, ref, out_dir, source_name, ss) -> int:
|
||
"""Deterministic spill_suppress sweep 0.5–1.5 → candidates for a visual pick.
|
||
|
||
Reports both sides of the tradeoff: scalp green_cast (want ~0) AND the
|
||
costume's orange-ness (suit R−G, want low). Over-despilling clears the
|
||
scalp but pushes a yellow costume orange, so the balance point — not the
|
||
maximum — is usually right."""
|
||
base = _grade_step(filters, "gnommokey")
|
||
values = [round(0.5 + (1.5 - 0.5) * i / 8, 3) for i in range(9)] # 0.5 .. 1.5
|
||
print(" Sweeping spill_suppress 0.5 → 1.5 (yellow_protect held fixed)")
|
||
suit_raw = _measure_suit(ref, _mask_and(filters, dict(base, spill_suppress=0)))
|
||
candidates = []
|
||
for i, v in enumerate(values, 1):
|
||
cfg = dict(base)
|
||
cfg["spill_suppress"] = v
|
||
chain = _mask_and(filters, cfg)
|
||
cast = _measure_green_cast(ref, chain)
|
||
suit = _measure_suit(ref, chain)
|
||
png = out_dir / f"despill_{i}.png"
|
||
_render_preview(ref, chain, png)
|
||
candidates.append({
|
||
"id": f"despill_{i}", "file": png.name,
|
||
"params": {"spill_suppress": v},
|
||
"hint": {"green_cast": cast, "suit_orange": suit[0] - suit[1]},
|
||
})
|
||
# Advisory: nearest-neutral scalp cast — the balance point that clears the
|
||
# scalp without needlessly orange-ing the costume (over-despill goes magenta).
|
||
rec = min(candidates, key=lambda c: abs(c["hint"]["green_cast"]))["id"]
|
||
manifest = {
|
||
"stage": "despill", "target_step": "gnommokey",
|
||
"source": source_name, "ss": round(ss, 2), "swept": ["spill_suppress"],
|
||
"recommended": rec, "candidates": candidates,
|
||
}
|
||
mpath = _grade_write_manifest(out_dir, manifest)
|
||
print(f" raw costume R−G (target for suit_orange): {suit_raw[0] - suit_raw[1]}")
|
||
print(f" {'id':>12} {'spill':>6} {'scalp cast':>10} {'suit_orange':>11} png")
|
||
print(f" {'-'*12} {'-'*6} {'-'*10} {'-'*11} {'-'*3}")
|
||
for c in candidates:
|
||
star = " ◀ suggested" if c["id"] == rec else ""
|
||
print(f" {c['id']:>12} {c['params']['spill_suppress']:>6} "
|
||
f"{c['hint']['green_cast']:>10} {c['hint']['suit_orange']:>11} {c['file']}{star}")
|
||
print("\n scalp cast: >0 green · ~0 neutral · <0 magenta | suit_orange: lower = more yellow")
|
||
print(f" Manifest: {mpath}")
|
||
print(" Pick the balance — scalp near 0 without the suit going orange:")
|
||
print(f" gnommo -p {project_path.name} grade --pick despill_5")
|
||
return 0
|
||
|
||
|
||
def _stage_grade(project_path, filters, ref, out_dir, source_name, ss) -> int:
|
||
"""Deterministic centered-grade × yellow-tint grid → color_grade candidates.
|
||
|
||
Two axes. 'grade' is a centered 1–9 dial: 5 keeps the camera's native
|
||
vibrance as-shot (no saturation/contrast/punch change), below 5 is paler
|
||
than default (desaturated, flatter), above 5 is more saturated with a
|
||
growing auto-levels punch. 'yellow_tint' hue-selectively pulls the yellow
|
||
costume back from the orange that punch introduces (negative = greener/
|
||
preserve, 0 = as-is). Skin (reds) is untouched by the tint, so you can crank
|
||
vibrance and keep the suit yellow."""
|
||
key = _grade_step(filters, "gnommokey")
|
||
grades = [1, 3, 5, 7, 9] # centered on 5 = camera vibrance
|
||
tints = [0.0, -0.4, -0.8] # 0 = as-is, negative = pull yellows back from orange
|
||
print(" Grid: grade {1, 3, 5, 7, 9} (5 = camera vibrance) × yellow_tint {0.0, -0.4, -0.8}")
|
||
candidates = []
|
||
recommended = None
|
||
i = 0
|
||
for g in grades:
|
||
# d ∈ [-1, +1] around the centered midpoint (grade 5 → d = 0).
|
||
d = (g - 5) / 4.0
|
||
for yt in tints:
|
||
i += 1
|
||
cg = {
|
||
"type": "color_grade",
|
||
# Punch only ramps ABOVE center; at/below 5 there is none.
|
||
"auto_levels": round(0.4 * max(0.0, d), 3),
|
||
# Symmetric around 1.0: <5 paler, 5 as-shot, >5 more saturated.
|
||
"saturation": round(1.0 + 0.25 * d, 3),
|
||
"contrast": round(1.0 + 0.08 * d, 3),
|
||
"brightness": round(0.02 * max(0.0, d), 3),
|
||
"yellow_tint": yt,
|
||
}
|
||
chain = _mask_and(filters, key, cg)
|
||
suit = _measure_suit(ref, chain)
|
||
png = out_dir / f"grade_{i}.png"
|
||
_render_preview(ref, chain, png)
|
||
params = {k: cg[k] for k in ("auto_levels", "saturation", "contrast", "brightness", "yellow_tint")}
|
||
params["grade"] = g
|
||
candidates.append({
|
||
"id": f"grade_{i}", "file": png.name, "params": params,
|
||
"hint": {"suit_rgb": list(suit), "orange": suit[0] - suit[1]},
|
||
})
|
||
# Default suggestion: the centered, true-to-camera look (grade 5, no tint).
|
||
if g == 5 and yt == 0.0:
|
||
recommended = f"grade_{i}"
|
||
manifest = {
|
||
"stage": "grade", "target_step": "color_grade",
|
||
"source": source_name, "ss": round(ss, 2), "swept": ["grade", "yellow_tint"],
|
||
"recommended": recommended, "candidates": candidates, # grade 5 = camera vibrance
|
||
}
|
||
mpath = _grade_write_manifest(out_dir, manifest)
|
||
rec = manifest["recommended"]
|
||
print(f" {'id':>10} {'grade':>5} {'tint':>5} {'suit RGB':>17} {'orange':>6} png")
|
||
print(f" {'-'*10} {'-'*5} {'-'*5} {'-'*17} {'-'*6} {'-'*3}")
|
||
for c in candidates:
|
||
star = " ◀ suggested (camera vibrance)" if c["id"] == rec else ""
|
||
print(f" {c['id']:>10} {c['params']['grade']:>5} {c['params']['yellow_tint']:>5} "
|
||
f"{str(tuple(c['hint']['suit_rgb'])):>17} {c['hint']['orange']:>6} {c['file']}{star}")
|
||
print("\n grade: 5 = camera vibrance, <5 paler than default, >5 more saturated")
|
||
print(" orange = suit R−G: lower is more yellow, higher is more orange")
|
||
print(f" Manifest: {mpath}")
|
||
print(" Pick a grade (paler <5 / punchier >5) + a tint that keeps the suit yellow:")
|
||
print(f" gnommo -p {project_path.name} grade --pick {rec}")
|
||
return 0
|
||
|
||
|
||
def _grade_pick(project_path, stage_hint, pick) -> int:
|
||
"""Apply a candidate (by id) from its stage manifest to project.json."""
|
||
out_dir = project_path / "grade_sweep"
|
||
stage = stage_hint
|
||
if "_" in pick and pick.split("_")[0] in ("key", "despill", "grade"):
|
||
stage = pick.split("_")[0]
|
||
if stage is None:
|
||
print(" ERROR: pass --stage with a numeric/best pick, or a full id like 'despill_5'.")
|
||
return 1
|
||
mpath = out_dir / f"{stage}_manifest.json"
|
||
if not mpath.exists():
|
||
print(f" ERROR: no manifest for '{stage}' — run 'grade --stage {stage}' first.")
|
||
return 1
|
||
manifest = _read_json(mpath)
|
||
cand_id = pick
|
||
if pick == "best":
|
||
cand_id = manifest.get("recommended")
|
||
elif pick.isdigit():
|
||
cand_id = f"{stage}_{pick}"
|
||
cand = next((c for c in manifest["candidates"] if c["id"] == cand_id), None)
|
||
if cand is None:
|
||
print(f" ERROR: candidate '{cand_id}' not in {mpath.name}.")
|
||
return 1
|
||
# 'grade'/'look'/'paleness'/'aggressiveness' are UI-only dials, not real fields.
|
||
params = {k: v for k, v in cand["params"].items()
|
||
if k not in ("grade", "look", "paleness", "aggressiveness")}
|
||
_apply_candidate_to_project(project_path, manifest["target_step"], params)
|
||
print(f" Applied {cand_id} → project.json ({manifest['target_step']}): "
|
||
+ ", ".join(f"{k}={v}" for k, v in params.items()))
|
||
return 0
|
||
|
||
|
||
def _apply_candidate_to_project(project_path, step_type, params) -> None:
|
||
"""Merge params into the talkinghead <step_type> step in project.json,
|
||
creating a color_grade step (before the mask) if it doesn't exist."""
|
||
vpath = project_path / "project.json"
|
||
data = _read_json(vpath)
|
||
th = (data.get("default_filters") or {}).get("talkinghead")
|
||
if not isinstance(th, list):
|
||
return
|
||
step = next((s for s in th if isinstance(s, dict) and s.get("type") == step_type), None)
|
||
if step is None and step_type == "color_grade":
|
||
step = {"type": "color_grade"}
|
||
idx = next((i for i, s in enumerate(th) if s.get("type") == "mask"), len(th))
|
||
th.insert(idx, step)
|
||
if step is None:
|
||
return
|
||
step.update(params)
|
||
with open(vpath, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
|
||
|
||
# =============================================================================
|
||
# Align Command
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_align(project_path: Path, verbose: bool) -> int:
|
||
"""Preview manuscript marker alignment (no files written)."""
|
||
from .transcriber import load_transcript
|
||
from .transformer import align_markers_to_transcription
|
||
from .parser import (
|
||
parse_project_config,
|
||
parse_videos,
|
||
parse_slides,
|
||
parse_audio,
|
||
parse_manuscript,
|
||
save_citations,
|
||
)
|
||
|
||
print(f"Alignment preview: {project_path.name}")
|
||
print(" (This is a preview - alignment happens automatically during render)")
|
||
|
||
# Load manuscript (cites are stripped at parse time)
|
||
manuscript_text, _, _, citations = parse_manuscript(project_path)
|
||
|
||
# Save citations for later use (e.g., description generation)
|
||
if citations:
|
||
citations_path = project_path / "citations.json"
|
||
save_citations(citations, citations_path)
|
||
|
||
# Load project config and resources
|
||
config = parse_project_config(project_path)
|
||
slides = parse_slides(project_path, config)
|
||
videos, videos_dir = parse_videos(project_path, config)
|
||
audio, _ = parse_audio(project_path, config)
|
||
|
||
# Find transcription (from narration video)
|
||
result = _find_narration_video(config, videos)
|
||
if not result:
|
||
print("Error: No suitable video found for transcription", file=sys.stderr)
|
||
return 1
|
||
|
||
video_id, video_source = result
|
||
video_path = videos_dir / video_source.source_file
|
||
transcript_path = video_path.with_suffix(".transcript.json")
|
||
|
||
# Try cache fallback for transcript
|
||
transcript_path, _ = resolve_with_cache(transcript_path, project_path)
|
||
if not transcript_path.exists():
|
||
print(f"Error: Transcription not found: {transcript_path}", file=sys.stderr)
|
||
print(f"Run 'gnommo -p {project_path.name} trim' first (it produces per-segment transcripts).", file=sys.stderr)
|
||
return 1
|
||
|
||
print(f" Loading: {transcript_path.name}")
|
||
transcription = load_transcript(transcript_path, project_path)
|
||
print(f" - {len(transcription)} words")
|
||
|
||
# Align (cite markers already stripped at parse time)
|
||
print("\n Aligning markers to transcription...")
|
||
timings = align_markers_to_transcription(
|
||
manuscript_text, transcription, slides=slides, videos=videos, audio=audio
|
||
)
|
||
|
||
# Report alignment results
|
||
unmatched = 0
|
||
fuzzy_matched = 0
|
||
exact_matched = 0
|
||
|
||
for t in timings:
|
||
if t.timestamp >= 0:
|
||
if t.confidence >= 1.0:
|
||
exact_matched += 1
|
||
if verbose:
|
||
print(f" [{t.marker_id}] @ {_format_time(t.timestamp)}")
|
||
else:
|
||
fuzzy_matched += 1
|
||
# Always show fuzzy matches so user can verify
|
||
print(
|
||
f" [{t.marker_id}] @ {_format_time(t.timestamp)} (fuzzy {t.confidence:.0%})"
|
||
)
|
||
else:
|
||
print(f' [{t.marker_id}] NOT FOUND - "{t.context}"')
|
||
unmatched += 1
|
||
|
||
# Summary
|
||
total = len(timings)
|
||
print(f"\n Alignment summary:")
|
||
print(f" - Exact matches: {exact_matched}/{total}")
|
||
if fuzzy_matched > 0:
|
||
print(f" - Fuzzy matches (60%+ words): {fuzzy_matched}/{total}")
|
||
if unmatched > 0:
|
||
print(f" - NOT FOUND: {unmatched}/{total}")
|
||
print(
|
||
f"\n Some markers could not be aligned. Check manuscript.txt matches the spoken audio."
|
||
)
|
||
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# All Command (Full Pipeline)
|
||
# =============================================================================
|
||
|
||
|
||
def _files_modified_since(root: Path, since: float, pattern: str) -> bool:
|
||
"""Return True if any file matching pattern under root has mtime > since."""
|
||
try:
|
||
for p in root.rglob(pattern):
|
||
if p.is_file() and p.stat().st_mtime > since:
|
||
return True
|
||
except (OSError, PermissionError):
|
||
pass
|
||
return False
|
||
|
||
|
||
def _trim_outputs_current(project_path: Path) -> bool:
|
||
"""Return True if the trim stage can be safely skipped for every segment.
|
||
|
||
A segment is resolved only when its trim *output* is actually present on
|
||
BOTH sides — begin (a user 'begin'/'start' pin or a written 'skip') AND end
|
||
(a user 'end' pin or a written 'take'). This mirrors the per-side resolution
|
||
logic in cmd_trim itself.
|
||
|
||
A cached transcript is deliberately NOT treated as "resolved": the transcript
|
||
existing does not mean skip/take were ever written (an interrupted run, a
|
||
manual edit, or a begin-only pin can leave the end un-trimmed). Trim will
|
||
still reuse the cached transcript when it runs, so skipping re-transcription
|
||
stays cheap — we just no longer skip trim while real work remains.
|
||
|
||
Returns False (i.e. "run trim") if narration can't be read or any segment
|
||
is still unresolved on either side.
|
||
"""
|
||
from .parser import parse_project_config, parse_narration
|
||
|
||
try:
|
||
config = parse_project_config(project_path)
|
||
narration, narration_dir = parse_narration(project_path, config)
|
||
except GnommoError:
|
||
return False
|
||
|
||
if not narration:
|
||
return False
|
||
|
||
try:
|
||
raw_data = _read_json(narration_dir / "narration.json")
|
||
except (OSError, json.JSONDecodeError):
|
||
raw_data = {}
|
||
|
||
for seg_id in narration:
|
||
entry = raw_data.get(seg_id, {})
|
||
begin_resolved = bool(entry.get("begin") or entry.get("start")) or "skip" in entry
|
||
end_resolved = bool(entry.get("end")) or "take" in entry
|
||
if not (begin_resolved and end_resolved):
|
||
return False # a side still needs trimming
|
||
|
||
return True
|
||
|
||
|
||
def cmd_all(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
res: str = "full",
|
||
force: bool = False,
|
||
) -> int:
|
||
"""Run full pipeline: import → prune → preprocess → trim → render → push → handoff → up.
|
||
|
||
Cascade rule: if any stage produces output, all subsequent stages are forced
|
||
to re-run (cascade_force=True), regardless of whether --force was passed.
|
||
This ensures downstream caches are always consistent with upstream changes.
|
||
"""
|
||
from .handoff import cmd_handoff
|
||
from .push import cmd_push
|
||
|
||
print(f"=== Full Pipeline: {project_path.name} ===\n")
|
||
|
||
# cascade_force starts at --force. Once any stage does real work it flips to
|
||
# True so all downstream stages re-run unconditionally.
|
||
cascade_force = force
|
||
|
||
print(">>> Step 1/8: Import\n")
|
||
t0 = time.time()
|
||
result = cmd_import(project_path, cascade_force, verbose)
|
||
if result != 0:
|
||
return result
|
||
if _files_modified_since(project_path, t0, "slides.json") or _files_modified_since(
|
||
project_path, t0, "narration.json"
|
||
):
|
||
cascade_force = True
|
||
|
||
print("\n>>> Step 2/8: Prune\n")
|
||
# Drop manifest entries left over from edits (e.g. a video split into two
|
||
# projects). Only removes unused entries, so it never forces downstream re-runs.
|
||
result = cmd_prune(project_path, verbose, dry_run)
|
||
if result != 0:
|
||
return result
|
||
|
||
print("\n>>> Step 3/8: Preprocess\n")
|
||
t0 = time.time()
|
||
result = cmd_preprocess(
|
||
project_path, verbose, dry_run, cascade_force, workers=1, res=res
|
||
)
|
||
if result != 0:
|
||
return result
|
||
if _files_modified_since(
|
||
project_path, t0, "*_processed.mov"
|
||
) or _files_modified_since(project_path, t0, "*_processed.webm"):
|
||
cascade_force = True
|
||
|
||
print("\n>>> Step 4/8: Trim\n")
|
||
# Skip the (Whisper-heavy) trim stage when nothing upstream changed
|
||
# (cache intact) and every segment is already resolved — i.e. it has a
|
||
# cached transcript or explicit skip/take. A cascade_force from preprocess
|
||
# (a raw video was reprocessed) always forces trim to re-run.
|
||
if not cascade_force and _trim_outputs_current(project_path):
|
||
print(" Cache intact and transcripts present for all segments — skipping trim.")
|
||
else:
|
||
t0 = time.time()
|
||
result = cmd_trim(project_path, verbose, force=cascade_force, threshold_db=-40.0)
|
||
if result != 0:
|
||
return result
|
||
# Trim modifies narration.json skip/take values; any change invalidates the render
|
||
if _files_modified_since(project_path, t0, "narration.json"):
|
||
cascade_force = True
|
||
|
||
print("\n>>> Step 5/8: Build scaffold + Render\n")
|
||
# render also writes events.json/scaffold.json. When upstream changed
|
||
# (cascade_force), re-align from the transcript; otherwise honour any
|
||
# hand-edits already in events.json.
|
||
result = cmd_render(
|
||
project_path, verbose, dry_run, res=res, force=cascade_force, realign=cascade_force
|
||
)
|
||
if result != 0:
|
||
return result
|
||
|
||
print("\n>>> Step 6/8: Push\n")
|
||
result = cmd_push(project_path, verbose, force=False, prod=True)
|
||
if result != 0:
|
||
return result
|
||
|
||
print("\n>>> Step 7/8: Handoff\n")
|
||
result = cmd_handoff(project_path, verbose, file_override=None, prod=True, res=res)
|
||
if result != 0:
|
||
return result
|
||
|
||
print("\n>>> Step 8/8: Upload\n")
|
||
from .transfer import cmd_up
|
||
return cmd_up(project_path, verbose, dry_run)
|
||
|
||
|
||
# =============================================================================
|
||
# Auto Command — unattended render driver for the rig (called by autorender.sh)
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_auto(
|
||
root: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
res: str = "full",
|
||
prod: bool = True,
|
||
) -> int:
|
||
"""Unattended per-project driver: down → (commit-gated) render → handoff.
|
||
|
||
For every ``video*`` project under ``root`` (default: the current directory,
|
||
which autorender.sh cd's into), pull the latest tree from the relay, then —
|
||
only when the project's commits.log has an entry newer than the one this rig
|
||
last handled — run the gated pipeline (preprocess → trim → render, each of
|
||
which self-skips when its own inputs are unchanged) and hand the result off.
|
||
The commit timestamp is the explicit "this is ready" trigger, so incidental
|
||
file touches don't cause spurious re-renders or version bumps.
|
||
|
||
Returns non-zero if any project failed, so autorender.sh can ping on failure.
|
||
"""
|
||
from .transfer import cmd_down, _read_log_lines, _last_timestamp, _COMMITS_LOG
|
||
from .handoff import cmd_handoff
|
||
from . import state as _state
|
||
|
||
projects = sorted(
|
||
d for d in Path(root).glob("video*")
|
||
if d.is_dir() and (d / "project.json").exists()
|
||
)
|
||
if not projects:
|
||
print(f" No video* projects with project.json under {root}")
|
||
return 0
|
||
|
||
print(f"=== auto: {len(projects)} project(s) under {root} ===")
|
||
rendered: list[str] = []
|
||
skipped: list[str] = []
|
||
failed: list[tuple[str, str]] = []
|
||
|
||
for proj in projects:
|
||
name = proj.name
|
||
print(f"\n--- {name} ---")
|
||
|
||
# 1. Sync from the relay first — this is what brings the newest commits.log
|
||
# and inputs. Runs in both modes so it's always visible; in dry-run it's
|
||
# cmd_down's own --dry-run (shows what it would pull, changes nothing).
|
||
if cmd_down(proj, verbose, dry_run=dry_run) != 0:
|
||
if dry_run:
|
||
print(f" {name}: (dry-run) down preview unavailable — continuing")
|
||
else:
|
||
print(f" {name}: down failed — skipping")
|
||
failed.append((name, "down"))
|
||
continue
|
||
|
||
# 2. Commit trigger: is commits.log newer than what we last handled here?
|
||
latest = _last_timestamp(_read_log_lines(proj / _COMMITS_LOG))
|
||
handled = _state.get_items(proj, "auto").get("handled")
|
||
if latest is None:
|
||
print(f" {name}: no commits.log — skipping")
|
||
skipped.append(name)
|
||
continue
|
||
if latest == handled:
|
||
print(f" {name}: unchanged since {handled} — skipping")
|
||
skipped.append(name)
|
||
continue
|
||
|
||
print(f" {name}: new commit {latest} (last handled: {handled or 'never'})")
|
||
if dry_run:
|
||
print(" [dry-run] would preprocess → trim → render → handoff")
|
||
rendered.append(name)
|
||
continue
|
||
|
||
# 3. Gated pipeline — each stage self-skips when its inputs are unchanged;
|
||
# `or` short-circuits at the first non-zero (failing) stage.
|
||
rc = (
|
||
cmd_preprocess(proj, verbose, False, force=False, workers=1, res=res)
|
||
or cmd_trim(proj, verbose, force=False)
|
||
or cmd_render(proj, verbose, False, res=res, force=False)
|
||
or cmd_handoff(proj, verbose, None, prod, res)
|
||
)
|
||
if rc != 0:
|
||
print(f" {name}: pipeline failed (rc={rc})")
|
||
failed.append((name, f"rc={rc}"))
|
||
continue
|
||
|
||
# 4. Record this commit as handled so we don't re-render it next run.
|
||
_state.record_items(proj, "auto", {"handled": latest})
|
||
rendered.append(name)
|
||
|
||
print("\n=== auto summary ===")
|
||
print(f" rendered: {', '.join(rendered) or '—'}")
|
||
print(f" skipped: {', '.join(skipped) or '—'}")
|
||
print(f" failed: {', '.join(f'{n}({r})' for n, r in failed) or '—'}")
|
||
return 1 if failed else 0
|
||
|
||
|
||
# =============================================================================
|
||
# Description Command
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_description(project_path: Path, verbose: bool) -> int:
|
||
"""Generate YouTube description file with chapters, citations, and attributions."""
|
||
from .parser import (
|
||
parse_audio,
|
||
parse_manuscript,
|
||
parse_project_config,
|
||
parse_slides,
|
||
parse_videos,
|
||
load_citations,
|
||
)
|
||
from .transcriber import load_transcript
|
||
from .transformer import align_markers_to_transcription
|
||
from .description import write_description_file
|
||
|
||
print(f"Generating description: {project_path.name}")
|
||
|
||
# Parse all project files
|
||
manuscript_text, markers, _, _ = parse_manuscript(project_path)
|
||
|
||
# Load citations from file (saved during parse/render/align stages)
|
||
citations_path = project_path / "citations.json"
|
||
citations = load_citations(citations_path)
|
||
config = parse_project_config(project_path)
|
||
slides = parse_slides(project_path, config)
|
||
videos, videos_dir = parse_videos(project_path, config)
|
||
audio, _ = parse_audio(project_path, config)
|
||
|
||
# Load transcription for alignment (optional but recommended)
|
||
transcription = None
|
||
result = _find_narration_video(config, videos)
|
||
if result:
|
||
_, narration_source = result
|
||
video_path = videos_dir / narration_source.source_file
|
||
transcript_path = video_path.with_suffix(".transcript.json")
|
||
# Try cache fallback for transcript
|
||
transcript_path, _ = resolve_with_cache(transcript_path, project_path)
|
||
if transcript_path.exists():
|
||
transcription = load_transcript(transcript_path, project_path)
|
||
if verbose:
|
||
print(f" Loaded transcription: {len(transcription)} words")
|
||
else:
|
||
print(f" Warning: No transcription found at {transcript_path}")
|
||
print(
|
||
f" Run 'gnommo -p {project_path.name} trim' for better timestamps."
|
||
)
|
||
|
||
# Align markers to get timings
|
||
print(" Aligning markers...")
|
||
marker_timings = align_markers_to_transcription(
|
||
manuscript_text,
|
||
transcription or [],
|
||
slides=slides,
|
||
videos=videos,
|
||
audio=audio,
|
||
)
|
||
|
||
if verbose:
|
||
aligned = sum(1 for t in marker_timings if t.timestamp >= 0)
|
||
print(f" Aligned {aligned}/{len(marker_timings)} markers")
|
||
|
||
# Generate description
|
||
output_path = project_path / "out" / "description_youtube.txt"
|
||
description = write_description_file(
|
||
output_path=output_path,
|
||
config=config,
|
||
manuscript_text=manuscript_text,
|
||
slides=slides,
|
||
videos=videos,
|
||
marker_timings=marker_timings,
|
||
transcription=transcription,
|
||
citations=citations,
|
||
)
|
||
|
||
# Print summary
|
||
lines = description.split("\n")
|
||
print(f"\n Output: {output_path}")
|
||
print(f" Length: {len(description)} characters, {len(lines)} lines")
|
||
|
||
# Show sections found
|
||
sections = []
|
||
if config.description:
|
||
sections.append("description")
|
||
if "CHAPTERS" in description:
|
||
sections.append("chapters")
|
||
if "REFERENCES" in description:
|
||
sections.append("references")
|
||
if "STOCK FOOTAGE" in description:
|
||
sections.append("attributions")
|
||
if config.footer:
|
||
sections.append("footer")
|
||
|
||
print(f" Sections: {', '.join(sections)}")
|
||
|
||
if verbose:
|
||
print("\n --- Preview ---")
|
||
preview_lines = lines[:20]
|
||
for line in preview_lines:
|
||
print(f" {line}")
|
||
if len(lines) > 20:
|
||
print(f" ... ({len(lines) - 20} more lines)")
|
||
|
||
print("\nDone.")
|
||
return 0
|
||
|
||
|
||
# Files and directories excluded from all sync/archive/load operations.
|
||
# Covers intermediate processing artifacts, chunk scratch dirs, venv, and
|
||
# common OS/editor noise.
|
||
|
||
_RSYNC_EXCLUDES = [
|
||
# Intermediate processing files
|
||
"media/narration/intermediate/",
|
||
"media/narration/intermediate/**",
|
||
"media/videos/intermediate/",
|
||
"media/videos/intermediate/**",
|
||
"media/narration/processed/",
|
||
"media/narration/processed/**",
|
||
# Low-res preview files (generated locally, not synced)
|
||
"media/narration/low/",
|
||
"media/narration/low/**",
|
||
"media/videos/low/",
|
||
"media/videos/low/**",
|
||
# Chunk scratch directories
|
||
"**/chunks/",
|
||
"**/chunks/**",
|
||
# Python
|
||
"*.py",
|
||
"__pycache__/",
|
||
"venv/",
|
||
# Version control / OS noise
|
||
".git/",
|
||
".DS_Store",
|
||
"*.tmp",
|
||
]
|
||
|
||
|
||
def cmd_archive(project_path: Path, verbose: bool, dry_run: bool) -> int:
|
||
"""Archive project files to external cache storage."""
|
||
from .cache import load_cache_config, load_assets_config
|
||
|
||
print(f"Archiving: {project_path.name}")
|
||
|
||
# Prefer [cache] disk; fall back to [assets] disk if cache isn't mounted.
|
||
cache_base = None
|
||
for label, candidate in (("cache", load_cache_config()), ("assets", load_assets_config())):
|
||
if candidate is not None and candidate.exists():
|
||
cache_base = candidate
|
||
print(f" Using {label} disk: {candidate}")
|
||
break
|
||
|
||
if cache_base is None:
|
||
# Give a useful error: list which paths were tried
|
||
tried = []
|
||
for label, fn in (("cache", load_cache_config), ("assets", load_assets_config)):
|
||
p = fn()
|
||
if p is not None:
|
||
tried.append(f" [{label}] {p}")
|
||
if tried:
|
||
print("Error: No configured external drive is mounted. Tried:")
|
||
for t in tried:
|
||
print(t)
|
||
else:
|
||
print("Error: No external drive configured. Create ~/.gnommo.conf with:")
|
||
print(" [cache]")
|
||
print(" path = /Volumes/YourDisk/gnommo")
|
||
return 1
|
||
|
||
# Build destination path
|
||
dest_path = cache_base / project_path.name
|
||
print(f" Source: {project_path}")
|
||
print(f" Destination: {dest_path}")
|
||
|
||
# Create destination if needed
|
||
if not dry_run:
|
||
dest_path.mkdir(parents=True, exist_ok=True)
|
||
|
||
rsync_cmd = [
|
||
"rsync",
|
||
"-av",
|
||
"--progress",
|
||
*[f"--exclude={p}" for p in _RSYNC_EXCLUDES],
|
||
f"{project_path}/",
|
||
f"{dest_path}/",
|
||
]
|
||
|
||
if dry_run:
|
||
rsync_cmd.insert(1, "--dry-run")
|
||
print("\n [DRY RUN] Would execute:")
|
||
print(f" {' '.join(rsync_cmd)}")
|
||
else:
|
||
print("\n Syncing files...")
|
||
|
||
if verbose:
|
||
print(f" Command: {' '.join(rsync_cmd)}")
|
||
|
||
result = subprocess.run(rsync_cmd)
|
||
if result.returncode != 0:
|
||
print(f"Error: rsync failed with code {result.returncode}")
|
||
return 1
|
||
|
||
# Update project.json with synced_time
|
||
if not dry_run:
|
||
project_json_path = project_path / "project.json"
|
||
if project_json_path.exists():
|
||
try:
|
||
data = _read_json(project_json_path)
|
||
data["synced_time"] = datetime.now().isoformat()
|
||
project_json_path.write_text(
|
||
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
print(
|
||
f"\n Updated project.json with synced_time: {data['synced_time']}"
|
||
)
|
||
except (json.JSONDecodeError, IOError) as e:
|
||
print(f"Warning: Could not update project.json: {e}")
|
||
|
||
print("\nDone.")
|
||
return 0
|
||
|
||
|
||
def cmd_load(project_path: Path, verbose: bool, dry_run: bool) -> int:
|
||
"""Load project files from external cache storage onto the local drive."""
|
||
from .cache import load_cache_config, load_assets_config
|
||
|
||
print(f"Loading: {project_path.name}")
|
||
|
||
# Prefer [cache] disk; fall back to [assets] disk if cache isn't mounted.
|
||
cache_base = None
|
||
for label, candidate in (("cache", load_cache_config()), ("assets", load_assets_config())):
|
||
if candidate is not None and candidate.exists():
|
||
proj_candidate = candidate / project_path.name
|
||
if proj_candidate.exists():
|
||
cache_base = candidate
|
||
print(f" Using {label} disk: {candidate}")
|
||
break
|
||
|
||
if cache_base is None:
|
||
tried = []
|
||
for label, fn in (("cache", load_cache_config), ("assets", load_assets_config)):
|
||
p = fn()
|
||
if p is not None:
|
||
tried.append(f" [{label}] {p}")
|
||
if tried:
|
||
print(f"Error: Project '{project_path.name}' not found on any mounted external drive. Tried:")
|
||
for t in tried:
|
||
print(t)
|
||
else:
|
||
print("Error: No external drive configured. Create ~/.gnommo.conf with:")
|
||
print(" [cache]")
|
||
print(" path = /Volumes/YourDisk/gnommo")
|
||
return 1
|
||
|
||
# Build source path on the external drive
|
||
src_path = cache_base / project_path.name
|
||
if not src_path.exists():
|
||
print(f"Error: Project not found on external drive: {src_path}")
|
||
return 1
|
||
|
||
print(f" Source: {src_path}")
|
||
print(f" Destination: {project_path}")
|
||
|
||
# Create destination if needed
|
||
if not dry_run:
|
||
project_path.mkdir(parents=True, exist_ok=True)
|
||
|
||
rsync_cmd = [
|
||
"rsync",
|
||
"-av",
|
||
"--progress",
|
||
*[f"--exclude={p}" for p in _RSYNC_EXCLUDES],
|
||
f"{src_path}/",
|
||
f"{project_path}/",
|
||
]
|
||
|
||
if dry_run:
|
||
rsync_cmd.insert(1, "--dry-run")
|
||
print("\n [DRY RUN] Would execute:")
|
||
print(f" {' '.join(rsync_cmd)}")
|
||
else:
|
||
print("\n Copying files...")
|
||
|
||
if verbose:
|
||
print(f" Command: {' '.join(rsync_cmd)}")
|
||
|
||
result = subprocess.run(rsync_cmd)
|
||
if result.returncode != 0:
|
||
print(f"Error: rsync failed with code {result.returncode}")
|
||
return 1
|
||
|
||
print("\nDone.")
|
||
return 0
|
||
|
||
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|