Add Avisynth video encoding workflow

This commit is contained in:
ajp_anton
2026-07-21 00:41:00 +00:00
parent f50f465465
commit 49296d6f45
37 changed files with 9487 additions and 76 deletions
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import statistics
from dataclasses import dataclass
from tools.avisynth_validate import FrameIdentity
from tools.video_probe import VideoProbe
@dataclass(frozen=True)
class OutputFrameTiming:
output_index: int
source_id: int
source_frame: int
start: float
duration: float
@dataclass(frozen=True)
class OutputTimeline:
frames: list[OutputFrameTiming]
audio_segments: list[tuple[float, float]]
consumed_source_frames: list[int]
duration_seconds: float
timing_kind: str
beginning_trimmed: bool
ending_trimmed: bool
dropped_frame_count: int
source_frame_count: int
matrix: int | None
def build_output_timeline(
identities: list[FrameIdentity],
*,
probe: VideoProbe,
) -> OutputTimeline:
source_durations = _source_frame_durations(probe)
kept_frames: list[OutputFrameTiming] = []
audio_segments: list[tuple[float, float]] = []
consumed_source_frames: list[int] = []
matrices: set[int] = set()
dropped_count = 0
leading_trimmed = False
last_consumed_source_frame: int | None = None
for frame in sorted(identities, key=lambda item: item.output_frame):
if frame.source_id is None or frame.source_frame is None:
continue
if frame.source_frame >= len(source_durations):
raise RuntimeError(
f"source frame {frame.source_frame} is outside probed frame table "
f"for {probe.path}"
)
duration = source_durations[frame.source_frame]
if frame.drop_frame:
dropped_count += 1
if kept_frames:
consumed_source_frames.append(frame.source_frame)
_append_audio_segment(
audio_segments,
start=probe.frame_timestamps[frame.source_frame],
duration=duration,
)
previous = kept_frames[-1]
kept_frames[-1] = OutputFrameTiming(
output_index=previous.output_index,
source_id=previous.source_id,
source_frame=previous.source_frame,
start=previous.start,
duration=previous.duration + duration,
)
last_consumed_source_frame = frame.source_frame
else:
leading_trimmed = True
continue
consumed_source_frames.append(frame.source_frame)
if frame.matrix is not None:
matrices.add(frame.matrix)
_append_audio_segment(
audio_segments,
start=probe.frame_timestamps[frame.source_frame],
duration=duration,
)
kept_frames.append(
OutputFrameTiming(
output_index=len(kept_frames),
source_id=frame.source_id,
source_frame=frame.source_frame,
start=probe.frame_timestamps[frame.source_frame],
duration=duration,
)
)
last_consumed_source_frame = frame.source_frame
if not kept_frames:
raise RuntimeError(f"No kept output frames after validation for {probe.path}")
if len(matrices) > 1:
raise RuntimeError(
f"Output frames have mixed _Matrix values for {probe.path}: "
+ ", ".join(str(value) for value in sorted(matrices))
)
source_count = len(probe.frame_timestamps)
beginning_trimmed = leading_trimmed or kept_frames[0].source_frame > 0
if last_consumed_source_frame is None:
last_consumed_source_frame = kept_frames[-1].source_frame
ending_trimmed = last_consumed_source_frame < source_count - 1
durations = [frame.duration for frame in kept_frames]
return OutputTimeline(
frames=kept_frames,
audio_segments=audio_segments,
consumed_source_frames=consumed_source_frames,
duration_seconds=sum(durations),
timing_kind=_classify_durations(durations),
beginning_trimmed=beginning_trimmed,
ending_trimmed=ending_trimmed,
dropped_frame_count=dropped_count,
source_frame_count=source_count,
matrix=next(iter(matrices)) if matrices else None,
)
def _append_audio_segment(
segments: list[tuple[float, float]],
*,
start: float,
duration: float,
) -> None:
if not segments:
segments.append((start, duration))
return
previous_start, previous_duration = segments[-1]
previous_end = previous_start + previous_duration
if abs(previous_end - start) <= 0.001:
segments[-1] = (previous_start, previous_duration + duration)
else:
segments.append((start, duration))
def _source_frame_durations(probe: VideoProbe) -> list[float]:
timestamps = probe.frame_timestamps
if not timestamps:
raise RuntimeError(f"No probed frame timestamps for {probe.path}")
durations: list[float] = []
for current, next_timestamp in zip(timestamps, timestamps[1:]):
durations.append(max(0.0, next_timestamp - current))
fallback = statistics.median(durations) if durations else 0.0
if probe.duration_seconds is not None and len(timestamps) > 1:
last_duration = probe.duration_seconds - timestamps[-1]
if last_duration <= 0:
last_duration = fallback
else:
last_duration = fallback
durations.append(max(0.0, last_duration))
return durations
def _classify_durations(durations: list[float]) -> str:
if len(durations) < 2:
return "unknown"
median = statistics.median(durations)
max_deviation = max(abs(duration - median) for duration in durations)
return "vfr" if max_deviation > 0.002 else "cfr"