Tweaks ton esure that
This commit is contained in:
+117
-41
@@ -418,7 +418,9 @@ def _probe_audio_durations(
|
|||||||
if verbose:
|
if verbose:
|
||||||
print(f" Audio '{audio_id}': file not found, skipping")
|
print(f" Audio '{audio_id}': file not found, skipping")
|
||||||
continue
|
continue
|
||||||
print(f" Probing audio '{audio_id}' ({audio_path.name})...", end=" ", flush=True)
|
print(
|
||||||
|
f" Probing audio '{audio_id}' ({audio_path.name})...", end=" ", flush=True
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
duration = _get_audio_duration(audio_path)
|
duration = _get_audio_duration(audio_path)
|
||||||
data[audio_id]["duration"] = round(duration, 3)
|
data[audio_id]["duration"] = round(duration, 3)
|
||||||
@@ -434,7 +436,11 @@ def _probe_audio_durations(
|
|||||||
|
|
||||||
|
|
||||||
def _probe_video_metadata(
|
def _probe_video_metadata(
|
||||||
project_path: Path, config, shared_assets_dir: Optional[Path], force: bool, verbose: bool
|
project_path: Path,
|
||||||
|
config,
|
||||||
|
shared_assets_dir: Optional[Path],
|
||||||
|
force: bool,
|
||||||
|
verbose: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Probe and cache video file duration and audio presence into videos.json.
|
"""Probe and cache video file duration and audio presence into videos.json.
|
||||||
|
|
||||||
@@ -459,7 +465,11 @@ def _probe_video_metadata(
|
|||||||
|
|
||||||
# Load shared_assets/videos.json separately — shared probes write there
|
# Load shared_assets/videos.json separately — shared probes write there
|
||||||
shared_json_path = shared_assets_dir / "videos.json" if shared_assets_dir else None
|
shared_json_path = shared_assets_dir / "videos.json" if shared_assets_dir else None
|
||||||
shared_data = _read_json(shared_json_path) if shared_json_path and shared_json_path.exists() else {}
|
shared_data = (
|
||||||
|
_read_json(shared_json_path)
|
||||||
|
if shared_json_path and shared_json_path.exists()
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
local_updated = False
|
local_updated = False
|
||||||
shared_updated = False
|
shared_updated = False
|
||||||
@@ -478,10 +488,14 @@ def _probe_video_metadata(
|
|||||||
|
|
||||||
if not force and "duration" in canonical and "has_audio" in canonical:
|
if not force and "duration" in canonical and "has_audio" in canonical:
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f" Video '{video_id}': cached ({canonical['duration']:.1f}s, audio={canonical['has_audio']})")
|
print(
|
||||||
|
f" Video '{video_id}': cached ({canonical['duration']:.1f}s, audio={canonical['has_audio']})"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
base_dir = shared_assets_dir if (is_shared and shared_assets_dir) else videos_dir
|
base_dir = (
|
||||||
|
shared_assets_dir if (is_shared and shared_assets_dir) else videos_dir
|
||||||
|
)
|
||||||
|
|
||||||
# Mirror renderer._resolve_video_path: try output_file first, then source_file
|
# Mirror renderer._resolve_video_path: try output_file first, then source_file
|
||||||
video_path = None
|
video_path = None
|
||||||
@@ -507,7 +521,9 @@ def _probe_video_metadata(
|
|||||||
print(f" Video '{video_id}': file not found, skipping")
|
print(f" Video '{video_id}': file not found, skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f" Probing video '{video_id}' ({video_path.name})...", end=" ", flush=True)
|
print(
|
||||||
|
f" Probing video '{video_id}' ({video_path.name})...", end=" ", flush=True
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
duration = get_video_duration(video_path)
|
duration = get_video_duration(video_path)
|
||||||
has_audio = _has_audio_stream(video_path)
|
has_audio = _has_audio_stream(video_path)
|
||||||
@@ -569,7 +585,10 @@ def _sync_shared_videos_to_local(
|
|||||||
# Propagate any metadata fields that were probed into shared_assets/videos.json
|
# Propagate any metadata fields that were probed into shared_assets/videos.json
|
||||||
changed = False
|
changed = False
|
||||||
for field in _METADATA_FIELDS:
|
for field in _METADATA_FIELDS:
|
||||||
if field in shared_entry and local_videos[video_id].get(field) != shared_entry[field]:
|
if (
|
||||||
|
field in shared_entry
|
||||||
|
and local_videos[video_id].get(field) != shared_entry[field]
|
||||||
|
):
|
||||||
local_videos[video_id][field] = shared_entry[field]
|
local_videos[video_id][field] = shared_entry[field]
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
@@ -588,9 +607,13 @@ def _sync_shared_videos_to_local(
|
|||||||
with open(local_json_path, "w", encoding="utf-8") as f:
|
with open(local_json_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(local_videos, f, indent=4)
|
json.dump(local_videos, f, indent=4)
|
||||||
if added:
|
if added:
|
||||||
print(f" Synced {len(added)} shared asset(s) to local videos.json: {', '.join(added)}")
|
print(
|
||||||
|
f" Synced {len(added)} shared asset(s) to local videos.json: {', '.join(added)}"
|
||||||
|
)
|
||||||
if metadata_updated:
|
if metadata_updated:
|
||||||
print(f" Updated metadata for {len(metadata_updated)} shared asset(s): {', '.join(metadata_updated)}")
|
print(
|
||||||
|
f" Updated metadata for {len(metadata_updated)} shared asset(s): {', '.join(metadata_updated)}"
|
||||||
|
)
|
||||||
elif verbose:
|
elif verbose:
|
||||||
print(" No new shared assets to sync to local videos.json")
|
print(" No new shared assets to sync to local videos.json")
|
||||||
|
|
||||||
@@ -887,8 +910,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
|
|||||||
|
|
||||||
# If a raw_mov equivalent exists, skip — step 2 will handle it
|
# If a raw_mov equivalent exists, skip — step 2 will handle it
|
||||||
raw_mov_has_file = raw_dir.exists() and any(
|
raw_mov_has_file = raw_dir.exists() and any(
|
||||||
(raw_dir / f"{segment_id}{ext}").exists()
|
(raw_dir / f"{segment_id}{ext}").exists() for ext in _raw_video_exts
|
||||||
for ext in _raw_video_exts
|
|
||||||
)
|
)
|
||||||
if raw_mov_has_file:
|
if raw_mov_has_file:
|
||||||
continue
|
continue
|
||||||
@@ -1200,7 +1222,9 @@ def cmd_preprocess(
|
|||||||
# --- Filter pipeline ---
|
# --- Filter pipeline ---
|
||||||
talkinghead_filter = (config.default_filters or {}).get("talkinghead", [])
|
talkinghead_filter = (config.default_filters or {}).get("talkinghead", [])
|
||||||
if not talkinghead_filter:
|
if not talkinghead_filter:
|
||||||
print(" ERROR: No 'talkinghead' filter defined in project.json default_filters.")
|
print(
|
||||||
|
" ERROR: No 'talkinghead' filter defined in project.json default_filters."
|
||||||
|
)
|
||||||
print(" Add a 'talkinghead' entry under 'default_filters' in project.json.")
|
print(" Add a 'talkinghead' entry under 'default_filters' in project.json.")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -1211,8 +1235,11 @@ def cmd_preprocess(
|
|||||||
if not d.exists():
|
if not d.exists():
|
||||||
return []
|
return []
|
||||||
return sorted(
|
return sorted(
|
||||||
f for f in d.iterdir()
|
f
|
||||||
if f.is_file() and f.suffix.lower() in _video_exts and not f.name.startswith(".")
|
for f in d.iterdir()
|
||||||
|
if f.is_file()
|
||||||
|
and f.suffix.lower() in _video_exts
|
||||||
|
and not f.name.startswith(".")
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_mov_files = _scan_dir(raw_dir)
|
raw_mov_files = _scan_dir(raw_dir)
|
||||||
@@ -1224,7 +1251,9 @@ def cmd_preprocess(
|
|||||||
elif raw_mp4_files:
|
elif raw_mp4_files:
|
||||||
source_files = raw_mp4_files
|
source_files = raw_mp4_files
|
||||||
using_compressed = True
|
using_compressed = True
|
||||||
print(" WARNING: raw_mov/ is empty — using compressed files from raw_mp4/ instead. Quality may be reduced.")
|
print(
|
||||||
|
" WARNING: raw_mov/ is empty — using compressed files from raw_mp4/ instead. Quality may be reduced."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(f" No source files found in raw_mov/ or raw_mp4/.")
|
print(f" No source files found in raw_mov/ or raw_mp4/.")
|
||||||
print(f" Place .mov recordings in {raw_dir}")
|
print(f" Place .mov recordings in {raw_dir}")
|
||||||
@@ -1259,7 +1288,9 @@ def cmd_preprocess(
|
|||||||
raw_filter = existing_entry.get("filter")
|
raw_filter = existing_entry.get("filter")
|
||||||
if raw_filter:
|
if raw_filter:
|
||||||
if isinstance(raw_filter, str):
|
if isinstance(raw_filter, str):
|
||||||
filter_list = (config.default_filters or {}).get(raw_filter, talkinghead_filter)
|
filter_list = (config.default_filters or {}).get(
|
||||||
|
raw_filter, talkinghead_filter
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
filter_list = raw_filter
|
filter_list = raw_filter
|
||||||
else:
|
else:
|
||||||
@@ -1276,7 +1307,9 @@ def cmd_preprocess(
|
|||||||
|
|
||||||
if not segments_to_process:
|
if not segments_to_process:
|
||||||
if skipped_count:
|
if skipped_count:
|
||||||
print(f"\n All {skipped_count} segment(s) already preprocessed. Use --force to reprocess.")
|
print(
|
||||||
|
f"\n All {skipped_count} segment(s) already preprocessed. Use --force to reprocess."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print("\n No segments to preprocess.")
|
print("\n No segments to preprocess.")
|
||||||
return 0
|
return 0
|
||||||
@@ -1294,19 +1327,27 @@ def cmd_preprocess(
|
|||||||
|
|
||||||
if workers > 1 and len(segments_to_process) > 1:
|
if workers > 1 and len(segments_to_process) > 1:
|
||||||
num_workers = min(workers, len(segments_to_process))
|
num_workers = min(workers, len(segments_to_process))
|
||||||
print(f"\n Processing {len(segments_to_process)} segments in parallel ({num_workers} workers)")
|
print(
|
||||||
|
f"\n Processing {len(segments_to_process)} segments in parallel ({num_workers} workers)"
|
||||||
|
)
|
||||||
|
|
||||||
def process_segment_task(task):
|
def process_segment_task(task):
|
||||||
seg_id, seg_source = task
|
seg_id, seg_source = task
|
||||||
preprocess_video(
|
preprocess_video(
|
||||||
narration_dir, seg_id, seg_source,
|
narration_dir,
|
||||||
verbose=False, force=force, custom_gnommo_scratch=gnommo_scratch,
|
seg_id,
|
||||||
|
seg_source,
|
||||||
|
verbose=False,
|
||||||
|
force=force,
|
||||||
|
custom_gnommo_scratch=gnommo_scratch,
|
||||||
)
|
)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
completed = 0
|
completed = 0
|
||||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||||
futures = {executor.submit(process_segment_task, t): t for t in segments_to_process}
|
futures = {
|
||||||
|
executor.submit(process_segment_task, t): t for t in segments_to_process
|
||||||
|
}
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
seg_id, seg_source = future.result()
|
seg_id, seg_source = future.result()
|
||||||
completed += 1
|
completed += 1
|
||||||
@@ -1321,8 +1362,12 @@ def cmd_preprocess(
|
|||||||
print(f" Output: {segment_source.output_file}")
|
print(f" Output: {segment_source.output_file}")
|
||||||
print(f" Filters: {len(segment_source.filter)} step(s)")
|
print(f" Filters: {len(segment_source.filter)} step(s)")
|
||||||
preprocess_video(
|
preprocess_video(
|
||||||
narration_dir, segment_id, segment_source,
|
narration_dir,
|
||||||
verbose, force, gnommo_scratch,
|
segment_id,
|
||||||
|
segment_source,
|
||||||
|
verbose,
|
||||||
|
force,
|
||||||
|
gnommo_scratch,
|
||||||
)
|
)
|
||||||
output_path = narration_dir / segment_source.output_file
|
output_path = narration_dir / segment_source.output_file
|
||||||
if output_path.exists():
|
if output_path.exists():
|
||||||
@@ -1330,8 +1375,17 @@ def cmd_preprocess(
|
|||||||
|
|
||||||
# --- Update narration.json ---
|
# --- Update narration.json ---
|
||||||
# Write processed segments; preserve any existing per-segment settings (skip/take/etc.)
|
# Write processed segments; preserve any existing per-segment settings (skip/take/etc.)
|
||||||
_PRESERVE_KEYS = ("skip", "take", "begin", "end", "cutout", "use_audio_channels",
|
_PRESERVE_KEYS = (
|
||||||
"defer_loudnorm", "volume", "zoom")
|
"skip",
|
||||||
|
"take",
|
||||||
|
"begin",
|
||||||
|
"end",
|
||||||
|
"cutout",
|
||||||
|
"use_audio_channels",
|
||||||
|
"defer_loudnorm",
|
||||||
|
"volume",
|
||||||
|
"zoom",
|
||||||
|
)
|
||||||
for segment_id, segment_source in successfully_processed:
|
for segment_id, segment_source in successfully_processed:
|
||||||
existing_entry = existing_narration.get(segment_id, {})
|
existing_entry = existing_narration.get(segment_id, {})
|
||||||
entry: dict = {}
|
entry: dict = {}
|
||||||
@@ -1930,9 +1984,7 @@ def cmd_stitch(
|
|||||||
|
|
||||||
# Get cutout from first narration segment
|
# Get cutout from first narration segment
|
||||||
first_seg = narration[segment_ids[0]]
|
first_seg = narration[segment_ids[0]]
|
||||||
cutout = (
|
cutout = first_seg.cutout or "talkinghead"
|
||||||
first_seg.cutout or "talkinghead"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create/update narration_combined entry
|
# Create/update narration_combined entry
|
||||||
existing_videos["narration_combined"] = {
|
existing_videos["narration_combined"] = {
|
||||||
@@ -2043,12 +2095,32 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None:
|
|||||||
print(f' {marker_id:6} {time_str}{conf_str} "{context}"')
|
print(f' {marker_id:6} {time_str}{conf_str} "{context}"')
|
||||||
elif any(
|
elif any(
|
||||||
marker_id.startswith(p)
|
marker_id.startswith(p)
|
||||||
for p in ("video:", "vft:", "vfb:", "vst:", "vsb:", "vft:", "vfbp:", "vstp:", "vsbp:")
|
for p in (
|
||||||
|
"video:",
|
||||||
|
"vft:",
|
||||||
|
"vfb:",
|
||||||
|
"vst:",
|
||||||
|
"vsb:",
|
||||||
|
"vft:",
|
||||||
|
"vfbp:",
|
||||||
|
"vstp:",
|
||||||
|
"vsbp:",
|
||||||
|
)
|
||||||
):
|
):
|
||||||
aligned_count += 1
|
aligned_count += 1
|
||||||
pfx_len = next(
|
pfx_len = next(
|
||||||
len(p)
|
len(p)
|
||||||
for p in ("video:", "vft:", "vfb:", "vst:", "vsb:", "vft:", "vfbp:", "vstp:", "vsbp:")
|
for p in (
|
||||||
|
"video:",
|
||||||
|
"vft:",
|
||||||
|
"vfb:",
|
||||||
|
"vst:",
|
||||||
|
"vsb:",
|
||||||
|
"vft:",
|
||||||
|
"vfbp:",
|
||||||
|
"vstp:",
|
||||||
|
"vsbp:",
|
||||||
|
)
|
||||||
if marker_id.startswith(p)
|
if marker_id.startswith(p)
|
||||||
)
|
)
|
||||||
video_id = marker_id[pfx_len:]
|
video_id = marker_id[pfx_len:]
|
||||||
@@ -2062,8 +2134,7 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None:
|
|||||||
cutout_name = "?"
|
cutout_name = "?"
|
||||||
end_on = "next_slide"
|
end_on = "next_slide"
|
||||||
layer_tag = ""
|
layer_tag = ""
|
||||||
|
|
||||||
|
|
||||||
cache_ind = " 📁" if video_id in plan.cached_files else ""
|
cache_ind = " 📁" if video_id in plan.cached_files else ""
|
||||||
print(
|
print(
|
||||||
f" {marker_id:20} {time_str} in '{cutout_name}' [{end_on}]{layer_tag}{cache_ind}"
|
f" {marker_id:20} {time_str} in '{cutout_name}' [{end_on}]{layer_tag}{cache_ind}"
|
||||||
@@ -2790,13 +2861,14 @@ def cmd_all(
|
|||||||
|
|
||||||
print("\n>>> Step 2/6: Preprocess\n")
|
print("\n>>> Step 2/6: Preprocess\n")
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
result = cmd_preprocess(project_path, verbose, dry_run, cascade_force, workers=1, res=res)
|
result = cmd_preprocess(
|
||||||
|
project_path, verbose, dry_run, cascade_force, workers=1, res=res
|
||||||
|
)
|
||||||
if result != 0:
|
if result != 0:
|
||||||
return result
|
return result
|
||||||
if (
|
if _files_modified_since(
|
||||||
_files_modified_since(project_path, t0, "*_processed.mov")
|
project_path, t0, "*_processed.mov"
|
||||||
or _files_modified_since(project_path, t0, "*_processed.webm")
|
) or _files_modified_since(project_path, t0, "*_processed.webm"):
|
||||||
):
|
|
||||||
cascade_force = True
|
cascade_force = True
|
||||||
|
|
||||||
print("\n>>> Step 3/6: Trim\n")
|
print("\n>>> Step 3/6: Trim\n")
|
||||||
@@ -2938,14 +3010,15 @@ def cmd_description(project_path: Path, verbose: bool) -> int:
|
|||||||
# Files and directories excluded from all sync/archive/load operations.
|
# Files and directories excluded from all sync/archive/load operations.
|
||||||
# Covers intermediate processing artifacts, chunk scratch dirs, venv, and
|
# Covers intermediate processing artifacts, chunk scratch dirs, venv, and
|
||||||
# common OS/editor noise.
|
# common OS/editor noise.
|
||||||
|
|
||||||
_RSYNC_EXCLUDES = [
|
_RSYNC_EXCLUDES = [
|
||||||
# Intermediate processing files
|
# Intermediate processing files
|
||||||
"media/narration/intermediate/",
|
"media/narration/intermediate/",
|
||||||
"media/narration/intermediate/**",
|
"media/narration/intermediate/**",
|
||||||
"media/videos/intermediate/",
|
"media/videos/intermediate/",
|
||||||
"media/videos/intermediate/**",
|
"media/videos/intermediate/**",
|
||||||
"media/videos/processed/",
|
"media/narration/processed/",
|
||||||
"media/videos/processed/**",
|
"media/narration/processed/**",
|
||||||
# Chunk scratch directories
|
# Chunk scratch directories
|
||||||
"**/chunks/",
|
"**/chunks/",
|
||||||
"**/chunks/**",
|
"**/chunks/**",
|
||||||
@@ -3145,7 +3218,9 @@ def cmd_sync(project_path: Path, verbose: bool, dry_run: bool, download: bool) -
|
|||||||
else:
|
else:
|
||||||
remote_dir = f"{server['path']}/{project_path.name}"
|
remote_dir = f"{server['path']}/{project_path.name}"
|
||||||
ssh_cmd = [
|
ssh_cmd = [
|
||||||
"ssh", "-p", server["port"],
|
"ssh",
|
||||||
|
"-p",
|
||||||
|
server["port"],
|
||||||
f"{server['user']}@{server['host']}",
|
f"{server['user']}@{server['host']}",
|
||||||
f"mkdir -p {remote_dir}",
|
f"mkdir -p {remote_dir}",
|
||||||
]
|
]
|
||||||
@@ -3160,7 +3235,8 @@ def cmd_sync(project_path: Path, verbose: bool, dry_run: bool, download: bool) -
|
|||||||
"rsync",
|
"rsync",
|
||||||
"-av",
|
"-av",
|
||||||
"--progress",
|
"--progress",
|
||||||
"-e", f"ssh -p {server['port']}",
|
"-e",
|
||||||
|
f"ssh -p {server['port']}",
|
||||||
*[f"--exclude={p}" for p in _RSYNC_EXCLUDES],
|
*[f"--exclude={p}" for p in _RSYNC_EXCLUDES],
|
||||||
src,
|
src,
|
||||||
dest,
|
dest,
|
||||||
|
|||||||
+6
-2
@@ -298,9 +298,13 @@ class VideoSource:
|
|||||||
)
|
)
|
||||||
volume: float = 1.0 # Volume multiplier (1.0=full, >1.0=boost, <1.0=reduce)
|
volume: float = 1.0 # Volume multiplier (1.0=full, >1.0=boost, <1.0=reduce)
|
||||||
layer: str = "above" # "above" = renders on top of slides; "below" = behind slides
|
layer: str = "above" # "above" = renders on top of slides; "below" = behind slides
|
||||||
duration: Optional[float] = None # Pre-probed file duration in seconds (set by import)
|
duration: Optional[
|
||||||
|
float
|
||||||
|
] = None # Pre-probed file duration in seconds (set by import)
|
||||||
has_audio: Optional[bool] = None # Pre-detected audio presence (set by import)
|
has_audio: Optional[bool] = None # Pre-detected audio presence (set by import)
|
||||||
end_on: Optional[str] = None # When video event ends: "next_slide" | "end" | "take" (None = marker-type default)
|
end_on: Optional[
|
||||||
|
str
|
||||||
|
] = None # When video event ends: "next_slide" | "end" | "take" (None = marker-type default)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+28
-11
@@ -302,7 +302,6 @@ def run_ffmpeg_with_progress(cmd, duration, description="Processing"):
|
|||||||
while True:
|
while True:
|
||||||
# If process ended and no more output, break
|
# If process ended and no more output, break
|
||||||
if p.poll() is not None:
|
if p.poll() is not None:
|
||||||
|
|
||||||
# drain any remaining output quickly
|
# drain any remaining output quickly
|
||||||
while True:
|
while True:
|
||||||
line = p.stdout.readline()
|
line = p.stdout.readline()
|
||||||
@@ -358,7 +357,9 @@ def run_ffmpeg_with_progress(cmd, duration, description="Processing"):
|
|||||||
else:
|
else:
|
||||||
code = p.returncode
|
code = p.returncode
|
||||||
# On macOS/Linux, -9 means SIGKILL (OOM kill by OS), -6 = SIGABRT
|
# On macOS/Linux, -9 means SIGKILL (OOM kill by OS), -6 = SIGABRT
|
||||||
signal_hint = " (OOM kill)" if code == -9 else (" (abort)" if code == -6 else "")
|
signal_hint = (
|
||||||
|
" (OOM kill)" if code == -9 else (" (abort)" if code == -6 else "")
|
||||||
|
)
|
||||||
sys.stdout.write(f"\n FFmpeg exited with code {code}{signal_hint}\n")
|
sys.stdout.write(f"\n FFmpeg exited with code {code}{signal_hint}\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
@@ -371,12 +372,19 @@ def _has_audio_stream(video_path: Path) -> bool:
|
|||||||
"""Return True if the file has a real (non-ghost) audio stream."""
|
"""Return True if the file has a real (non-ghost) audio stream."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"ffprobe", "-v", "error",
|
"ffprobe",
|
||||||
"-analyzeduration", "0",
|
"-v",
|
||||||
"-probesize", "1000000",
|
"error",
|
||||||
"-select_streams", "a:0",
|
"-analyzeduration",
|
||||||
"-show_entries", "stream=index,nb_frames",
|
"0",
|
||||||
"-of", "csv=p=0",
|
"-probesize",
|
||||||
|
"1000000",
|
||||||
|
"-select_streams",
|
||||||
|
"a:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=index,nb_frames",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0",
|
||||||
str(video_path),
|
str(video_path),
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -1380,9 +1388,18 @@ def _process_chunk_to_prores4444(
|
|||||||
# FFmpeg can return 0 but write a corrupt/incomplete file (e.g. moov atom
|
# FFmpeg can return 0 but write a corrupt/incomplete file (e.g. moov atom
|
||||||
# missing) when faststart rewrite fails or disk is under pressure.
|
# missing) when faststart rewrite fails or disk is under pressure.
|
||||||
probe = subprocess.run(
|
probe = subprocess.run(
|
||||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
[
|
||||||
"-of", "csv=p=0", str(output_path)],
|
"ffprobe",
|
||||||
capture_output=True, text=True,
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0",
|
||||||
|
str(output_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
if probe.returncode != 0 or not probe.stdout.strip():
|
if probe.returncode != 0 or not probe.stdout.strip():
|
||||||
raise PreprocessError(
|
raise PreprocessError(
|
||||||
|
|||||||
+8
-3
@@ -410,7 +410,9 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
input_idx += 1
|
input_idx += 1
|
||||||
has_audio = event.video_source.has_audio
|
has_audio = event.video_source.has_audio
|
||||||
if has_audio is None:
|
if has_audio is None:
|
||||||
print(f" Warning: no cached metadata for '{event.video_source.source_file}' — run 'gnommo import' to avoid slow probing")
|
print(
|
||||||
|
f" Warning: no cached metadata for '{event.video_source.source_file}' — run 'gnommo import' to avoid slow probing"
|
||||||
|
)
|
||||||
has_audio = _has_audio_stream(video_path)
|
has_audio = _has_audio_stream(video_path)
|
||||||
if has_audio:
|
if has_audio:
|
||||||
video_events_with_audio.add(i)
|
video_events_with_audio.add(i)
|
||||||
@@ -436,7 +438,9 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
input_idx += 1
|
input_idx += 1
|
||||||
has_audio = event.video_source.has_audio
|
has_audio = event.video_source.has_audio
|
||||||
if has_audio is None:
|
if has_audio is None:
|
||||||
print(f" Warning: no cached metadata for '{event.video_source.source_file}' — run 'gnommo import' to avoid slow probing")
|
print(
|
||||||
|
f" Warning: no cached metadata for '{event.video_source.source_file}' — run 'gnommo import' to avoid slow probing"
|
||||||
|
)
|
||||||
has_audio = _has_audio_stream(video_path)
|
has_audio = _has_audio_stream(video_path)
|
||||||
if has_audio:
|
if has_audio:
|
||||||
outro_events_with_audio.add(i)
|
outro_events_with_audio.add(i)
|
||||||
@@ -468,7 +472,8 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
|
|||||||
# Cache duration for crossfade loop filter
|
# Cache duration for crossfade loop filter
|
||||||
if event.audio_def.loop and event.audio_def.overlap:
|
if event.audio_def.loop and event.audio_def.overlap:
|
||||||
audio_durations[event.audio_id] = (
|
audio_durations[event.audio_id] = (
|
||||||
file_duration if file_duration is not None
|
file_duration
|
||||||
|
if file_duration is not None
|
||||||
else _get_audio_duration(audio_path)
|
else _get_audio_duration(audio_path)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+14
-7
@@ -134,7 +134,18 @@ def _is_known_marker(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Video/narration triggers (all supported prefixes)
|
# Video/narration triggers (all supported prefixes)
|
||||||
_VIDEO_PREFIXES = ("video:", "narration:", "vft:", "vfb:", "vst:", "vsb:", "vftp:", "vfbp:", "vstp:", "vsbp:")
|
_VIDEO_PREFIXES = (
|
||||||
|
"video:",
|
||||||
|
"narration:",
|
||||||
|
"vft:",
|
||||||
|
"vfb:",
|
||||||
|
"vst:",
|
||||||
|
"vsb:",
|
||||||
|
"vftp:",
|
||||||
|
"vfbp:",
|
||||||
|
"vstp:",
|
||||||
|
"vsbp:",
|
||||||
|
)
|
||||||
if any(marker_id.startswith(p) for p in _VIDEO_PREFIXES):
|
if any(marker_id.startswith(p) for p in _VIDEO_PREFIXES):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -923,9 +934,7 @@ def _extract_video_events(
|
|||||||
f"Marker [video:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. "
|
f"Marker [video:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. "
|
||||||
f"Available: {list(cutouts.keys())}"
|
f"Available: {list(cutouts.keys())}"
|
||||||
)
|
)
|
||||||
video_markers.append(
|
video_markers.append((timing.timestamp, video_id, "video", None, None))
|
||||||
(timing.timestamp, video_id, "video", None, None)
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- [narration:xxx] ---
|
# --- [narration:xxx] ---
|
||||||
@@ -946,9 +955,7 @@ def _extract_video_events(
|
|||||||
f"Marker [narration:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. "
|
f"Marker [narration:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. "
|
||||||
f"Available: {list(cutouts.keys())}"
|
f"Available: {list(cutouts.keys())}"
|
||||||
)
|
)
|
||||||
video_markers.append(
|
video_markers.append((timing.timestamp, video_id, "narration", None, None))
|
||||||
(timing.timestamp, video_id, "narration", None, None)
|
|
||||||
)
|
|
||||||
|
|
||||||
events: list[VideoEvent] = []
|
events: list[VideoEvent] = []
|
||||||
for (
|
for (
|
||||||
|
|||||||
Reference in New Issue
Block a user