Adding updates to titlesZZ

This commit is contained in:
2026-07-14 14:04:44 +02:00
parent f9ff847f6b
commit 308a9f8bcd
17 changed files with 4608 additions and 186 deletions
+279 -4
View File
@@ -38,6 +38,7 @@ Examples:
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 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
@@ -50,6 +51,7 @@ Examples:
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
@@ -107,6 +109,8 @@ Examples:
"handoff",
"transcode",
"pexels",
"clear",
"new",
],
help="Action to perform (default: render)",
)
@@ -259,6 +263,10 @@ Examples:
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 in ("preprocess", "pre"):
return cmd_preprocess(
project_path,
@@ -1909,6 +1917,235 @@ def _resolve_narration_combined(
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
def cmd_preprocess(
project_path: Path,
verbose: bool,
@@ -1955,6 +2192,12 @@ def cmd_preprocess(
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:
@@ -2419,11 +2662,13 @@ def cmd_trim(
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)
# 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"
f" → skip={new_skip:.3f}s take={new_take:.3f}s (no end trim)"
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, "")
@@ -3257,6 +3502,27 @@ def _project_markers_to_videos(
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():
@@ -3284,6 +3550,15 @@ def _project_markers_to_videos(
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: