Enhance Avisynth video workflow and metadata tools
This commit is contained in:
+103
-13
@@ -8,11 +8,12 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from tools.console import prompt_input, prompt_yes_no
|
||||
from tools.video_color import ColorMetadata
|
||||
from tools.exiftool import run_exiftool_command
|
||||
from tools.filesystem import unique_path
|
||||
from tools.filenames import format_rounded_filename_stem, round_datetime_to_second
|
||||
from tools.metadata_copy import copy_meaningful_metadata
|
||||
from tools.video_inputs import VideoInput
|
||||
from tools.video_options import OutputNamingOptions
|
||||
from tools.video_probe import VideoProbe, probe_video
|
||||
from tools.video_timeline import OutputTimeline
|
||||
from tools.video_formatting import join_parts, video_parts
|
||||
@@ -24,6 +25,12 @@ class EncodedItem:
|
||||
output: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputNamingOptions:
|
||||
use_timestamps: bool
|
||||
timezone_value: timezone
|
||||
|
||||
|
||||
def planned_output_path(
|
||||
*,
|
||||
script: Path,
|
||||
@@ -65,12 +72,11 @@ def output_begin_timestamp(
|
||||
if probe.duration_seconds is None:
|
||||
raise RuntimeError(f"No source duration found for {probe.path}")
|
||||
|
||||
source_end = probe.metadata_end
|
||||
if source_end.tzinfo is None:
|
||||
source_end = source_end.replace(tzinfo=timezone.utc)
|
||||
source_begin = source_end - timedelta(seconds=probe.duration_seconds)
|
||||
source_begin = _stretched_source_begin_timestamp(probe, timeline)
|
||||
first_frame_start = timeline.frames[0].start
|
||||
return (source_begin + timedelta(seconds=first_frame_start)).astimezone(
|
||||
return (source_begin + timedelta(
|
||||
seconds=first_frame_start * timeline.absolute_timestretch
|
||||
)).astimezone(
|
||||
output_timezone
|
||||
)
|
||||
|
||||
@@ -78,10 +84,47 @@ def output_begin_timestamp(
|
||||
def output_end_timestamp(
|
||||
probe: VideoProbe,
|
||||
timeline: OutputTimeline,
|
||||
output_duration: float,
|
||||
) -> datetime:
|
||||
begin = output_begin_timestamp(probe, timeline, timezone.utc)
|
||||
return begin + timedelta(seconds=output_duration)
|
||||
source_begin = _stretched_source_begin_timestamp(probe, timeline)
|
||||
return source_begin + timedelta(
|
||||
seconds=_last_consumed_source_end(probe, timeline)
|
||||
* timeline.absolute_timestretch
|
||||
)
|
||||
|
||||
|
||||
def _stretched_source_begin_timestamp(
|
||||
probe: VideoProbe,
|
||||
timeline: OutputTimeline,
|
||||
) -> datetime:
|
||||
if probe.metadata_end is None:
|
||||
raise RuntimeError(f"No metadata end timestamp found for {probe.path}")
|
||||
if probe.duration_seconds is None:
|
||||
raise RuntimeError(f"No source duration found for {probe.path}")
|
||||
source_end = probe.metadata_end
|
||||
if source_end.tzinfo is None:
|
||||
source_end = source_end.replace(tzinfo=timezone.utc)
|
||||
return source_end - timedelta(
|
||||
seconds=probe.duration_seconds * timeline.absolute_timestretch
|
||||
)
|
||||
|
||||
|
||||
def _last_consumed_source_end(probe: VideoProbe, timeline: OutputTimeline) -> float:
|
||||
source_frames = timeline.consumed_source_frames or [
|
||||
frame.source_frame for frame in timeline.frames
|
||||
]
|
||||
if not source_frames:
|
||||
raise RuntimeError(f"No source frames were consumed for {probe.path}")
|
||||
source_frame = max(source_frames)
|
||||
if source_frame >= len(probe.frame_timestamps):
|
||||
raise RuntimeError(
|
||||
f"source frame {source_frame} is outside probed frame table for {probe.path}"
|
||||
)
|
||||
start = probe.frame_timestamps[source_frame]
|
||||
if source_frame + 1 < len(probe.frame_timestamps):
|
||||
return probe.frame_timestamps[source_frame + 1]
|
||||
if probe.duration_seconds is None:
|
||||
raise RuntimeError(f"No source duration found for {probe.path}")
|
||||
return max(start, probe.duration_seconds)
|
||||
|
||||
|
||||
def write_video_end_timestamp(
|
||||
@@ -89,13 +132,15 @@ def write_video_end_timestamp(
|
||||
output: Path,
|
||||
probe: VideoProbe,
|
||||
timeline: OutputTimeline,
|
||||
output_duration: float,
|
||||
) -> None:
|
||||
if output.suffix.lower() not in {".mp4", ".mov"}:
|
||||
print(f" skipped container timestamp write for {output.suffix}")
|
||||
return
|
||||
end_time = round_datetime_to_second(
|
||||
output_end_timestamp(probe, timeline, output_duration).astimezone(timezone.utc)
|
||||
output_end_timestamp(
|
||||
probe,
|
||||
timeline,
|
||||
).astimezone(timezone.utc)
|
||||
)
|
||||
timestamp = end_time.strftime("%Y:%m:%d %H:%M:%S")
|
||||
args = [
|
||||
@@ -115,11 +160,26 @@ def write_video_end_timestamp(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f" wrote video end timestamp: {timestamp} UTC")
|
||||
speed_note = (
|
||||
""
|
||||
if timeline.absolute_timestretch == 1
|
||||
else f" ({timeline.absolute_timestretch:g}x real-time)"
|
||||
)
|
||||
print(f" wrote video end timestamp: {timestamp} UTC{speed_note}")
|
||||
|
||||
|
||||
def copy_video_metadata(exiftool: str, source: Path, output: Path) -> None:
|
||||
copy_meaningful_metadata(exiftool, source, output)
|
||||
copy_meaningful_metadata(
|
||||
exiftool,
|
||||
source,
|
||||
output,
|
||||
extra_excluded_tags=[
|
||||
"--QuickTime:ColorRepresentation",
|
||||
"--QuickTime:ColorPrimaries",
|
||||
"--QuickTime:TransferCharacteristics",
|
||||
"--QuickTime:MatrixCoefficients",
|
||||
],
|
||||
)
|
||||
if output.suffix.lower() in {".mp4", ".mov"}:
|
||||
print(" copied source metadata")
|
||||
else:
|
||||
@@ -189,6 +249,36 @@ def validate_encoded_video(
|
||||
return probe
|
||||
|
||||
|
||||
def validate_output_color_metadata(
|
||||
output: Path,
|
||||
probe: VideoProbe,
|
||||
timeline: OutputTimeline,
|
||||
*,
|
||||
expected_hdr10plus: bool = False,
|
||||
) -> None:
|
||||
expected = ColorMetadata(
|
||||
timeline.matrix, timeline.primaries, timeline.transfer, timeline.color_range
|
||||
).expected_probe_values()
|
||||
actual = {
|
||||
"matrix": probe.color_matrix,
|
||||
"primaries": probe.color_primaries,
|
||||
"transfer": probe.color_transfer,
|
||||
"range": probe.color_range,
|
||||
}
|
||||
mismatches = [
|
||||
f"{name} {actual[name] or 'missing'} (expected {value})"
|
||||
for name, value in expected.items()
|
||||
if value is not None and actual[name] != value
|
||||
]
|
||||
if mismatches:
|
||||
raise RuntimeError(
|
||||
f"Encoded output lost color metadata for {output}: "
|
||||
+ "; ".join(mismatches)
|
||||
)
|
||||
if expected_hdr10plus and not probe.hdr10plus:
|
||||
raise RuntimeError(f"Encoded output lost HDR10+ metadata for {output}")
|
||||
|
||||
|
||||
def validate_encoded_timestamps(
|
||||
output: Path,
|
||||
actual: list[float],
|
||||
|
||||
Reference in New Issue
Block a user