Adding fixes to the stitcher
This commit is contained in:
+640
-73
@@ -76,8 +76,8 @@ Examples:
|
||||
"-p",
|
||||
"--project",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Project directory",
|
||||
default=None,
|
||||
help="Project directory (required for all actions except 'pexels --search')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"action",
|
||||
@@ -193,6 +193,12 @@ Examples:
|
||||
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,
|
||||
@@ -216,17 +222,38 @@ Examples:
|
||||
dest="alpha_quality",
|
||||
help="For transcode --processed: HEVC alpha quality 0.0-1.0 (default: 0.75; lower=smaller file)",
|
||||
)
|
||||
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
|
||||
project_path = Path(args.project)
|
||||
if not project_path.is_absolute():
|
||||
project_path = Path.cwd() / project_path
|
||||
# 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
|
||||
action = args.action
|
||||
|
||||
if action == "import":
|
||||
return cmd_import(project_path, args.force, args.verbose)
|
||||
@@ -243,7 +270,7 @@ Examples:
|
||||
)
|
||||
elif action == "trim":
|
||||
return cmd_trim(
|
||||
project_path, args.verbose, args.force, args.threshold, args.res
|
||||
project_path, args.verbose, args.force, args.threshold, args.res, args.model
|
||||
)
|
||||
elif action == "transcode":
|
||||
return cmd_transcode(
|
||||
@@ -312,7 +339,7 @@ Examples:
|
||||
project_path, args.verbose, args.file, args.prod, args.res
|
||||
)
|
||||
elif action == "pexels":
|
||||
return cmd_pexels(project_path, args.verbose)
|
||||
return cmd_pexels(project_path, args.verbose, args.search, args.search_max)
|
||||
|
||||
except GnommoError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
@@ -329,6 +356,213 @@ Examples:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
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
|
||||
@@ -421,15 +655,29 @@ def cmd_import(project_path: Path, force: bool, verbose: bool) -> int:
|
||||
for marker in markers:
|
||||
for prefix in _SHORTHAND_PREFIXES:
|
||||
if marker.startswith(prefix):
|
||||
vid_id = marker[len(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 — add it manually"
|
||||
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
|
||||
|
||||
@@ -473,7 +721,7 @@ def _import_shared_audio(
|
||||
|
||||
added = 0
|
||||
for f in audio_files:
|
||||
audio_id = f.stem
|
||||
audio_id = f.stem.lower()
|
||||
if audio_id in existing:
|
||||
if verbose:
|
||||
print(f" Skipping {audio_id} (already in audio.json)")
|
||||
@@ -619,21 +867,27 @@ def _probe_video_metadata(
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -717,8 +971,13 @@ def _sync_shared_videos_to_local(
|
||||
elif verbose:
|
||||
print(f" Shared '{video_id}': already in local videos.json, skipping")
|
||||
continue
|
||||
# New entry — copy from shared and mark it as shared
|
||||
local_entry = dict(shared_entry)
|
||||
# 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)
|
||||
@@ -769,15 +1028,15 @@ def _import_shared_assets(shared_assets_dir: Path, verbose: bool) -> None:
|
||||
video_extensions = {".mov", ".mp4", ".webm", ".avi", ".mkv", ".m4v"}
|
||||
|
||||
# Find all video files in shared_assets (root level and subdirectories).
|
||||
# Also scan the GnommoDisk cache mirror so files placed there are registered.
|
||||
from .cache import load_cache_config
|
||||
# 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]
|
||||
cache_base = load_cache_config()
|
||||
if cache_base:
|
||||
cache_shared = cache_base / "shared_assets"
|
||||
if cache_shared.exists() and cache_shared != shared_assets_dir:
|
||||
scan_roots.append(cache_shared)
|
||||
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
|
||||
@@ -821,12 +1080,28 @@ def _import_shared_assets(shared_assets_dir: Path, verbose: bool) -> None:
|
||||
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
|
||||
# e.g., "Logo" for root files, "pexels/6759604-hd" for subdirectory files
|
||||
video_id = str(rel_path.with_suffix(""))
|
||||
# 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:
|
||||
@@ -840,11 +1115,12 @@ def _import_shared_assets(shared_assets_dir: Path, verbose: bool) -> None:
|
||||
if verbose:
|
||||
print(f" Added: {video_id}")
|
||||
|
||||
if added_count > 0:
|
||||
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)
|
||||
print(f" Updated {videos_json_path} (+{added_count} shared assets)")
|
||||
if added_count > 0:
|
||||
print(f" Updated {videos_json_path} (+{added_count} shared assets)")
|
||||
else:
|
||||
print(f" No new shared assets to add")
|
||||
|
||||
@@ -940,14 +1216,32 @@ def _import_videos(videos_dir: Path, config, verbose: bool) -> None:
|
||||
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
|
||||
video_id = video_file.stem
|
||||
# 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:
|
||||
@@ -981,11 +1275,12 @@ def _import_videos(videos_dir: Path, config, verbose: bool) -> None:
|
||||
existing_videos[video_id] = video_entry
|
||||
added_count += 1
|
||||
|
||||
if added_count > 0:
|
||||
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)
|
||||
print(f" Updated {videos_json_path.name} (+{added_count} videos)")
|
||||
if added_count > 0:
|
||||
print(f" Updated {videos_json_path.name} (+{added_count} videos)")
|
||||
else:
|
||||
print(f" No new videos to add")
|
||||
|
||||
@@ -1018,6 +1313,41 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
|
||||
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
|
||||
|
||||
@@ -1041,6 +1371,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
|
||||
# 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:
|
||||
@@ -1066,7 +1397,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
|
||||
|
||||
# 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
|
||||
segment_id = video_file.stem.lower()
|
||||
|
||||
if segment_id in existing_narration:
|
||||
if verbose:
|
||||
@@ -1089,12 +1420,20 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
|
||||
added_count += 1
|
||||
print(f" Added narration segment: {segment_id} (from raw_mov)")
|
||||
|
||||
if added_count > 0 or not narration_json_path.exists():
|
||||
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:
|
||||
print(f" Updated narration.json (+{added_count} segments)")
|
||||
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/)")
|
||||
@@ -1327,7 +1666,12 @@ def _write_tasks_file(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def cmd_pexels(project_path: Path, verbose: bool) -> int:
|
||||
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 (
|
||||
@@ -1336,6 +1680,7 @@ def cmd_pexels(project_path: Path, verbose: bool) -> int:
|
||||
download_video,
|
||||
update_videos_json,
|
||||
enrich_missing_descriptions,
|
||||
search_and_download,
|
||||
)
|
||||
|
||||
api_key = get_pexels_api_key()
|
||||
@@ -1350,20 +1695,66 @@ def cmd_pexels(project_path: Path, verbose: bool) -> int:
|
||||
)
|
||||
return 1
|
||||
|
||||
config = parse_project_config(project_path)
|
||||
_, markers, _, _ = parse_manuscript(project_path)
|
||||
videos, _ = parse_videos(project_path, config)
|
||||
# --- 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
|
||||
|
||||
local_videos_json = project_path / config.videos_path
|
||||
shared_videos_json = shared_assets_dir / "videos.json"
|
||||
|
||||
# 1. Download missing files
|
||||
# --- 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)...")
|
||||
@@ -1395,6 +1786,7 @@ def cmd_pexels(project_path: Path, verbose: bool) -> int:
|
||||
def cmd_validate(project_path: Path, verbose: bool) -> int:
|
||||
"""Validate project configuration."""
|
||||
from .parser import (
|
||||
parse_audio,
|
||||
parse_manuscript,
|
||||
parse_project_config,
|
||||
parse_slides,
|
||||
@@ -1413,15 +1805,17 @@ def cmd_validate(project_path: Path, verbose: bool) -> int:
|
||||
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
|
||||
project_path, markers, config, slides, videos, videos_dir, malformed, audio
|
||||
)
|
||||
for w in warnings:
|
||||
print(f" Warning: {w}")
|
||||
@@ -1440,13 +1834,32 @@ def cmd_validate(project_path: Path, verbose: bool) -> int:
|
||||
|
||||
|
||||
def _resolve_process_cache(project_path: Path, config) -> Optional[Path]:
|
||||
"""Return per-project cache dir on external disk, or None if not configured."""
|
||||
if not (config and config.process_cache):
|
||||
return None
|
||||
p = Path(config.process_cache)
|
||||
if not p.is_absolute():
|
||||
p = (project_path / p).resolve()
|
||||
return p / project_path.name
|
||||
"""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:
|
||||
@@ -1799,9 +2212,104 @@ def cmd_preprocess(
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Trim Command — auto-detect silence bounds for narration segments
|
||||
# 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,
|
||||
@@ -1809,25 +2317,24 @@ def cmd_trim(
|
||||
force: bool = False,
|
||||
threshold_db: float = -40.0,
|
||||
res: str = "full",
|
||||
whisper_model: str = "base",
|
||||
) -> int:
|
||||
"""
|
||||
Auto-detect silence bounds for all narration segments and write skip/take
|
||||
values into narration.json.
|
||||
Trim narration segments and write skip/take values into narration.json.
|
||||
|
||||
For each segment:
|
||||
skip = max(0, first_sound_time - 0.5)
|
||||
take = last_sound_time + 3.0 - skip (capped at file duration)
|
||||
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
|
||||
|
||||
Segments that already have explicit skip or take values are left unchanged
|
||||
unless --force is passed.
|
||||
|
||||
Use --threshold to adjust sensitivity, e.g. -25 to ignore clothing/room
|
||||
noise that sits above -40 dB.
|
||||
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"Auto-trimming narration: {project_path.name}")
|
||||
print(f"Trimming narration: {project_path.name}")
|
||||
|
||||
config = parse_project_config(project_path)
|
||||
narration, narration_dir = parse_narration(project_path, config)
|
||||
@@ -1837,12 +2344,18 @@ def cmd_trim(
|
||||
print(" Run 'gnommo -p <project> import' first.")
|
||||
return 1
|
||||
|
||||
# Build a lookup of raw source files by segment ID. Raw files give cleaner
|
||||
# silence detection — loudnorm can introduce early peaks in processed audio.
|
||||
# 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():
|
||||
@@ -1855,7 +2368,7 @@ def cmd_trim(
|
||||
stem = f.stem
|
||||
if stem.endswith("_compressed"):
|
||||
stem = stem[: -len("_compressed")]
|
||||
raw_lookup[stem] = f
|
||||
raw_lookup[stem.lower()] = f
|
||||
|
||||
narration_json_path = narration_dir / "narration.json"
|
||||
raw_data: dict = _read_json(narration_json_path)
|
||||
@@ -1870,7 +2383,7 @@ def cmd_trim(
|
||||
print(f" {seg_id}: already trimmed, skipping (use --force to redo)")
|
||||
continue
|
||||
|
||||
# Prefer raw file; fall back to processed if raw not available.
|
||||
# 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
|
||||
@@ -1878,6 +2391,68 @@ def cmd_trim(
|
||||
print(f" {seg_id}: source file not found, skipping")
|
||||
continue
|
||||
|
||||
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 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: only trim start
|
||||
new_take = round(total_dur - new_skip, 3)
|
||||
print(
|
||||
f" first={words[0].start:.2f}s"
|
||||
f" → skip={new_skip:.3f}s take={new_take:.3f}s (no end trim)"
|
||||
)
|
||||
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
|
||||
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="",
|
||||
@@ -2670,7 +3245,7 @@ def _project_markers_to_videos(
|
||||
for marker in markers:
|
||||
for prefix, implied in _SHORTHAND_PREFIXES.items():
|
||||
if marker.startswith(prefix):
|
||||
video_id = marker[len(prefix):]
|
||||
video_id = marker[len(prefix):].lower()
|
||||
cutout, layer = implied[0], implied[1]
|
||||
projection[video_id] = {
|
||||
"cutout": cutout,
|
||||
@@ -2720,14 +3295,6 @@ def _project_markers_to_videos(
|
||||
if updated_local:
|
||||
print(f" Projected marker semantics → videos.json: {', '.join(updated_local)}")
|
||||
|
||||
# Also project into shared_assets/videos.json for pexels/library videos
|
||||
shared_assets_dir = _find_shared_assets(project_path) if project_path else None
|
||||
if shared_assets_dir:
|
||||
shared_videos_json = shared_assets_dir / "videos.json"
|
||||
updated_shared = _apply_projection(shared_videos_json)
|
||||
if updated_shared:
|
||||
print(f" Projected marker semantics → shared_assets/videos.json: {', '.join(updated_shared)}")
|
||||
|
||||
|
||||
def _writeback_video_metadata(plan, project_path, config) -> None:
|
||||
"""Write back cutout/layer derived from shorthand markers to videos.json.
|
||||
|
||||
Reference in New Issue
Block a user