Adding updates to titlesZZ
This commit is contained in:
+279
-4
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user