Commti prior to change to video tag below / above layering
This commit is contained in:
+204
-72
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user