diff --git a/gnommo/cli.py b/gnommo/cli.py index 0c2e0d6..b6dab04 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -4520,6 +4520,23 @@ def cmd_render( # Check for unaligned markers unaligned = [t for t in marker_timings if t.timestamp < 0] + if slide_range and unaligned: + # Partial (--slides) render: only unaligned markers INSIDE the requested + # range should block it — failures elsewhere in the manuscript are + # irrelevant to this window. Restrict to the manuscript-order span + # [start_slide, end_slide]. + order = [t.marker_id for t in marker_timings] + start_slide, end_slide = slide_range + lo = order.index(start_slide) if start_slide in order else 0 + hi = order.index(end_slide) if (end_slide and end_slide in order) else len(order) - 1 + in_range = set(order[lo : hi + 1]) + skipped = [t for t in unaligned if t.marker_id not in in_range] + unaligned = [t for t in unaligned if t.marker_id in in_range] + if skipped: + print( + f"\n ({len(skipped)} unaligned marker(s) outside the {start_slide}:" + f"{end_slide or ''} range ignored for this partial render)" + ) if unaligned: print(f"\n WARNING: {len(unaligned)} marker(s) could not be aligned!") for t in unaligned: diff --git a/gnommo/parser.py b/gnommo/parser.py index 333409b..f79229f 100644 --- a/gnommo/parser.py +++ b/gnommo/parser.py @@ -476,11 +476,12 @@ def parse_videos( videos = {} for video_id, video_data in data.items(): + if "source_file" not in video_data: raise ParseError( f"Video '{video_id}' missing required field 'source_file'", videos_path ) - + # Parse attribution if present attribution = None if "attribution" in video_data: diff --git a/gnommo/preprocessor.py b/gnommo/preprocessor.py index 4972cda..9c55450 100644 --- a/gnommo/preprocessor.py +++ b/gnommo/preprocessor.py @@ -806,7 +806,7 @@ def preprocess_video( filter_type=first_filter_type, ) - # If output_file is specified, copy/rename to final location and clean up + # If output_file is specified, move/copy to final location and clean up if video_source.output_file: import shutil @@ -814,11 +814,32 @@ def preprocess_video( final_output.parent.mkdir(parents=True, exist_ok=True) - # Write to a .tmp file first so that an interrupted copy never leaves a - # partial file at the final path (which would be silently skipped next run). + # Stage on a .tmp on the destination filesystem, then atomically swap in — + # an interrupted transfer never leaves a partial file at the final path. tmp_output = final_output.with_suffix(".tmp") - shutil.copy2(current_input, tmp_output) - tmp_output.replace(final_output) # atomic on same filesystem + + # current_input is the last batch's scratch intermediate (about to be + # deleted anyway), so MOVE it into place rather than copying — concat has + # already written this multi-GB ProRes once, and a rename is instant on the + # same filesystem. Only fall back to a copy across filesystems (e.g. scratch + # on an external SSD, output on the internal disk). Never move the original + # source (transcribe-only path leaves current_input == source). + moved = False + if current_input in intermediate_files: + try: + os.replace(current_input, tmp_output) + moved = True + except OSError: + moved = False # cross-filesystem — copy instead + if not moved: + try: + size_gb = current_input.stat().st_size / (1024**3) + print(f" Finalizing: copying {size_gb:.1f} GB to output (cross-disk)...") + except OSError: + pass + shutil.copy2(current_input, tmp_output) + + os.replace(tmp_output, final_output) # atomic on the destination filesystem if verbose: print(f" Final output: {final_output}")