5692 lines
205 KiB
Python
5692 lines
205 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
|
||
|
||
|
||
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 stitch --res tiny -f Fast stitch with new begin/end values
|
||
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 → stitch → render → push → handoff → up
|
||
gnommo -p video1 render --dry-run Show FFmpeg command without running
|
||
gnommo -p video1 description Generate YouTube description file
|
||
gnommo -p video1 transcribe Narration file for timing of slides
|
||
gnommo -p video1 transcribe --final Transcribe outputted file and generate SRT for YouTube
|
||
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.
|
||
gnommo -p video1 extract-audio --combined Extract audio from narration_combined.mov
|
||
gnommo -p video1 extract-audio --combined --channel left Extract left channel only
|
||
gnommo -p video1 extract-audio --segment seg01 Extract from a specific segment
|
||
gnommo -p video1 master Extract raw + processed audio for A/B comparison
|
||
""",
|
||
)
|
||
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",
|
||
"stitch",
|
||
"trim",
|
||
"render",
|
||
"all",
|
||
"transcribe",
|
||
"align",
|
||
"import",
|
||
"description",
|
||
"archive",
|
||
"load",
|
||
"commit",
|
||
"up",
|
||
"down",
|
||
"extract-audio",
|
||
"master",
|
||
"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(
|
||
"--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(
|
||
"--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=1,
|
||
help="Number of parallel workers for preprocessing (default: 1)",
|
||
)
|
||
parser.add_argument(
|
||
"--final",
|
||
action="store_true",
|
||
help="For transcribe: transcribe the final rendered video and generate SRT captions for YouTube",
|
||
)
|
||
parser.add_argument(
|
||
"--segment",
|
||
type=str,
|
||
help="For extract-audio: specific segment ID to extract (default: all segments)",
|
||
)
|
||
parser.add_argument(
|
||
"--channel",
|
||
type=str,
|
||
choices=["auto", "left", "right", "both"],
|
||
default="both",
|
||
help="For extract-audio: which audio channel(s) to extract (default: both)",
|
||
)
|
||
parser.add_argument(
|
||
"--combined",
|
||
action="store_true",
|
||
help="For extract-audio: extract from narration_combined.mov instead of individual segments",
|
||
)
|
||
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)",
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
# 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
|
||
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 in ("stitch"):
|
||
return cmd_stitch(
|
||
project_path,
|
||
args.verbose,
|
||
args.force,
|
||
args.res,
|
||
)
|
||
elif action == "render":
|
||
return cmd_render(
|
||
project_path,
|
||
args.verbose,
|
||
args.dry_run,
|
||
args.slides,
|
||
args.res,
|
||
args.force,
|
||
chunk_slides=args.chunk_slides,
|
||
)
|
||
elif action == "transcribe":
|
||
return cmd_transcribe(project_path, args.verbose, args.res, args.final)
|
||
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 == "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 == "extract-audio":
|
||
return cmd_extract_audio(
|
||
project_path, args.verbose, args.segment, args.channel, args.combined
|
||
)
|
||
elif action == "master":
|
||
return cmd_master(project_path, args.verbose, args.channel)
|
||
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)
|
||
|
||
# Generate slides.json for each slide directory (after Keynote export)
|
||
slides_base = project_path / "media" / "slides"
|
||
slides_dirs = (
|
||
[d for d in slides_base.glob("*/") if d.is_dir()]
|
||
if slides_base.exists()
|
||
else []
|
||
)
|
||
for slides_dir in slides_dirs:
|
||
_generate_slides_json(slides_dir, verbose)
|
||
else:
|
||
if verbose:
|
||
print(" No .key file found, skipping presenter notes import")
|
||
|
||
# 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
|
||
for f in audio_files:
|
||
audio_id = f.stem.lower()
|
||
if audio_id in existing:
|
||
if verbose:
|
||
print(f" Skipping {audio_id} (already in audio.json)")
|
||
continue
|
||
existing[audio_id] = {
|
||
"file": f.name,
|
||
"is_shared": True,
|
||
"volume": 1.0,
|
||
}
|
||
added += 1
|
||
if verbose:
|
||
print(f" Added shared audio: {audio_id}")
|
||
|
||
if added > 0:
|
||
with open(audio_json_path, "w", encoding="utf-8") as fh:
|
||
json.dump(existing, fh, indent=2)
|
||
print(
|
||
f" Updated {audio_json_path.relative_to(project_path)} (+{added} shared audio files)"
|
||
)
|
||
else:
|
||
if verbose:
|
||
print(f" No new shared audio files to add")
|
||
|
||
|
||
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 "duration" in audio_data and not force:
|
||
if verbose:
|
||
print(f" Audio '{audio_id}': cached ({audio_data['duration']:.1f}s)")
|
||
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
|
||
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)
|
||
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
|
||
|
||
if not force and "duration" in canonical and "has_audio" in canonical:
|
||
if verbose:
|
||
print(
|
||
f" Video '{video_id}': cached ({canonical['duration']:.1f}s, audio={canonical['has_audio']})"
|
||
)
|
||
continue
|
||
|
||
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
|
||
|
||
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}
|
||
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}]")
|
||
|
||
|
||
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
|
||
and not f.name.startswith("narration_combined")
|
||
]
|
||
|
||
# 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
|
||
|
||
# Determine if this is a talking head segment
|
||
# Match patterns like: talkinghead, talkingheadS01, talkinghead_s01, etc.
|
||
is_narration_combined = "narration_combined" in video_file.stem.lower()
|
||
# Build the video entry
|
||
video_entry = {
|
||
"source_file": video_file.name,
|
||
}
|
||
|
||
if is_narration_combined:
|
||
video_entry["output_file"] = None
|
||
video_entry["cutout"] = "talkinghead"
|
||
video_entry["always_visible"] = True
|
||
video_entry["skip"] = 0
|
||
video_entry["filter"] = []
|
||
print(f" Added talking head segment: {video_id}")
|
||
else:
|
||
# Regular video
|
||
|
||
video_entry["output_file"] = video_file.name
|
||
video_entry["cutout"] = "square"
|
||
video_entry["filter"] = []
|
||
if verbose:
|
||
print(f" Added: {video_id}")
|
||
|
||
existing_videos[video_id] = video_entry
|
||
added_count += 1
|
||
|
||
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.name} (+{added_count} videos)")
|
||
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-stitch 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
|
||
|
||
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
|
||
raw_mov_has_file = raw_dir.exists() and any(
|
||
(raw_dir / f"{segment_id}{ext}").exists() for ext in _raw_video_exts
|
||
)
|
||
if raw_mov_has_file:
|
||
continue
|
||
|
||
narration_entry = {
|
||
"source_file": f"processed/{video_file.name}",
|
||
}
|
||
narration_entry["use_audio_channels"] = "auto"
|
||
narration_entry["defer_loudnorm"] = True
|
||
|
||
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}",
|
||
"output_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"
|
||
narration_entry["defer_loudnorm"] = True
|
||
|
||
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 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:
|
||
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)")
|
||
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
|
||
video_id = marker[_TASKS_VIDEO_PREFIXES[matched] :]
|
||
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")
|
||
print(
|
||
f" Tasks written → tasks.md"
|
||
+ (f" ({len(missing_videos)} missing videos)" if missing_videos else "")
|
||
+ (f" ({len(alignment_issues)} alignment issues)" if alignment_issues else "")
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 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 _narration_combined_hint(project_path: Path, config) -> str:
|
||
"""Return a helpful hint when narration_combined.mov cannot be found.
|
||
|
||
If external storage is configured but the volume isn't mounted, the stitch
|
||
command wouldn't help — the disk is just not connected.
|
||
"""
|
||
from .cache import load_cache_config
|
||
|
||
missing_paths = []
|
||
|
||
cache_base = load_cache_config()
|
||
if cache_base is not None and not cache_base.exists():
|
||
missing_paths.append(cache_base)
|
||
|
||
if config and config.process_cache:
|
||
pc = Path(config.process_cache)
|
||
if not pc.is_absolute():
|
||
pc = (project_path / pc).resolve()
|
||
if not pc.exists():
|
||
missing_paths.append(pc)
|
||
|
||
if missing_paths:
|
||
return (
|
||
f"External disk not connected (expected at {missing_paths[0]}).\n"
|
||
"Connect the disk and try again."
|
||
)
|
||
return "Run 'gnommo -p <project> stitch' first."
|
||
|
||
|
||
def _resolve_narration_combined(
|
||
project_path: Path, videos_dir: Path, config
|
||
) -> Optional[Path]:
|
||
"""Find narration_combined.mov: local → GnommoCache → process_cache."""
|
||
local = videos_dir / "narration_combined.mov"
|
||
if local.exists():
|
||
return local
|
||
resolved, _ = resolve_with_cache(local, project_path)
|
||
if resolved.exists():
|
||
return resolved
|
||
pc_root = _resolve_process_cache(project_path, config)
|
||
if pc_root:
|
||
pc_path = pc_root / "media" / "videos" / "narration_combined.mov"
|
||
if pc_path.exists():
|
||
return pc_path
|
||
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:
|
||
# Sensible placeholder — user should tweak gnommokey values for their camera
|
||
talkinghead_filter = [
|
||
{
|
||
"type": "audio_normalize",
|
||
"compress": False,
|
||
"normalize": True,
|
||
"target_lufs": -14,
|
||
"target_lra": 11,
|
||
"target_tp": -1.5,
|
||
},
|
||
{
|
||
"type": "gnommokey",
|
||
"screen_color": [81, 137, 65],
|
||
"screen_gain": 175,
|
||
"screen_balance": 58,
|
||
"despill_bias": [217, 240, 255],
|
||
"despill_strength": 5.0,
|
||
"edge_erode": 1.0,
|
||
"clip_black": 0,
|
||
"clip_white": 100,
|
||
},
|
||
{
|
||
"type": "color_grade",
|
||
"saturation": 0.95,
|
||
"contrast": 1.06,
|
||
"rm": -0.05,
|
||
"gm": 0.02,
|
||
"bm": -0.04,
|
||
"curves_master": "0/0.02 0.5/0.5 1/0.97",
|
||
},
|
||
{
|
||
"type": "mask",
|
||
"left": 0.05,
|
||
"right": 0.1,
|
||
"top": 0.1,
|
||
"bottom": 0.0,
|
||
},
|
||
]
|
||
print(" Using default talkinghead filter (adjust gnommokey values for your camera)")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 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} stitch # join narration into one file
|
||
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
|
||
]
|
||
|
||
if not to_remove:
|
||
if verbose:
|
||
print(f" {path.name}: all {len(data)} {label} entries in use.")
|
||
return 0
|
||
|
||
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 not dry_run:
|
||
for key in to_remove:
|
||
del data[key]
|
||
_write_json_preserve(path, data)
|
||
|
||
return len(to_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
|
||
|
||
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/stitch.
|
||
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}")
|
||
|
||
# --- 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 into the res subdir so stitch --res low finds the
|
||
# files at narration/low/processed/ (narration.json still records the plain
|
||
# "processed/..." path; stitch shifts the base dir itself).
|
||
_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 stitch (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"),
|
||
defer_loudnorm=existing_entry.get("defer_loudnorm", True),
|
||
)
|
||
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
|
||
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)")
|
||
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 ---
|
||
# Write processed segments; preserve any existing per-segment settings (skip/take/etc.)
|
||
_PRESERVE_KEYS = (
|
||
"skip",
|
||
"take",
|
||
"begin",
|
||
"end",
|
||
"cutout",
|
||
"use_audio_channels",
|
||
"defer_loudnorm",
|
||
"volume",
|
||
"zoom",
|
||
)
|
||
for segment_id, segment_source in successfully_processed:
|
||
existing_entry = existing_narration.get(segment_id, {})
|
||
entry: dict = {}
|
||
# Preserve settings the user may have set (trim points, cutout, etc.)
|
||
for key in _PRESERVE_KEYS:
|
||
if key in existing_entry:
|
||
entry[key] = existing_entry[key]
|
||
# Always record the plain path; stitch shifts the base dir for low/tiny.
|
||
entry["source_file"] = f"processed/{segment_id}_processed.mov"
|
||
entry.setdefault("use_audio_channels", "auto")
|
||
entry.setdefault("defer_loudnorm", True)
|
||
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 Run 'gnommo -p <project> stitch' to stitch narration segments into one full length narration file."
|
||
)
|
||
|
||
# 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}")
|
||
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
|
||
# =============================================================================
|
||
|
||
# Words so common they're useless for matching slide boundaries.
|
||
_TRIM_STOP_WORDS = frozenset({
|
||
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "of",
|
||
"for", "is", "it", "its", "we", "our", "this", "that", "with", "be",
|
||
"are", "was", "has", "not", "so", "as", "by", "do", "if", "up",
|
||
"you", "i", "he", "she", "they", "them", "just", "now", "can",
|
||
"than", "then", "from", "about", "into", "out", "what", "there",
|
||
"when", "how", "who", "all", "very", "also", "more", "get", "have",
|
||
})
|
||
|
||
|
||
def _parse_segment_slide_range(seg_id: str) -> "tuple[int, int | None] | None":
|
||
"""Parse 'S11-24' → (11, 24), 'S1-end' → (1, None), else None."""
|
||
m = re.match(r"^s(\d+)-(?:s?(\d+)|(end))$", seg_id.lower())
|
||
if not m:
|
||
return None
|
||
start = int(m.group(1))
|
||
end = int(m.group(2)) if m.group(2) else None
|
||
return (start, end)
|
||
|
||
|
||
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 _trim_content_words(text: str) -> "list[str]":
|
||
"""Lowercase words stripped of punctuation, excluding stop words."""
|
||
words = re.findall(r"[a-zA-Z0-9']+", text.lower())
|
||
return [w for w in words if w not in _TRIM_STOP_WORDS and len(w) > 2]
|
||
|
||
|
||
def _find_slide_end_in_transcript(
|
||
transcript_words: list,
|
||
slide_text: str,
|
||
verbose: bool = False,
|
||
) -> "float | None":
|
||
"""
|
||
Locate where slide_text ends in the transcript and return that word's
|
||
end timestamp. Searches the tail of the transcript for the last few
|
||
content words from slide_text using a sequential fuzzy match.
|
||
Returns None if no confident match is found.
|
||
"""
|
||
target = _trim_content_words(slide_text)[-12:]
|
||
if not target:
|
||
return None
|
||
|
||
# Normalise transcript words (strip punctuation, lowercase)
|
||
norm = [re.sub(r"[^a-z0-9']", "", w.word.lower()) for w in transcript_words]
|
||
# Index into transcript of content words only
|
||
content_idxs = [
|
||
i for i, w in enumerate(norm)
|
||
if w and w not in _TRIM_STOP_WORDS and len(w) > 2
|
||
]
|
||
if not content_idxs:
|
||
return None
|
||
|
||
n = len(target)
|
||
threshold = max(1, int(n * 0.55))
|
||
|
||
# Scan backwards: find the latest window of n content words that matches
|
||
for end_ci in range(len(content_idxs) - 1, n - 2, -1):
|
||
window_idxs = content_idxs[max(0, end_ci - n + 1): end_ci + 1]
|
||
window = [norm[i] for i in window_idxs]
|
||
|
||
# Sequential match: iterate target left-to-right, consume window matches
|
||
score = 0
|
||
wi = 0
|
||
for t_word in target:
|
||
while wi < len(window):
|
||
w = window[wi]
|
||
wi += 1
|
||
if w == t_word or (len(w) >= 4 and len(t_word) >= 4 and w[:4] == t_word[:4]):
|
||
score += 1
|
||
break
|
||
|
||
if score >= threshold:
|
||
last_tw = transcript_words[content_idxs[end_ci]]
|
||
if verbose:
|
||
print(f"\n → matched slide end: '{last_tw.word}' at {last_tw.end:.2f}s")
|
||
return last_tw.end
|
||
|
||
if verbose:
|
||
print(f"\n → could not match slide end (target tail: {target[-5:]})")
|
||
return None
|
||
|
||
|
||
def cmd_trim(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
force: bool = False,
|
||
threshold_db: float = -40.0,
|
||
res: str = "full",
|
||
whisper_model: str = "base",
|
||
) -> int:
|
||
"""
|
||
Trim narration segments and write skip/take values into narration.json.
|
||
|
||
For segments named S{N}-{M}.mov or S{N}-end.mov:
|
||
- Transcribes audio with Whisper to get word-level timestamps
|
||
- skip = max(0, first_word.start - 0.5)
|
||
- take = (end of last word on slide M) + 0.15 - skip
|
||
- S{N}-end.mov: only trims the start, no end trim
|
||
- Transcripts are cached in narration/transcripts/{seg_id}.json
|
||
|
||
For other segments: falls 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
|
||
|
||
updated = 0
|
||
for seg_id in sorted(narration.keys()):
|
||
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
|
||
|
||
# Has the raw source changed since we last trimmed this segment?
|
||
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, {})
|
||
has_explicit = "skip" in existing or "take" in existing
|
||
if has_explicit and not seg_force:
|
||
print(f" {seg_id}: already trimmed, skipping (use --force to redo)")
|
||
_trim_fps[seg_id] = current_fp # adopt/keep current fingerprint
|
||
continue
|
||
if source_changed:
|
||
print(f" {seg_id}: raw source changed since last trim — re-trimming")
|
||
|
||
slide_range = _parse_segment_slide_range(seg_id) if slide_texts else None
|
||
|
||
if slide_range is not None:
|
||
# --- Transcript-based trimming ---
|
||
from .transcriber import transcribe_video, save_transcript, load_transcript, TranscriptionError
|
||
|
||
start_slide, end_slide = slide_range
|
||
transcripts_dir.mkdir(parents=True, exist_ok=True)
|
||
transcript_path = transcripts_dir / f"{seg_id}.json"
|
||
|
||
try:
|
||
if transcript_path.exists() and not seg_force:
|
||
words = load_transcript(transcript_path)
|
||
print(f" {seg_id}: loaded cached transcript ({len(words)} words)", end="", flush=True)
|
||
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", end="", flush=True)
|
||
|
||
if not words:
|
||
print(f" — no words found, falling back to silence detection")
|
||
slide_range = None
|
||
else:
|
||
total_dur = get_video_duration(source_path)
|
||
new_skip = max(0.0, round(words[0].start - 0.5, 3))
|
||
|
||
if end_slide is None:
|
||
# S{N}-end: trim start and 2s after last spoken word
|
||
last_word_end = words[-1].end
|
||
new_take = round(min(last_word_end + 2.0 - new_skip, total_dur - new_skip), 3)
|
||
new_take = max(0.0, new_take)
|
||
print(
|
||
f" first={words[0].start:.2f}s last={last_word_end:.2f}s"
|
||
f" → skip={new_skip:.3f}s take={new_take:.3f}s"
|
||
)
|
||
else:
|
||
last_slide_text = slide_texts.get(end_slide, "")
|
||
end_ts = None
|
||
if last_slide_text:
|
||
end_ts = _find_slide_end_in_transcript(words, last_slide_text, verbose)
|
||
if end_ts is None:
|
||
print(f"\n ⚠ could not locate S{end_slide} end in transcript — trimming start only")
|
||
new_take = round(total_dur - new_skip, 3)
|
||
else:
|
||
new_take = round(min(end_ts + 0.15 - new_skip, total_dur - new_skip), 3)
|
||
new_take = max(0.0, new_take)
|
||
print(
|
||
f" first={words[0].start:.2f}s S{end_slide}_end={end_ts:.2f}s"
|
||
f" → skip={new_skip:.3f}s take={new_take:.3f}s"
|
||
)
|
||
|
||
raw_data[seg_id]["skip"] = new_skip
|
||
raw_data[seg_id]["take"] = new_take
|
||
_trim_fps[seg_id] = current_fp
|
||
updated += 1
|
||
continue
|
||
|
||
except Exception as exc:
|
||
from .transcriber import TranscriptionError
|
||
label = "Whisper not installed" if "openai-whisper" in str(exc) else str(exc)
|
||
print(f"\n ⚠ transcription failed ({label}), falling back to silence detection")
|
||
slide_range = None # fall through
|
||
|
||
# --- Silence-based fallback ---
|
||
print(
|
||
f" {seg_id}: analysing {source_path.parent.name}/{source_path.name}...",
|
||
end="",
|
||
flush=True,
|
||
)
|
||
first_sound, last_sound = detect_silence_bounds(
|
||
source_path, noise_threshold_db=threshold_db, verbose=verbose
|
||
)
|
||
total_dur = get_video_duration(source_path)
|
||
|
||
new_skip = max(0.0, round(first_sound - 0.5, 3))
|
||
new_take = round(min(total_dur - new_skip, last_sound + 3.0 - new_skip), 3)
|
||
new_take = max(0.0, new_take)
|
||
|
||
print(
|
||
f" first={first_sound:.2f}s last={last_sound:.2f}s"
|
||
f" → skip={new_skip:.3f}s take={new_take:.3f}s"
|
||
)
|
||
|
||
raw_data[seg_id]["skip"] = new_skip
|
||
raw_data[seg_id]["take"] = new_take
|
||
_trim_fps[seg_id] = 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 stitch/render find 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 (stitch, 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
|
||
|
||
|
||
# =============================================================================
|
||
# Stitch Command (fast iteration on narration segments)
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_stitch(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
force: bool = False,
|
||
res: str = "full",
|
||
) -> int:
|
||
"""
|
||
Stitch narration segments from narration.json.
|
||
|
||
Reads segments from media/narration/narration.json, applies begin/end
|
||
trimming during concatenation, and writes output to media/videos/narration_combined.mov.
|
||
Also creates/updates an entry in videos.json with volume property.
|
||
"""
|
||
from .parser import parse_project_config, parse_narration, parse_videos
|
||
from .preprocessor import (
|
||
stitch_narration_segments,
|
||
ensure_downscaled_files_exist,
|
||
RES_CONFIGS,
|
||
)
|
||
|
||
mode_str = f" ({res.upper()})" if res != "full" else ""
|
||
print(f"Stitching narration: {project_path.name}{mode_str}")
|
||
|
||
config = parse_project_config(project_path)
|
||
narration, narration_dir = parse_narration(project_path, config)
|
||
|
||
if not narration:
|
||
print(" No narration segments found in media/narration/narration.json")
|
||
print(" Run 'gnommo -p <project> import' first to populate narration.json")
|
||
return 1
|
||
|
||
# narration.json (skip/take/order) drives stitch — capture its local path for
|
||
# fingerprinting before narration_dir is redirected to a cache/res subdir.
|
||
_local_narration_json = narration_dir / "narration.json"
|
||
|
||
# Get videos_dir for output
|
||
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"
|
||
|
||
# When process_cache is set, redirect processed segment reads and combined output.
|
||
# Mirror media/ structure so GnommoCache (resolve_with_cache) finds files during render.
|
||
cache_root = _resolve_process_cache(project_path, config)
|
||
if cache_root:
|
||
narration_dir = cache_root / "media" / "narration"
|
||
narration_dir.mkdir(parents=True, exist_ok=True)
|
||
videos_dir_out = cache_root / "media" / "videos"
|
||
videos_dir_out.mkdir(parents=True, exist_ok=True)
|
||
print(f" Using process cache: {cache_root}")
|
||
else:
|
||
videos_dir_out = videos_dir
|
||
|
||
# Use downscaled dirs for non-full res
|
||
if res != "full":
|
||
cfg = RES_CONFIGS[res]
|
||
narration_dir = ensure_downscaled_files_exist(
|
||
narration_dir, res, force=False, verbose=verbose
|
||
)
|
||
videos_dir_out = videos_dir_out / cfg[2]
|
||
videos_dir_out.mkdir(parents=True, exist_ok=True)
|
||
print(f" Using {res} dirs: {narration_dir}, {videos_dir_out}")
|
||
|
||
# Get segment IDs in natural order (Segment2 before Segment10)
|
||
segment_ids = sorted(narration.keys(), key=lambda s: [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', s)])
|
||
|
||
# Show what we're stitching
|
||
print(f"\n Segments ({len(segment_ids)}):")
|
||
for segment_id in segment_ids:
|
||
seg = narration[segment_id]
|
||
skip_str = f"skip={seg.skip:.1f}s" if seg.skip else ""
|
||
take_str = f"take={seg.take:.1f}s" if seg.take else ""
|
||
trim_info = ", ".join(filter(None, [skip_str, take_str]))
|
||
trim_str = f" ({trim_info})" if trim_info else ""
|
||
print(f" - {segment_id}{trim_str}")
|
||
|
||
stitch_output = videos_dir_out / "narration_combined.mov"
|
||
|
||
# Stage-level staleness: skip when narration.json and every processed segment
|
||
# are unchanged since the last successful stitch AND the output still exists.
|
||
# A changed input (re-trim, reprocessed segment) auto-triggers a regenerate.
|
||
from . import state as _state
|
||
|
||
_stitch_key = f"stitch:{res}"
|
||
_stitch_inputs = _state.compute(
|
||
[("narration.json", _local_narration_json, _state.HASH)]
|
||
+ [
|
||
(f"seg:{sid}", narration_dir / narration[sid].source_file, _state.META)
|
||
for sid in segment_ids
|
||
]
|
||
)
|
||
_stitch_current = _state.is_current(
|
||
project_path, _stitch_key, _stitch_inputs, [stitch_output]
|
||
)
|
||
|
||
if stitch_output.exists() and not force and _stitch_current:
|
||
print(f"\n Combined narration up to date: {stitch_output.name}")
|
||
print(" (inputs unchanged since last stitch — use --force to regenerate)")
|
||
else:
|
||
if stitch_output.exists() and not force and not _stitch_current:
|
||
print("\n Inputs changed since last stitch — regenerating.")
|
||
# Extract loudnorm config from talkinghead filter so stitch uses
|
||
# per-project settings instead of hardcoded defaults.
|
||
_loudnorm_cfg = None
|
||
if config and config.default_filters:
|
||
for _f in config.default_filters.get("talkinghead") or []:
|
||
if isinstance(_f, dict) and _f.get("type") == "audio_normalize":
|
||
_loudnorm_cfg = _f
|
||
break
|
||
stitch_narration_segments(
|
||
narration_dir,
|
||
segment_ids,
|
||
narration,
|
||
stitch_output,
|
||
verbose=verbose,
|
||
default_end_trim=config.default_end_trim if config else 0.0,
|
||
loudnorm_config=_loudnorm_cfg,
|
||
)
|
||
# Run import videos again to update duration metadata (skip when using cache
|
||
# since narration_combined.mov lives on the external disk, not in videos_dir).
|
||
if not cache_root:
|
||
_import_videos(videos_dir_out, config, verbose)
|
||
|
||
# Record fingerprints (recompute output META now that it exists) so the
|
||
# next run can detect whether inputs changed.
|
||
_state.record(
|
||
project_path,
|
||
_stitch_key,
|
||
_state.compute(
|
||
[("narration.json", _local_narration_json, _state.HASH)]
|
||
+ [
|
||
(f"seg:{sid}", narration_dir / narration[sid].source_file, _state.META)
|
||
for sid in segment_ids
|
||
]
|
||
),
|
||
)
|
||
|
||
# Always update the MAIN videos.json (parent of subdir when using low/tiny res)
|
||
# Downscaled dirs only affect file paths, not JSON metadata updates
|
||
main_videos_dir = (
|
||
videos_dir_out.parent if (res != "full" and not cache_root) else videos_dir
|
||
)
|
||
videos_json_path = main_videos_dir / "videos.json"
|
||
if True: # Always update JSON regardless of proxy mode
|
||
existing_videos: dict = {}
|
||
if videos_json_path.exists():
|
||
existing_videos = _read_json(videos_json_path)
|
||
|
||
# Get cutout from first narration segment
|
||
first_seg = narration[segment_ids[0]]
|
||
cutout = first_seg.cutout or "talkinghead"
|
||
|
||
# Create/update narration_combined entry
|
||
existing_videos["narration_combined"] = {
|
||
"source_file": "narration_combined.mov",
|
||
"cutout": cutout,
|
||
"always_visible": True,
|
||
"volume": 1.0,
|
||
}
|
||
|
||
with open(videos_json_path, "w", encoding="utf-8") as f:
|
||
json.dump(existing_videos, f, indent=2)
|
||
print(f"\n Updated videos.json with narration_combined entry (volume=1.0)")
|
||
print(" Edit videos.json to adjust volume if needed.")
|
||
|
||
print("\nConcatenation complete.")
|
||
|
||
# Automatically transcribe to keep transcript in sync with narration
|
||
print("\n" + "=" * 60)
|
||
print("Auto-running transcribe to sync with new narration...")
|
||
print("=" * 60 + "\n")
|
||
return cmd_transcribe(project_path, verbose, res=res)
|
||
|
||
|
||
# =============================================================================
|
||
# 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) -> None:
|
||
"""
|
||
Print a detailed render plan showing each marker with its aligned time.
|
||
|
||
Uses marker_timings from the transformer which contains alignment info.
|
||
"""
|
||
from .models import CAMERA_PRESETS
|
||
|
||
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)
|
||
|
||
# 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
|
||
if marker_id in slides:
|
||
if marker_id in collision_ids:
|
||
collision_count += 1
|
||
print(
|
||
f' {marker_id:6} {time_str}{conf_str} COLLISION - same time as adjacent slide - "{context}"'
|
||
)
|
||
else:
|
||
aligned_count += 1
|
||
print(f' {marker_id:6} {time_str}{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)
|
||
)
|
||
video_id = marker_id[pfx_len:]
|
||
# 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:
|
||
cutout_name = "?"
|
||
end_on = "next_slide"
|
||
layer_tag = ""
|
||
|
||
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
|
||
print(f' {marker_id:6} {time_str} "{context}"')
|
||
else:
|
||
unaligned_count += 1
|
||
# Check if this is a slide that was interpolated into the plan
|
||
if 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(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 _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 a case-insensitive index of shared_assets pause_narration values.
|
||
# When a video is marked is_shared but its local entry is missing pause_narration,
|
||
# we pull the value from the shared canonical entry so it's never lost when
|
||
# the ETL writes back cutout/layer under a lowercase key.
|
||
_shared_pause: 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)
|
||
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":
|
||
# Write pause_narration = duration only when:
|
||
# - marker is a pause-prefix (value is True)
|
||
# - pause_narration not already set (preserve manual overrides)
|
||
# - duration is known (probed by import)
|
||
if value and not entry.get("pause_narration") and entry.get("duration"):
|
||
entry["pause_narration"] = entry["duration"]
|
||
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
|
||
# not already set locally (handles case where explicit pause_narration
|
||
# 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 _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,
|
||
) -> int:
|
||
"""Render in slide-based chunks then concatenate — avoids filter graph OOM."""
|
||
import math
|
||
|
||
# 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)
|
||
]
|
||
print(
|
||
f"\n Auto-chunking: {len(slide_ids)} slides → {len(groups)} chunks of ≤{chunk_size}"
|
||
)
|
||
|
||
chunks_dir = out_dir / "chunks"
|
||
chunks_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}:"
|
||
chunk_path = chunks_dir / f"chunk_{i+1:03d}_{start}-{end or 'end'}.mp4"
|
||
|
||
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)
|
||
return result
|
||
chunk_paths.append(chunk_path)
|
||
|
||
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 cmd_render(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
dry_run: bool,
|
||
slides_arg: str = None,
|
||
res: str = "full",
|
||
force: bool = False,
|
||
chunk_slides: int = 0,
|
||
_output_path_override: Path = None,
|
||
) -> int:
|
||
"""Render final video."""
|
||
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
|
||
|
||
# Parse slide range if provided
|
||
slide_range = None
|
||
if slides_arg:
|
||
slide_range = _parse_slide_range(slides_arg)
|
||
print(f"Rendering: {project_path.name} (slides {slides_arg})")
|
||
else:
|
||
print(f"Rendering: {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)
|
||
|
||
# Load whisper transcription JSON
|
||
# Resolve the combined narration skeleton. The .mov file — not the videos.json
|
||
# entry — is the source of truth: stitch run without the external drive leaves
|
||
# the file in media/videos/ even when the videos.json entry is absent (e.g. a
|
||
# metadata pull overwrote it). Legacy multi-segment projects are handled below.
|
||
combined_path = videos_dir / "narration_combined.mov"
|
||
resolved_combined = _resolve_narration_combined(project_path, videos_dir, config)
|
||
narration_json = project_path / "media" / "narration" / "narration.json"
|
||
_narr_segments = _read_json(narration_json) if narration_json.exists() else {}
|
||
if resolved_combined and resolved_combined.exists():
|
||
# File is available (locally or via process cache). Ensure a videos.json
|
||
# entry exists — synthesizing one when stitch's entry was lost — then use it.
|
||
if "narration_combined" not in videos:
|
||
from .models import VideoSource
|
||
|
||
_first_seg = next(iter(_narr_segments.values()), {})
|
||
_seg_cutout = (
|
||
_first_seg.get("cutout") if isinstance(_first_seg, dict) else None
|
||
)
|
||
videos["narration_combined"] = VideoSource(
|
||
source_file="narration_combined.mov",
|
||
cutout=_seg_cutout or "talkinghead",
|
||
always_visible=True,
|
||
volume=1.0,
|
||
)
|
||
if resolved_combined != combined_path:
|
||
# File lives on external disk — point the VideoSource at the absolute
|
||
# path so the renderer doesn't re-resolve it via the local videos_dir.
|
||
videos["narration_combined"].source_file = str(resolved_combined)
|
||
transcript_path = resolved_combined.with_suffix(".transcript.json")
|
||
config.main_video = "narration_combined"
|
||
if verbose:
|
||
print(
|
||
f" Using combined narration: {resolved_combined.name} (volume={videos['narration_combined'].volume})"
|
||
)
|
||
elif isinstance(config.main_video, list) and len(config.main_video) > 1:
|
||
# Legacy: Multi-segment narration with main_video array in project.json
|
||
resolved_combined, _ = resolve_with_cache(combined_path, project_path)
|
||
transcript_path = resolved_combined.with_suffix(".transcript.json")
|
||
|
||
if not resolved_combined.exists():
|
||
print(
|
||
f"Error: Combined narration not found: {combined_path}", file=sys.stderr
|
||
)
|
||
print(
|
||
"Run 'gnommo -p <project> concat' first to concatenate segments.",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
# Create a synthetic video entry for the combined narration
|
||
# Inherit settings from the first segment
|
||
first_segment_id = config.main_video[0]
|
||
if first_segment_id in videos:
|
||
first_segment = videos[first_segment_id]
|
||
from .models import VideoSource
|
||
|
||
combined_video = VideoSource(
|
||
source_file="narration_combined.mov",
|
||
filter=first_segment.filter,
|
||
output_file=None, # Already processed
|
||
cutout=first_segment.cutout,
|
||
always_visible=True,
|
||
skip=0.0, # Already trimmed during concatenation
|
||
take=None,
|
||
)
|
||
videos["_narration_combined"] = combined_video
|
||
config.main_video = "_narration_combined"
|
||
|
||
if verbose:
|
||
print(f" Using combined narration: {combined_path.name}")
|
||
elif _narr_segments:
|
||
# narration.json has segments, but the combined .mov could not be found.
|
||
# Distinguish "stitched, file unreachable" from "never stitched" so the
|
||
# hint is actionable instead of always blaming videos.json.
|
||
if "narration_combined" in videos:
|
||
print(
|
||
f"Error: narration_combined.mov could not be found.", file=sys.stderr
|
||
)
|
||
print(
|
||
f"videos.json references narration_combined, but the file is not on disk "
|
||
f"(checked local media/videos and the process cache).",
|
||
file=sys.stderr,
|
||
)
|
||
print(_narration_combined_hint(project_path, config), file=sys.stderr)
|
||
else:
|
||
print(
|
||
f"Error: narration_combined not found in videos.json", file=sys.stderr
|
||
)
|
||
print(
|
||
f"You have narration segments in narration.json but haven't stitched them.",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
f"Run 'gnommo -p {project_path.name} stitch' first.",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
else:
|
||
# Single video - look for .transcript.json next to the narration video
|
||
result = _find_narration_video(config, videos)
|
||
if result:
|
||
video_id, narration_source = result
|
||
config.main_video = video_id # Ensure main_video is set to the found video
|
||
video_path = videos_dir / narration_source.source_file
|
||
transcript_path = video_path.with_suffix(".transcript.json")
|
||
else:
|
||
transcript_path = project_path / "transcript.json"
|
||
|
||
# If project.json specifies a transcript path, prefer it (always local)
|
||
if config.transcript_path:
|
||
local_transcript = project_path / config.transcript_path
|
||
if local_transcript.exists():
|
||
transcript_path = local_transcript
|
||
|
||
# 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} transcribe' first.", 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 (includes on-the-fly alignment)
|
||
print("\n[3/4] Building render plan...")
|
||
plan, marker_timings = build_render_plan(
|
||
project_path,
|
||
config,
|
||
slides,
|
||
videos,
|
||
videos_dir,
|
||
manuscript_text,
|
||
transcription,
|
||
audio,
|
||
audio_dir,
|
||
slide_range=slide_range,
|
||
)
|
||
if plan.time_offset > 0:
|
||
print(f" Time offset: {plan.time_offset:.1f}s (partial render)")
|
||
|
||
# Print detailed render plan with alignment info
|
||
_print_render_plan_details(plan, marker_timings, slides)
|
||
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)
|
||
|
||
# Check for unaligned markers
|
||
unaligned = [t for t in marker_timings if t.timestamp < 0]
|
||
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
|
||
elif config.output_video:
|
||
out_filename = config.output_video
|
||
out_dir = project_path / "out" / res if res != "full" else project_path / "out"
|
||
output_path = out_dir / out_filename
|
||
elif slide_range:
|
||
start, end = slide_range
|
||
range_suffix = f"_{start}-{end}" if end else f"_{start}-end"
|
||
out_filename = f"final{range_suffix}.mp4"
|
||
out_dir = project_path / "out" / res if res != "full" else project_path / "out"
|
||
output_path = out_dir / out_filename
|
||
else:
|
||
out_filename = f"{config.co}.mp4"
|
||
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
|
||
|
||
_chunk_size = chunk_slides or get_render_chunk_size() or 0
|
||
_slide_ids = [e.slide_id for e in plan.slide_events]
|
||
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.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),
|
||
("transcript", transcript_path, _state.HASH),
|
||
]
|
||
if resolved_combined:
|
||
specs.append(("narration_combined", resolved_combined, _state.META))
|
||
for _sid, _sdef in slides.items():
|
||
specs.append((f"slide:{_sid}", _slides_dir / _sdef.image, _state.META))
|
||
return specs
|
||
|
||
_render_key = f"render:{res}"
|
||
if _render_gateable and _state.is_current(
|
||
project_path, _render_key, _state.compute(_render_input_specs()), [output_path]
|
||
):
|
||
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...")
|
||
render(plan, output_path, verbose=verbose)
|
||
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
|
||
|
||
|
||
def cmd_transcribe(
|
||
project_path: Path, verbose: bool, res: str = "full", final: bool = False
|
||
) -> int:
|
||
"""Transcribe video audio using Whisper."""
|
||
from .transcriber import transcribe_video, save_transcript, words_to_srt
|
||
from .parser import parse_project_config, parse_videos
|
||
from .preprocessor import ensure_downscaled_files_exist
|
||
|
||
config = parse_project_config(project_path)
|
||
|
||
# Handle --final mode: transcribe the rendered output for YouTube captions
|
||
if final:
|
||
path = project_path / "out" / f"{config.output_video}.mp4"
|
||
return _transcribe_final(path, verbose)
|
||
|
||
mode_str = f" ({res.upper()})" if res != "full" else ""
|
||
print(f"Transcribing: {project_path.name}{mode_str}")
|
||
|
||
videos, videos_dir = parse_videos(project_path, config)
|
||
if not videos:
|
||
print("Error: No videos defined in videos.json", file=sys.stderr)
|
||
return 1
|
||
|
||
# Non-full res: use downscaled video directory
|
||
if res != "full":
|
||
videos_dir = ensure_downscaled_files_exist(
|
||
videos_dir, res, force=False, verbose=verbose
|
||
)
|
||
|
||
# Check for multi-segment narration (concatenated file)
|
||
if isinstance(config.main_video, list) and len(config.main_video) > 1:
|
||
video_path = videos_dir / "narration_combined.mov"
|
||
if not video_path.exists():
|
||
print(f"Error: Combined narration not found: {video_path}", file=sys.stderr)
|
||
print(
|
||
"Run 'gnommo -p <project> pre' first to concatenate segments.",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
print(f" Using combined narration: {video_path.name}")
|
||
else:
|
||
# Single video - find it using existing logic
|
||
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
|
||
|
||
if (
|
||
not video_path.exists()
|
||
and video_source.source_file == "narration_combined.mov"
|
||
):
|
||
found = _resolve_narration_combined(project_path, videos_dir, config)
|
||
if found:
|
||
video_path = found
|
||
if not video_path.exists():
|
||
video_path, _ = resolve_with_cache(video_path, project_path)
|
||
if not video_path.exists():
|
||
print(f"Error: Video not found: {video_path}", file=sys.stderr)
|
||
return 1
|
||
|
||
print(f" Video: {video_path.name}")
|
||
|
||
words = transcribe_video(video_path, model="base")
|
||
|
||
# Save to project-local path if configured in project.json (keeps transcript off external drives)
|
||
if config.transcript_path:
|
||
output_path = project_path / config.transcript_path
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
else:
|
||
output_path = video_path.with_suffix(".transcript.json")
|
||
save_transcript(words, output_path)
|
||
|
||
print(f" - Transcribed {len(words)} words")
|
||
print(f" - Duration: {words[-1].end:.1f}s" if words else " - No words found")
|
||
print(f" - Saved: {output_path}")
|
||
|
||
if verbose and words:
|
||
preview = " ".join(w.word for w in words[:10])
|
||
print(f" - Preview: {preview}...")
|
||
|
||
return 0
|
||
|
||
|
||
def _transcribe_final(final_video: Path, verbose: bool) -> int:
|
||
"""
|
||
Transcribe the final rendered video and generate SRT captions for YouTube.
|
||
|
||
Looks and creates out filename.srt suitable for upload.
|
||
"""
|
||
from .transcriber import transcribe_video, save_transcript, words_to_srt
|
||
|
||
print(f"Transcribing final output: {final_video}")
|
||
|
||
if not final_video.exists():
|
||
print(f"Error: Final video not found: {final_video}", file=sys.stderr)
|
||
print("Run 'gnommo render' first.", file=sys.stderr)
|
||
return 1
|
||
|
||
print(f" Video: {final_video.name}")
|
||
|
||
# Transcribe with word-level timestamps
|
||
words = transcribe_video(final_video, model="base")
|
||
|
||
if not words:
|
||
print("Error: No words transcribed from video", file=sys.stderr)
|
||
return 1
|
||
|
||
# Save JSON transcript
|
||
transcript_path = final_video.with_suffix(".transcript.json")
|
||
save_transcript(words, transcript_path)
|
||
|
||
# Generate SRT captions
|
||
srt_path = final_video.with_suffix(".srt")
|
||
srt_content = words_to_srt(words)
|
||
srt_path.write_text(srt_content, encoding="utf-8")
|
||
|
||
print(f" - Transcribed {len(words)} words")
|
||
print(f" - Duration: {words[-1].end:.1f}s")
|
||
print(f" - Transcript: {transcript_path}")
|
||
print(f" - Captions: {srt_path}")
|
||
|
||
# Count caption segments
|
||
caption_count = srt_content.count("\n\n") + 1
|
||
print(f" - Caption segments: {caption_count}")
|
||
|
||
if verbose and words:
|
||
preview = " ".join(w.word for w in words[:15])
|
||
print(f" - Preview: {preview}...")
|
||
|
||
print("\nSRT file ready for YouTube upload.")
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# 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} transcribe' first.", 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 considered resolved when narration.json already records an
|
||
explicit skip/take for it, or a cached Whisper transcript exists at
|
||
narration/transcripts/{seg_id}.json (from which trim would just recompute
|
||
the same skip/take). Used by the 'all' pipeline to avoid re-running the
|
||
expensive transcription stage when nothing upstream changed.
|
||
|
||
Returns False (i.e. "run trim") if narration can't be read or any segment
|
||
is still unresolved.
|
||
"""
|
||
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
|
||
|
||
transcripts_dir = narration_dir / "transcripts"
|
||
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, {})
|
||
if "skip" in entry or "take" in entry:
|
||
continue # already trimmed
|
||
if (transcripts_dir / f"{seg_id}.json").exists():
|
||
continue # transcript cached — trim would just reuse it
|
||
return False # unresolved segment: trim still has work to do
|
||
|
||
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 → stitch → 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/9: 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/9: 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/9: 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/9: 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 stitch
|
||
if _files_modified_since(project_path, t0, "narration.json"):
|
||
cascade_force = True
|
||
|
||
print("\n>>> Step 5/9: Stitch\n")
|
||
t0 = time.time()
|
||
result = cmd_stitch(project_path, verbose, cascade_force, res=res)
|
||
if result != 0:
|
||
return result
|
||
if _files_modified_since(project_path, t0, "narration_combined.mov"):
|
||
cascade_force = True
|
||
|
||
print("\n>>> Step 6/9: Render\n")
|
||
result = cmd_render(project_path, verbose, dry_run, res=res, force=cascade_force)
|
||
if result != 0:
|
||
return result
|
||
|
||
print("\n>>> Step 7/9: Push\n")
|
||
result = cmd_push(project_path, verbose, force=False, prod=True)
|
||
if result != 0:
|
||
return result
|
||
|
||
print("\n>>> Step 8/9: Handoff\n")
|
||
result = cmd_handoff(project_path, verbose, file_override=None, prod=True, res=res)
|
||
if result != 0:
|
||
return result
|
||
|
||
print("\n>>> Step 9/9: Upload\n")
|
||
from .transfer import cmd_up
|
||
return cmd_up(project_path, verbose, dry_run)
|
||
|
||
|
||
# =============================================================================
|
||
# 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} transcribe' 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/**",
|
||
"media/videos/narration_combined.mov",
|
||
# 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
|
||
|
||
|
||
|
||
|
||
# =============================================================================
|
||
# Extract Audio Command
|
||
# =============================================================================
|
||
|
||
|
||
def _extract_audio_file(
|
||
source_path: Path,
|
||
output_dir: Path,
|
||
name: str,
|
||
channel: str,
|
||
verbose: bool,
|
||
) -> int:
|
||
"""
|
||
Extract audio from a single video file to WAV.
|
||
|
||
Args:
|
||
source_path: Path to the source video file
|
||
output_dir: Directory to save the WAV file
|
||
name: Base name for the output file (without extension)
|
||
channel: "left", "right", or "both"
|
||
verbose: Print verbose output
|
||
|
||
Returns:
|
||
0 on success, 1 on error
|
||
"""
|
||
# Build output filename
|
||
if channel == "both":
|
||
output_name = f"{name}.wav"
|
||
else:
|
||
output_name = f"{name}_{channel}.wav"
|
||
output_path = output_dir / output_name
|
||
|
||
print(f" Channel: {channel}")
|
||
print(f" Source: {source_path}")
|
||
print(f" Output: {output_path}")
|
||
|
||
# Build ffmpeg command
|
||
cmd = [
|
||
"ffmpeg",
|
||
"-y", # Overwrite
|
||
"-i",
|
||
str(source_path),
|
||
"-vn", # No video
|
||
]
|
||
|
||
# Channel selection
|
||
if channel == "left":
|
||
cmd.extend(["-af", "pan=mono|c0=c0"])
|
||
elif channel == "right":
|
||
cmd.extend(["-af", "pan=mono|c0=c1"])
|
||
# "both" keeps stereo, no filter needed
|
||
|
||
# Output format: 48kHz 16-bit WAV (standard for audio editing)
|
||
cmd.extend(
|
||
[
|
||
"-ar",
|
||
"48000", # 48kHz sample rate
|
||
"-acodec",
|
||
"pcm_s16le", # 16-bit PCM
|
||
str(output_path),
|
||
]
|
||
)
|
||
|
||
if verbose:
|
||
print(f" Command: {' '.join(cmd)}")
|
||
|
||
print(f" Extracting...", end=" ", flush=True)
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
print(f"Error!")
|
||
print(f" {result.stderr}", file=sys.stderr)
|
||
return 1
|
||
|
||
# Get duration info
|
||
duration_cmd = [
|
||
"ffprobe",
|
||
"-v",
|
||
"error",
|
||
"-show_entries",
|
||
"format=duration",
|
||
"-of",
|
||
"default=noprint_wrappers=1:nokey=1",
|
||
str(output_path),
|
||
]
|
||
duration_result = subprocess.run(duration_cmd, capture_output=True, text=True)
|
||
duration_str = ""
|
||
if duration_result.returncode == 0:
|
||
try:
|
||
duration = float(duration_result.stdout.strip())
|
||
duration_str = f" ({duration:.1f}s)"
|
||
except ValueError:
|
||
pass
|
||
|
||
print(f"Done{duration_str}")
|
||
|
||
print(f"\n Open in Audition to experiment with:")
|
||
print(f" - Effect > Noise Reduction")
|
||
print(f" - Effect > Compressor")
|
||
print(f" - Effect > Filter Curve EQ")
|
||
print(f" - Effect > Loudness Normalization")
|
||
print(
|
||
f"\n Once you find good settings, update narration.json with matching filter config."
|
||
)
|
||
|
||
return 0
|
||
|
||
|
||
def cmd_extract_audio(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
segment: Optional[str] = None,
|
||
channel: str = "both",
|
||
combined: bool = False,
|
||
) -> int:
|
||
"""
|
||
Extract audio from narration segments to WAV files for editing in Audacity.
|
||
|
||
This allows you to experiment with audio processing settings (EQ, compression,
|
||
noise reduction) in external software before applying them in the pipeline.
|
||
|
||
Args:
|
||
project_path: Path to the project directory
|
||
verbose: Enable verbose output
|
||
segment: Specific segment ID to extract, or None for all segments
|
||
channel: Which channel(s) to extract: "left", "right", or "both"
|
||
combined: If True, extract from narration_combined.mov instead of segments
|
||
"""
|
||
from .parser import parse_project_config, parse_narration, parse_videos
|
||
|
||
print(f"Extracting audio: {project_path.name}")
|
||
|
||
config = parse_project_config(project_path)
|
||
|
||
# Handle --combined mode: extract from narration_combined.mov
|
||
if combined:
|
||
videos, videos_dir = parse_videos(project_path, config)
|
||
combined_path = _resolve_narration_combined(
|
||
project_path, videos_dir, config
|
||
) or (videos_dir / "narration_combined.mov")
|
||
|
||
if not combined_path.exists():
|
||
print(
|
||
f"Error: narration_combined.mov not found at {combined_path}",
|
||
file=sys.stderr,
|
||
)
|
||
print(_narration_combined_hint(project_path, config), file=sys.stderr)
|
||
return 1
|
||
|
||
# Output to project out/ directory
|
||
audio_dir = project_path / "out"
|
||
audio_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
return _extract_audio_file(
|
||
combined_path, audio_dir, "narration_combined", channel, verbose
|
||
)
|
||
|
||
# Normal mode: extract from individual segments
|
||
narration, narration_dir = parse_narration(project_path, config)
|
||
|
||
if not narration:
|
||
print(" No narration segments found in media/narration/narration.json")
|
||
print(" Run 'gnommo -p <project> import' first to populate narration.json")
|
||
return 1
|
||
|
||
# Create output directory
|
||
audio_dir = narration_dir / "audio"
|
||
audio_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Determine which segments to process
|
||
if segment:
|
||
if segment not in narration:
|
||
print(
|
||
f"Error: Segment '{segment}' not found in narration.json",
|
||
file=sys.stderr,
|
||
)
|
||
print(
|
||
f"Available segments: {', '.join(sorted(narration.keys()))}",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
segments_to_process = [(segment, narration[segment])]
|
||
else:
|
||
segments_to_process = sorted(narration.items())
|
||
|
||
print(f" Channel: {channel}")
|
||
print(f" Output: {audio_dir}/")
|
||
print(f" Segments: {len(segments_to_process)}")
|
||
|
||
# Process each segment
|
||
for segment_id, segment_source in segments_to_process:
|
||
source_path = narration_dir / segment_source.source_file
|
||
if not source_path.exists():
|
||
print(f" Warning: Source not found: {source_path.name}, skipping")
|
||
continue
|
||
|
||
# Build output filename
|
||
if channel == "both":
|
||
output_name = f"{segment_id}.wav"
|
||
else:
|
||
output_name = f"{segment_id}_{channel}.wav"
|
||
output_path = audio_dir / output_name
|
||
|
||
print(f"\n {segment_id}:")
|
||
print(f" Source: {source_path.name}")
|
||
print(f" Output: {output_name}")
|
||
|
||
# Build ffmpeg command
|
||
cmd = [
|
||
"ffmpeg",
|
||
"-y", # Overwrite
|
||
"-i",
|
||
str(source_path),
|
||
"-vn", # No video
|
||
]
|
||
|
||
# Channel selection
|
||
if channel == "left":
|
||
cmd.extend(["-af", "pan=mono|c0=c0"])
|
||
elif channel == "right":
|
||
cmd.extend(["-af", "pan=mono|c0=c1"])
|
||
# "both" keeps stereo, no filter needed
|
||
|
||
# Output format: 48kHz 16-bit WAV (standard for audio editing)
|
||
cmd.extend(
|
||
[
|
||
"-ar",
|
||
"48000", # 48kHz sample rate
|
||
"-acodec",
|
||
"pcm_s16le", # 16-bit PCM
|
||
str(output_path),
|
||
]
|
||
)
|
||
|
||
if verbose:
|
||
print(f" Command: {' '.join(cmd)}")
|
||
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
print(f" Error: {result.stderr}", file=sys.stderr)
|
||
return 1
|
||
|
||
# Get duration info
|
||
duration_cmd = [
|
||
"ffprobe",
|
||
"-v",
|
||
"error",
|
||
"-show_entries",
|
||
"format=duration",
|
||
"-of",
|
||
"default=noprint_wrappers=1:nokey=1",
|
||
str(output_path),
|
||
]
|
||
duration_result = subprocess.run(duration_cmd, capture_output=True, text=True)
|
||
if duration_result.returncode == 0:
|
||
try:
|
||
duration = float(duration_result.stdout.strip())
|
||
print(f" Duration: {duration:.1f}s")
|
||
except ValueError:
|
||
pass
|
||
|
||
print(f" Done")
|
||
|
||
print(f"\n Audio files saved to: {audio_dir}")
|
||
print(f"\n Open in Audacity to experiment with:")
|
||
print(f" - Effect > Noise Reduction")
|
||
print(f" - Effect > Compressor")
|
||
print(f" - Effect > Filter Curve EQ")
|
||
print(f" - Effect > Loudness Normalization")
|
||
print(
|
||
f"\n Once you find good settings, update narration.json with matching filter config."
|
||
)
|
||
|
||
return 0
|
||
|
||
|
||
# =============================================================================
|
||
# Master Command (A/B audio comparison)
|
||
# =============================================================================
|
||
|
||
|
||
def cmd_master(
|
||
project_path: Path,
|
||
verbose: bool,
|
||
channel: str = "both",
|
||
) -> int:
|
||
"""
|
||
Extract raw and processed audio from narration_combined for A/B comparison.
|
||
|
||
Outputs:
|
||
out/narration_combined.wav - Raw audio (no processing)
|
||
out/narration_combined_processed.wav - With audio filters applied
|
||
|
||
This lets you compare the effect of your audio processing chain.
|
||
"""
|
||
from .parser import parse_project_config, parse_videos
|
||
from .preprocessor import parse_audio_normalize_config
|
||
|
||
print(f"Audio mastering: {project_path.name}")
|
||
|
||
config = parse_project_config(project_path)
|
||
videos, videos_dir = parse_videos(project_path, config)
|
||
|
||
# Find narration_combined.mov
|
||
combined_path = _resolve_narration_combined(project_path, videos_dir, config) or (
|
||
videos_dir / "narration_combined.mov"
|
||
)
|
||
if not combined_path.exists():
|
||
print(
|
||
f"Error: narration_combined.mov not found at {combined_path}",
|
||
file=sys.stderr,
|
||
)
|
||
print(_narration_combined_hint(project_path, config), file=sys.stderr)
|
||
return 1
|
||
|
||
# Output directory
|
||
out_dir = project_path / "out"
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
raw_output = out_dir / "narration_combined.wav"
|
||
processed_output = out_dir / "narration_combined_processed.wav"
|
||
|
||
# Find audio_normalize config from default_filters
|
||
audio_config = None
|
||
if config.default_filters:
|
||
for preset_name, filters in config.default_filters.items():
|
||
for f in filters:
|
||
if f.get("type") == "audio_normalize":
|
||
audio_config = f
|
||
print(f" Using audio config from: default_filters.{preset_name}")
|
||
break
|
||
if audio_config:
|
||
break
|
||
|
||
if not audio_config:
|
||
print(" Warning: No audio_normalize filter found in default_filters")
|
||
print(" Will only extract raw audio.")
|
||
|
||
# Build channel filter
|
||
channel_filter = ""
|
||
if channel == "left":
|
||
channel_filter = "pan=mono|c0=c0,"
|
||
elif channel == "right":
|
||
channel_filter = "pan=mono|c0=c1,"
|
||
|
||
# Step 1: Extract raw audio
|
||
print(f"\n Extracting raw audio...")
|
||
raw_cmd = [
|
||
"ffmpeg",
|
||
"-y",
|
||
"-i",
|
||
str(combined_path),
|
||
"-vn",
|
||
]
|
||
if channel_filter:
|
||
raw_cmd.extend(["-af", channel_filter.rstrip(",")])
|
||
raw_cmd.extend(
|
||
[
|
||
"-ar",
|
||
"48000",
|
||
"-acodec",
|
||
"pcm_s16le",
|
||
str(raw_output),
|
||
]
|
||
)
|
||
|
||
if verbose:
|
||
print(f" Command: {' '.join(raw_cmd)}")
|
||
|
||
result = subprocess.run(raw_cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
print(f" Error extracting raw audio: {result.stderr}", file=sys.stderr)
|
||
return 1
|
||
print(f" Saved: {raw_output.name}")
|
||
|
||
# Step 2: Extract processed audio (if we have config)
|
||
if audio_config:
|
||
print(f"\n Applying audio filters...")
|
||
cfg = parse_audio_normalize_config(audio_config)
|
||
|
||
# Build filter chain (same order as apply_audio_normalize)
|
||
audio_filters = []
|
||
|
||
# Channel mapping
|
||
if channel_filter:
|
||
audio_filters.append(channel_filter.rstrip(","))
|
||
|
||
# EQ bands
|
||
for band in cfg.eq_bands:
|
||
if band.type == "lowshelf":
|
||
audio_filters.append(
|
||
f"lowshelf=f={band.freq:.1f}:g={band.gain:.1f}:t=q:w={band.q:.2f}"
|
||
)
|
||
elif band.type == "highshelf":
|
||
audio_filters.append(
|
||
f"highshelf=f={band.freq:.1f}:g={band.gain:.1f}:t=q:w={band.q:.2f}"
|
||
)
|
||
else:
|
||
audio_filters.append(
|
||
f"equalizer=f={band.freq:.1f}:width_type=q:width={band.q:.2f}:g={band.gain:.1f}"
|
||
)
|
||
|
||
# High-pass
|
||
if cfg.highpass > 0:
|
||
audio_filters.append(f"highpass=f={cfg.highpass:.1f}")
|
||
|
||
# Low-pass
|
||
if cfg.lowpass > 0:
|
||
audio_filters.append(f"lowpass=f={cfg.lowpass:.1f}")
|
||
|
||
# Room EQ
|
||
if cfg.room_eq:
|
||
audio_filters.append(
|
||
f"equalizer=f={cfg.room_eq_freq:.1f}:width_type=q:width={cfg.room_eq_width:.2f}:g={cfg.room_eq_gain:.1f}"
|
||
)
|
||
|
||
# Denoise
|
||
if cfg.denoise:
|
||
audio_filters.append(f"afftdn=nf={cfg.noise_floor:.1f}")
|
||
|
||
# Gate
|
||
if cfg.gate:
|
||
audio_filters.append(
|
||
f"agate=threshold={cfg.gate_threshold:.1f}dB"
|
||
f":range={cfg.gate_range:.1f}dB"
|
||
f":attack={cfg.gate_attack:.1f}"
|
||
f":release={cfg.gate_release:.1f}"
|
||
)
|
||
|
||
# Compressor
|
||
if cfg.compress:
|
||
audio_filters.append(
|
||
f"acompressor=threshold={cfg.threshold:.1f}dB"
|
||
f":ratio={cfg.ratio:.1f}"
|
||
f":attack={cfg.attack:.1f}"
|
||
f":release={cfg.release:.1f}"
|
||
f":makeup={cfg.makeup:.1f}dB"
|
||
)
|
||
|
||
# Loudness normalization
|
||
if cfg.normalize:
|
||
audio_filters.append(
|
||
f"loudnorm=I={cfg.target_lufs:.1f}"
|
||
f":LRA={cfg.target_lra:.1f}"
|
||
f":TP={cfg.target_tp:.1f}"
|
||
)
|
||
|
||
filter_chain = ",".join(audio_filters)
|
||
|
||
if verbose:
|
||
print(f" Filter chain: {filter_chain}")
|
||
|
||
# Print filter summary
|
||
print(f" Filters applied:")
|
||
if cfg.eq_bands:
|
||
print(f" - EQ: {len(cfg.eq_bands)} bands")
|
||
if cfg.highpass > 0:
|
||
print(f" - Highpass: {cfg.highpass}Hz")
|
||
if cfg.denoise:
|
||
print(f" - Denoise: floor={cfg.noise_floor}dB")
|
||
if cfg.gate:
|
||
print(f" - Gate: threshold={cfg.gate_threshold}dB")
|
||
if cfg.compress:
|
||
print(f" - Compressor: ratio={cfg.ratio}:1, attack={cfg.attack}ms")
|
||
if cfg.normalize:
|
||
print(f" - Loudnorm: target={cfg.target_lufs} LUFS")
|
||
|
||
processed_cmd = [
|
||
"ffmpeg",
|
||
"-y",
|
||
"-i",
|
||
str(combined_path),
|
||
"-vn",
|
||
"-af",
|
||
filter_chain,
|
||
"-ar",
|
||
"48000",
|
||
"-acodec",
|
||
"pcm_s16le",
|
||
str(processed_output),
|
||
]
|
||
|
||
if verbose:
|
||
print(f" Command: {' '.join(processed_cmd)}")
|
||
|
||
result = subprocess.run(processed_cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
print(f" Error applying filters: {result.stderr}", file=sys.stderr)
|
||
return 1
|
||
print(f" Saved: {processed_output.name}")
|
||
|
||
# Get durations
|
||
def get_duration(path):
|
||
cmd = [
|
||
"ffprobe",
|
||
"-v",
|
||
"error",
|
||
"-show_entries",
|
||
"format=duration",
|
||
"-of",
|
||
"default=noprint_wrappers=1:nokey=1",
|
||
str(path),
|
||
]
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
try:
|
||
return float(r.stdout.strip())
|
||
except:
|
||
return 0
|
||
|
||
duration = get_duration(raw_output)
|
||
|
||
print(f"\n Output files ({duration:.1f}s):")
|
||
print(f" {raw_output}")
|
||
print(f" {processed_output}")
|
||
print(f"\n Open both in Audition to A/B compare the processing.")
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|