from __future__ import annotations from fractions import Fraction from pathlib import Path from tools.avisynth_render import AvisynthClipInfo from tools.video_encode_output import AudioEncodeOptions from tools.video_probe import VideoProbe from tools.video_timeline import OutputTimeline def normal_deleted_frame_count(timeline: OutputTimeline) -> int: return missing_source_frames(timeline.consumed_source_frames) def missing_source_frames_between_kept(timeline: OutputTimeline) -> int: return missing_source_frames([frame.source_frame for frame in timeline.frames]) def missing_source_frames(source_frames: list[int]) -> int: missing = 0 for previous, current in zip(source_frames, source_frames[1:]): missing += max(0, current - previous - 1) return missing def should_preserve_video_timestamps(timeline: OutputTimeline) -> bool: return timeline.timing_kind == "vfr" or normal_deleted_frame_count(timeline) > 0 def should_use_audio_segments( timeline: OutputTimeline, audio_options: AudioEncodeOptions, ) -> bool: if audio_options.mode != "aac": return False if len(timeline.audio_segments) > 1: return True if not timeline.audio_segments: return False start, duration = timeline.audio_segments[0] return ( abs(start - timeline.frames[0].start) > 0.001 or abs(duration - timeline.duration_seconds) > 0.001 ) def y4m_command_for_timeline( avs_runner: Path, timeline: OutputTimeline, *, force_timeline_rate: bool = False, ) -> list[str]: if not timeline.dropped_frame_count and not force_timeline_rate: return [str(avs_runner), "--y4m"] fps = cfr_fps_for_timeline(timeline) return [str(avs_runner), "--y4m-skip-drop", str(fps.numerator), str(fps.denominator)] def cfr_fps_for_timeline(timeline: OutputTimeline) -> Fraction: if timeline.duration_seconds <= 0: raise RuntimeError("Cannot encode a zero-duration timeline") return Fraction(len(timeline.frames) / timeline.duration_seconds).limit_denominator(1001000) def timeline_frame_starts(timeline: OutputTimeline) -> list[float]: starts: list[float] = [] current = 0.0 for frame in timeline.frames: starts.append(current) current += frame.duration return starts def avisynth_duration_differs( clip_info: AvisynthClipInfo, timeline: OutputTimeline, ) -> bool: duration = clip_info.duration_seconds if duration is None: return False return abs(duration - timeline.duration_seconds) > 0.05 def encoded_duration_seconds( clip_info: AvisynthClipInfo | None, timeline: OutputTimeline, ) -> float: if clip_info is None or clip_info.duration_seconds is None: return timeline.duration_seconds return clip_info.duration_seconds def has_avisynth_speed_changes( timelines: dict[Path, OutputTimeline], clip_infos: dict[Path, AvisynthClipInfo], ) -> bool: for path, timeline in timelines.items(): clip_info = clip_infos.get(path) if clip_info is not None and avisynth_duration_differs(clip_info, timeline): return True return False def audio_tempo_factor(timeline: OutputTimeline, output_duration: float) -> float: if output_duration <= 0: raise RuntimeError("Encoded output duration must be positive") return timeline.duration_seconds / output_duration def audio_options_for_probe( requested: AudioEncodeOptions, probe: VideoProbe, timeline: OutputTimeline, output_duration: float, *, normal_deleted_frames: int = 0, ) -> AudioEncodeOptions: if requested.mode == "none": return requested if probe.audio_stream_count <= 0: print(" audio: source has no audio stream; output will be video-only") return AudioEncodeOptions(mode="none") if requested.mode == "copy" and normal_deleted_frames: print(" audio: copy requested, but frame deletion needs audio cuts; output will be video-only") return AudioEncodeOptions(mode="none") if requested.mode == "copy" and abs(timeline.duration_seconds - output_duration) > 0.05: print(" audio: copy requested, but speed-changed output needs audio re-encoding; output will be video-only") return AudioEncodeOptions(mode="none") return requested