Commti prior to change to video tag below / above layering

This commit is contained in:
2026-03-16 16:57:54 +01:00
parent 757d966803
commit e734dbfcac
12 changed files with 416 additions and 154 deletions
+204 -72
View File
@@ -2,12 +2,15 @@
import argparse
import json
from logging import config
import re
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
@@ -35,10 +38,15 @@ Examples:
gnommo -p video1 import Generate slides.json from images
gnommo -p video1 pre Preprocess videos (chroma key, etc.)
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 all Full pipeline: transcribe → align → render
gnommo -p video1 render --dry-run Show FFmpeg command without running
gnommo -p video1 description Generate YouTube description file
gnommo -p video1 transcribe --final Transcribe final.mp4 and generate SRT for YouTube
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 Sync project to external cache storage
gnommo -p video1 archive --dry-run Preview what would be synced
gnommo -p video1 extract-audio --combined Extract audio from narration_combined.mov
@@ -71,6 +79,7 @@ Examples:
"preprocess",
"pre",
"stitch",
"trim",
"render",
"all",
"transcribe",
@@ -156,6 +165,12 @@ Examples:
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.",
)
args = parser.parse_args()
@@ -181,6 +196,8 @@ Examples:
args.workers,
args.res,
)
elif action == "trim":
return cmd_trim(project_path, args.verbose, args.force, args.threshold)
elif action in ("stitch"):
return cmd_stitch(
project_path,
@@ -223,7 +240,7 @@ Examples:
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)
return cmd_handoff(project_path, args.verbose, args.file, args.prod, args.res)
except GnommoError as e:
print(f"Error: {e}", file=sys.stderr)
@@ -242,7 +259,7 @@ Examples:
def cmd_import(project_path: Path, force: bool, verbose: bool) -> int:
"""Import assets and generate metadata JSON files."""
from .parser import parse_project_config
from .parser import parse_project_config, _read_json
print(f"Importing assets for: {project_path.name}")
@@ -367,8 +384,7 @@ def _import_shared_assets(shared_assets_dir: Path, verbose: bool) -> None:
videos_json_path = shared_assets_dir / "videos.json"
existing_videos: dict = {}
if videos_json_path.exists():
with open(videos_json_path, "r", encoding="utf-8") as f:
existing_videos = json.load(f)
existing_videos = _read_json(videos_json_path)
# Add new videos (don't overwrite existing)
added_count = 0
@@ -474,8 +490,7 @@ def _import_videos(videos_dir: Path, config, verbose: bool) -> None:
videos_json_path = videos_dir / "videos.json"
existing_videos: dict = {}
if videos_json_path.exists():
with open(videos_json_path, "r", encoding="utf-8") as f:
existing_videos = json.load(f)
existing_videos = _read_json(videos_json_path)
# Get available filter presets from config
default_filters = config.default_filters if config else {}
@@ -558,8 +573,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
narration_json_path = narration_dir / "narration.json"
existing_narration: dict = {}
if narration_json_path.exists():
with open(narration_json_path, "r", encoding="utf-8") as f:
existing_narration = json.load(f)
existing_narration = _read_json(narration_json_path)
# Get available filter presets from config
default_filters = config.default_filters if config else {}
@@ -583,9 +597,11 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
# Apply talkinghead preset if available
if "talkinghead" in default_filters:
narration_entry["filter"] = "talkinghead"
narration_entry["cutout"] = "talkinghead"
if "talkinghead" in default_filters:
narration_entry["filter"] = "talkinghead"
# Default audio settings for narration
narration_entry["use_audio_channels"] = "left"
narration_entry["defer_loudnorm"] = True
@@ -656,7 +672,7 @@ def _import_presenter_notes(
# Parse JSON output from JXA script
try:
notes_data = json.loads(proc.stdout)
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
@@ -714,9 +730,11 @@ def cmd_validate(project_path: Path, verbose: bool) -> int:
print(f" - Videos defined: {len(videos)}")
# Validate
validate_project(
warnings = validate_project(
project_path, markers, config, slides, videos, videos_dir, malformed
)
for w in warnings:
print(f" Warning: {w}")
print("Validation passed.")
return 0
@@ -735,9 +753,9 @@ def cmd_preprocess(
workers: int = 1,
res: str = "full",
) -> int:
"""Run preprocessing pipeline on narration segments."""
"""Run preprocessing pipeline on narration segments and videos."""
from concurrent.futures import ThreadPoolExecutor, as_completed
from .parser import parse_project_config, parse_narration
from .parser import parse_project_config, parse_narration, parse_videos
from .preprocessor import (
preprocess_video,
create_downscaled_videos,
@@ -834,10 +852,118 @@ def cmd_preprocess(
)
print(f"\n Run 'gnommo -p <project> stitch' to stitch narration segments into one fulll 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)
print("\nPreprocessing complete.")
return 0
# =============================================================================
# Trim Command — auto-detect silence bounds for narration segments
# =============================================================================
def cmd_trim(
project_path: Path,
verbose: bool,
force: bool = False,
threshold_db: float = -40.0,
) -> int:
"""
Auto-detect silence bounds for all 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)
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.
"""
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}")
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
narration_json_path = narration_dir / "narration.json"
raw_data: dict = _read_json(narration_json_path)
updated = 0
for seg_id in sorted(narration.keys()):
seg = narration[seg_id]
existing = raw_data.get(seg_id, {})
has_explicit = "skip" in existing or "take" in existing
if has_explicit and not force:
print(f" {seg_id}: already trimmed, skipping (use --force to redo)")
continue
# Always analyse the raw source file — it's always present and has the
# same audio as any processed version (processing is video-only).
source_path = narration_dir / seg.source_file
if not source_path.exists():
print(f" {seg_id}: source file not found ({seg.source_file}), skipping")
continue
print(f" {seg_id}: analysing...", 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
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")
return 0
# =============================================================================
# Stitch Command (fast iteration on narration segments)
# =============================================================================
@@ -903,19 +1029,17 @@ def cmd_stitch(
if stitch_output.exists() and not force:
print(f"\n Combined narration exists: {stitch_output.name}")
print(" (use --force to regenerate)")
return 0
stitch_narration_segments(
narration_dir,
segment_ids,
narration,
stitch_output,
verbose=verbose,
default_end_trim=config.default_end_trim if config else 0.0,
)
# Run import videos again, because at this point narration_combined might have been created.
_import_videos(videos_dir, config, verbose)
else:
stitch_narration_segments(
narration_dir,
segment_ids,
narration,
stitch_output,
verbose=verbose,
default_end_trim=config.default_end_trim if config else 0.0,
)
# Run import videos again, because at this point narration_combined might have been created.
_import_videos(videos_dir, config, verbose)
# Always update the MAIN videos.json (parent of subdir when using low/tiny res)
# Downscaled dirs only affect file paths, not JSON metadata updates
@@ -924,12 +1048,11 @@ def cmd_stitch(
if True: # Always update JSON regardless of proxy mode
existing_videos: dict = {}
if videos_json_path.exists():
with open(videos_json_path, "r", encoding="utf-8") as f:
existing_videos = json.load(f)
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"
cutout = first_seg.cutout or "talkinghead" # Default to audioonly if no cutout specified
# Create/update narration_combined entry
existing_videos["narration_combined"] = {
@@ -1149,7 +1272,10 @@ def cmd_render(
# Non-full res: use downscaled video directory, create on-the-fly if needed
if res != "full":
videos_dir = ensure_downscaled_files_exist(videos_dir, res, force=False, verbose=verbose)
# 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)
@@ -1246,9 +1372,11 @@ def cmd_render(
# Stage 2: Validate
print("\n[2/4] Validating...")
validate_project(
warnings = validate_project(
project_path, markers, config, slides, videos, videos_dir, malformed
)
for w in warnings:
print(f" Warning: {w}")
print(" Passed.")
# Stage 3: Transform (includes on-the-fly alignment)
@@ -1310,14 +1438,19 @@ def cmd_render(
print(f"\n Continuing anyway due to --force flag...")
# Stage 4: Render
# Generate output filename based on slide range and resolution
base_name = "preview" if res == "low" else "final"
if slide_range:
# Determine output filename and directory
if config.output_video:
out_filename = config.output_video
elif slide_range:
start, end = slide_range
range_suffix = f"_{start}-{end}" if end else f"_{start}-end"
output_path = project_path / "out" / f"{base_name}{range_suffix}.mp4"
out_filename = f"final{range_suffix}.mp4"
else:
output_path = project_path / "out" / f"{base_name}.mp4"
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
plan.output_path = output_path
if dry_run:
print("\n[4/4] FFmpeg command (dry run):")
@@ -1372,15 +1505,17 @@ def cmd_transcribe(
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:
return _transcribe_final(project_path, verbose)
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}")
config = parse_project_config(project_path)
videos, videos_dir = parse_videos(project_path, config)
if not videos:
print("Error: No videos defined in videos.json", file=sys.stderr)
@@ -1433,23 +1568,20 @@ def cmd_transcribe(
return 0
def _transcribe_final(project_path: Path, verbose: bool) -> int:
def _transcribe_final(final_video: Path, verbose: bool) -> int:
"""
Transcribe the final rendered video and generate SRT captions for YouTube.
Looks for out/final.mp4 and creates out/final.srt suitable for upload.
Looks and creates out filename.srt suitable for upload.
"""
from .transcriber import transcribe_video, save_transcript, words_to_srt
print(f"Transcribing final output: {project_path.name}")
print(f"Transcribing final output: {final_video}")
# Look for the final rendered video
out_dir = project_path / "out"
final_video = out_dir / "final.mp4"
if not final_video.exists():
print(f"Error: Final video not found: {final_video}", file=sys.stderr)
print(f"Run 'gnommo -p {project_path.name} render' first.", file=sys.stderr)
print("Run 'gnommo render' first.", file=sys.stderr)
return 1
print(f" Video: {final_video.name}")
@@ -1462,11 +1594,11 @@ def _transcribe_final(project_path: Path, verbose: bool) -> int:
return 1
# Save JSON transcript
transcript_path = out_dir / "final.transcript.json"
transcript_path = final_video.with_suffix(".transcript.json")
save_transcript(words, transcript_path)
# Generate SRT captions
srt_path = out_dir / "final.srt"
srt_path = final_video.with_suffix(".srt")
srt_content = words_to_srt(words)
srt_path.write_text(srt_content, encoding="utf-8")
@@ -1597,33 +1729,33 @@ def cmd_all(
res: str = "full",
force: bool = False,
) -> int:
"""Run full pipeline: transcribe → render (alignment is automatic)."""
from .parser import parse_project_config, parse_videos
"""Run full pipeline: preprocess → stitch → render → handoff."""
from .handoff import cmd_handoff
print(f"=== Full Pipeline: {project_path.name} ===\n")
# Check if transcription exists
config = parse_project_config(project_path)
videos, videos_dir = parse_videos(project_path, config)
result = _find_narration_video(config, videos)
if result:
video_id, video_source = result
video_path = videos_dir / video_source.source_file
transcript_path = video_path.with_suffix(".transcript.json")
print(">>> Step 1/5: Import\n")
result = cmd_import(project_path, force, verbose)
if result != 0:
return result
# Try cache fallback for transcript
resolved_transcript, _ = resolve_with_cache(transcript_path, project_path)
if not resolved_transcript.exists():
print(">>> Step 1/2: Transcribe\n")
result = cmd_transcribe(project_path, verbose)
if result != 0:
return result
else:
print(f">>> Step 1/2: Transcribe (cached: {resolved_transcript.name})\n")
print("\n>>> Step 2/5: Preprocess\n")
result = cmd_preprocess(project_path, verbose, dry_run, force, workers=1, res=res)
if result != 0:
return result
# Render (alignment happens automatically)
print("\n>>> Step 2/2: Render\n")
return cmd_render(project_path, verbose, dry_run, res=res, force=force)
print("\n>>> Step 3/5: Stitch\n")
result = cmd_stitch(project_path, verbose, force, res=res)
if result != 0:
return result
print("\n>>> Step 4/5: Render\n")
result = cmd_render(project_path, verbose, dry_run, res=res, force=force)
if result != 0:
return result
print("\n>>> Step 5/5: Handoff\n")
return cmd_handoff(project_path, verbose, file_override=None, prod=False, res=res)
# =============================================================================
@@ -1801,7 +1933,7 @@ def cmd_archive(project_path: Path, verbose: bool, dry_run: bool) -> int:
project_json_path = project_path / "project.json"
if project_json_path.exists():
try:
data = json.loads(project_json_path.read_text(encoding="utf-8"))
data = _read_json(project_json_path.read_text(encoding="utf-8"))
data["synced_time"] = datetime.now().isoformat()
project_json_path.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",